300 lines
11 KiB
Python
300 lines
11 KiB
Python
|
# Copyright (C) 2016 Intel Corporation
|
||
|
# Copyright (C) 2020 Savoir-Faire Linux
|
||
|
#
|
||
|
# SPDX-License-Identifier: GPL-2.0-only
|
||
|
#
|
||
|
"""Recipe creation tool - npm module support plugin"""
|
||
|
|
||
|
import json
|
||
|
import logging
|
||
|
import os
|
||
|
import re
|
||
|
import sys
|
||
|
import tempfile
|
||
|
import bb
|
||
|
from bb.fetch2.npm import NpmEnvironment
|
||
|
from bb.fetch2.npmsw import foreach_dependencies
|
||
|
from recipetool.create import RecipeHandler
|
||
|
from recipetool.create import get_license_md5sums
|
||
|
from recipetool.create import guess_license
|
||
|
from recipetool.create import split_pkg_licenses
|
||
|
logger = logging.getLogger('recipetool')
|
||
|
|
||
|
TINFOIL = None
|
||
|
|
||
|
def tinfoil_init(instance):
|
||
|
"""Initialize tinfoil"""
|
||
|
global TINFOIL
|
||
|
TINFOIL = instance
|
||
|
|
||
|
class NpmRecipeHandler(RecipeHandler):
|
||
|
"""Class to handle the npm recipe creation"""
|
||
|
|
||
|
@staticmethod
|
||
|
def _npm_name(name):
|
||
|
"""Generate a Yocto friendly npm name"""
|
||
|
name = re.sub("/", "-", name)
|
||
|
name = name.lower()
|
||
|
name = re.sub(r"[^\-a-z0-9]", "", name)
|
||
|
name = name.strip("-")
|
||
|
return name
|
||
|
|
||
|
@staticmethod
|
||
|
def _get_registry(lines):
|
||
|
"""Get the registry value from the 'npm://registry' url"""
|
||
|
registry = None
|
||
|
|
||
|
def _handle_registry(varname, origvalue, op, newlines):
|
||
|
nonlocal registry
|
||
|
if origvalue.startswith("npm://"):
|
||
|
registry = re.sub(r"^npm://", "http://", origvalue.split(";")[0])
|
||
|
return origvalue, None, 0, True
|
||
|
|
||
|
bb.utils.edit_metadata(lines, ["SRC_URI"], _handle_registry)
|
||
|
|
||
|
return registry
|
||
|
|
||
|
@staticmethod
|
||
|
def _ensure_npm():
|
||
|
"""Check if the 'npm' command is available in the recipes"""
|
||
|
if not TINFOIL.recipes_parsed:
|
||
|
TINFOIL.parse_recipes()
|
||
|
|
||
|
try:
|
||
|
d = TINFOIL.parse_recipe("nodejs-native")
|
||
|
except bb.providers.NoProvider:
|
||
|
bb.error("Nothing provides 'nodejs-native' which is required for the build")
|
||
|
bb.note("You will likely need to add a layer that provides nodejs")
|
||
|
sys.exit(14)
|
||
|
|
||
|
bindir = d.getVar("STAGING_BINDIR_NATIVE")
|
||
|
npmpath = os.path.join(bindir, "npm")
|
||
|
|
||
|
if not os.path.exists(npmpath):
|
||
|
TINFOIL.build_targets("nodejs-native", "addto_recipe_sysroot")
|
||
|
|
||
|
if not os.path.exists(npmpath):
|
||
|
bb.error("Failed to add 'npm' to sysroot")
|
||
|
sys.exit(14)
|
||
|
|
||
|
return bindir
|
||
|
|
||
|
@staticmethod
|
||
|
def _npm_global_configs(dev):
|
||
|
"""Get the npm global configuration"""
|
||
|
configs = []
|
||
|
|
||
|
if dev:
|
||
|
configs.append(("also", "development"))
|
||
|
else:
|
||
|
configs.append(("only", "production"))
|
||
|
|
||
|
configs.append(("save", "false"))
|
||
|
configs.append(("package-lock", "false"))
|
||
|
configs.append(("shrinkwrap", "false"))
|
||
|
return configs
|
||
|
|
||
|
def _run_npm_install(self, d, srctree, registry, dev):
|
||
|
"""Run the 'npm install' command without building the addons"""
|
||
|
configs = self._npm_global_configs(dev)
|
||
|
configs.append(("ignore-scripts", "true"))
|
||
|
|
||
|
if registry:
|
||
|
configs.append(("registry", registry))
|
||
|
|
||
|
bb.utils.remove(os.path.join(srctree, "node_modules"), recurse=True)
|
||
|
|
||
|
env = NpmEnvironment(d, configs=configs)
|
||
|
env.run("npm install", workdir=srctree)
|
||
|
|
||
|
def _generate_shrinkwrap(self, d, srctree, dev):
|
||
|
"""Check and generate the 'npm-shrinkwrap.json' file if needed"""
|
||
|
configs = self._npm_global_configs(dev)
|
||
|
|
||
|
env = NpmEnvironment(d, configs=configs)
|
||
|
env.run("npm shrinkwrap", workdir=srctree)
|
||
|
|
||
|
return os.path.join(srctree, "npm-shrinkwrap.json")
|
||
|
|
||
|
def _handle_licenses(self, srctree, shrinkwrap_file, dev):
|
||
|
"""Return the extra license files and the list of packages"""
|
||
|
licfiles = []
|
||
|
packages = {}
|
||
|
|
||
|
# Handle the parent package
|
||
|
packages["${PN}"] = ""
|
||
|
|
||
|
def _licfiles_append_fallback_readme_files(destdir):
|
||
|
"""Append README files as fallback to license files if a license files is missing"""
|
||
|
|
||
|
fallback = True
|
||
|
readmes = []
|
||
|
basedir = os.path.join(srctree, destdir)
|
||
|
for fn in os.listdir(basedir):
|
||
|
upper = fn.upper()
|
||
|
if upper.startswith("README"):
|
||
|
fullpath = os.path.join(basedir, fn)
|
||
|
readmes.append(fullpath)
|
||
|
if upper.startswith("COPYING") or "LICENCE" in upper or "LICENSE" in upper:
|
||
|
fallback = False
|
||
|
if fallback:
|
||
|
for readme in readmes:
|
||
|
licfiles.append(os.path.relpath(readme, srctree))
|
||
|
|
||
|
# Handle the dependencies
|
||
|
def _handle_dependency(name, params, deptree):
|
||
|
suffix = "-".join([self._npm_name(dep) for dep in deptree])
|
||
|
destdirs = [os.path.join("node_modules", dep) for dep in deptree]
|
||
|
destdir = os.path.join(*destdirs)
|
||
|
packages["${PN}-" + suffix] = destdir
|
||
|
_licfiles_append_fallback_readme_files(destdir)
|
||
|
|
||
|
with open(shrinkwrap_file, "r") as f:
|
||
|
shrinkwrap = json.load(f)
|
||
|
|
||
|
foreach_dependencies(shrinkwrap, _handle_dependency, dev)
|
||
|
|
||
|
return licfiles, packages
|
||
|
|
||
|
def process(self, srctree, classes, lines_before, lines_after, handled, extravalues):
|
||
|
"""Handle the npm recipe creation"""
|
||
|
|
||
|
if "buildsystem" in handled:
|
||
|
return False
|
||
|
|
||
|
files = RecipeHandler.checkfiles(srctree, ["package.json"])
|
||
|
|
||
|
if not files:
|
||
|
return False
|
||
|
|
||
|
with open(files[0], "r") as f:
|
||
|
data = json.load(f)
|
||
|
|
||
|
if "name" not in data or "version" not in data:
|
||
|
return False
|
||
|
|
||
|
extravalues["PN"] = self._npm_name(data["name"])
|
||
|
extravalues["PV"] = data["version"]
|
||
|
|
||
|
if "description" in data:
|
||
|
extravalues["SUMMARY"] = data["description"]
|
||
|
|
||
|
if "homepage" in data:
|
||
|
extravalues["HOMEPAGE"] = data["homepage"]
|
||
|
|
||
|
dev = bb.utils.to_boolean(str(extravalues.get("NPM_INSTALL_DEV", "0")), False)
|
||
|
registry = self._get_registry(lines_before)
|
||
|
|
||
|
bb.note("Checking if npm is available ...")
|
||
|
# The native npm is used here (and not the host one) to ensure that the
|
||
|
# npm version is high enough to ensure an efficient dependency tree
|
||
|
# resolution and avoid issue with the shrinkwrap file format.
|
||
|
# Moreover the native npm is mandatory for the build.
|
||
|
bindir = self._ensure_npm()
|
||
|
|
||
|
d = bb.data.createCopy(TINFOIL.config_data)
|
||
|
d.prependVar("PATH", bindir + ":")
|
||
|
d.setVar("S", srctree)
|
||
|
|
||
|
bb.note("Generating shrinkwrap file ...")
|
||
|
# To generate the shrinkwrap file the dependencies have to be installed
|
||
|
# first. During the generation process some files may be updated /
|
||
|
# deleted. By default devtool tracks the diffs in the srctree and raises
|
||
|
# errors when finishing the recipe if some diffs are found.
|
||
|
git_exclude_file = os.path.join(srctree, ".git", "info", "exclude")
|
||
|
if os.path.exists(git_exclude_file):
|
||
|
with open(git_exclude_file, "r+") as f:
|
||
|
lines = f.readlines()
|
||
|
for line in ["/node_modules/", "/npm-shrinkwrap.json"]:
|
||
|
if line not in lines:
|
||
|
f.write(line + "\n")
|
||
|
|
||
|
lock_file = os.path.join(srctree, "package-lock.json")
|
||
|
lock_copy = lock_file + ".copy"
|
||
|
if os.path.exists(lock_file):
|
||
|
bb.utils.copyfile(lock_file, lock_copy)
|
||
|
|
||
|
self._run_npm_install(d, srctree, registry, dev)
|
||
|
shrinkwrap_file = self._generate_shrinkwrap(d, srctree, dev)
|
||
|
|
||
|
with open(shrinkwrap_file, "r") as f:
|
||
|
shrinkwrap = json.load(f)
|
||
|
|
||
|
if os.path.exists(lock_copy):
|
||
|
bb.utils.movefile(lock_copy, lock_file)
|
||
|
|
||
|
# Add the shrinkwrap file as 'extrafiles'
|
||
|
shrinkwrap_copy = shrinkwrap_file + ".copy"
|
||
|
bb.utils.copyfile(shrinkwrap_file, shrinkwrap_copy)
|
||
|
extravalues.setdefault("extrafiles", {})
|
||
|
extravalues["extrafiles"]["npm-shrinkwrap.json"] = shrinkwrap_copy
|
||
|
|
||
|
url_local = "npmsw://%s" % shrinkwrap_file
|
||
|
url_recipe= "npmsw://${THISDIR}/${BPN}/npm-shrinkwrap.json"
|
||
|
|
||
|
if dev:
|
||
|
url_local += ";dev=1"
|
||
|
url_recipe += ";dev=1"
|
||
|
|
||
|
# Add the npmsw url in the SRC_URI of the generated recipe
|
||
|
def _handle_srcuri(varname, origvalue, op, newlines):
|
||
|
"""Update the version value and add the 'npmsw://' url"""
|
||
|
value = origvalue.replace("version=" + data["version"], "version=${PV}")
|
||
|
value = value.replace("version=latest", "version=${PV}")
|
||
|
values = [line.strip() for line in value.strip('\n').splitlines()]
|
||
|
if "dependencies" in shrinkwrap:
|
||
|
values.append(url_recipe)
|
||
|
return values, None, 4, False
|
||
|
|
||
|
(_, newlines) = bb.utils.edit_metadata(lines_before, ["SRC_URI"], _handle_srcuri)
|
||
|
lines_before[:] = [line.rstrip('\n') for line in newlines]
|
||
|
|
||
|
# In order to generate correct licence checksums in the recipe the
|
||
|
# dependencies have to be fetched again using the npmsw url
|
||
|
bb.note("Fetching npm dependencies ...")
|
||
|
bb.utils.remove(os.path.join(srctree, "node_modules"), recurse=True)
|
||
|
fetcher = bb.fetch2.Fetch([url_local], d)
|
||
|
fetcher.download()
|
||
|
fetcher.unpack(srctree)
|
||
|
|
||
|
bb.note("Handling licences ...")
|
||
|
(licfiles, packages) = self._handle_licenses(srctree, shrinkwrap_file, dev)
|
||
|
|
||
|
def _guess_odd_license(licfiles):
|
||
|
import bb
|
||
|
|
||
|
md5sums = get_license_md5sums(d, linenumbers=True)
|
||
|
|
||
|
chksums = []
|
||
|
licenses = []
|
||
|
for licfile in licfiles:
|
||
|
f = os.path.join(srctree, licfile)
|
||
|
md5value = bb.utils.md5_file(f)
|
||
|
(license, beginline, endline, md5) = md5sums.get(md5value,
|
||
|
(None, "", "", ""))
|
||
|
if not license:
|
||
|
license = "Unknown"
|
||
|
logger.info("Please add the following line for '%s' to a "
|
||
|
"'lib/recipetool/licenses.csv' and replace `Unknown`, "
|
||
|
"`X`, `Y` and `MD5` with the license, begin line, "
|
||
|
"end line and partial MD5 checksum:\n" \
|
||
|
"%s,Unknown,X,Y,MD5" % (licfile, md5value))
|
||
|
chksums.append("file://%s%s%s;md5=%s" % (licfile,
|
||
|
";beginline=%s" % (beginline) if beginline else "",
|
||
|
";endline=%s" % (endline) if endline else "",
|
||
|
md5 if md5 else md5value))
|
||
|
licenses.append((license, licfile, md5value))
|
||
|
return (licenses, chksums)
|
||
|
|
||
|
(licenses, extravalues["LIC_FILES_CHKSUM"]) = _guess_odd_license(licfiles)
|
||
|
split_pkg_licenses([*licenses, *guess_license(srctree, d)], packages, lines_after)
|
||
|
|
||
|
classes.append("npm")
|
||
|
handled.append("buildsystem")
|
||
|
|
||
|
return True
|
||
|
|
||
|
def register_recipe_handlers(handlers):
|
||
|
"""Register the npm handler"""
|
||
|
handlers.append((NpmRecipeHandler(), 60))
|