create_npm.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. # Copyright (C) 2016 Intel Corporation
  2. # Copyright (C) 2020 Savoir-Faire Linux
  3. #
  4. # SPDX-License-Identifier: GPL-2.0-only
  5. #
  6. """Recipe creation tool - npm module support plugin"""
  7. import json
  8. import logging
  9. import os
  10. import re
  11. import sys
  12. import tempfile
  13. import bb
  14. from bb.fetch2.npm import NpmEnvironment
  15. from bb.fetch2.npm import npm_package
  16. from bb.fetch2.npmsw import foreach_dependencies
  17. from recipetool.create import RecipeHandler
  18. from recipetool.create import match_licenses, find_license_files, generate_common_licenses_chksums
  19. from recipetool.create import split_pkg_licenses
  20. logger = logging.getLogger('recipetool')
  21. TINFOIL = None
  22. def tinfoil_init(instance):
  23. """Initialize tinfoil"""
  24. global TINFOIL
  25. TINFOIL = instance
  26. class NpmRecipeHandler(RecipeHandler):
  27. """Class to handle the npm recipe creation"""
  28. @staticmethod
  29. def _get_registry(lines):
  30. """Get the registry value from the 'npm://registry' url"""
  31. registry = None
  32. def _handle_registry(varname, origvalue, op, newlines):
  33. nonlocal registry
  34. if origvalue.startswith("npm://"):
  35. registry = re.sub(r"^npm://", "http://", origvalue.split(";")[0])
  36. return origvalue, None, 0, True
  37. bb.utils.edit_metadata(lines, ["SRC_URI"], _handle_registry)
  38. return registry
  39. @staticmethod
  40. def _ensure_npm():
  41. """Check if the 'npm' command is available in the recipes"""
  42. if not TINFOIL.recipes_parsed:
  43. TINFOIL.parse_recipes()
  44. try:
  45. d = TINFOIL.parse_recipe("nodejs-native")
  46. except bb.providers.NoProvider:
  47. bb.error("Nothing provides 'nodejs-native' which is required for the build")
  48. bb.note("You will likely need to add a layer that provides nodejs")
  49. sys.exit(14)
  50. bindir = d.getVar("STAGING_BINDIR_NATIVE")
  51. npmpath = os.path.join(bindir, "npm")
  52. if not os.path.exists(npmpath):
  53. TINFOIL.build_targets("nodejs-native", "addto_recipe_sysroot")
  54. if not os.path.exists(npmpath):
  55. bb.error("Failed to add 'npm' to sysroot")
  56. sys.exit(14)
  57. return bindir
  58. @staticmethod
  59. def _npm_global_configs(dev):
  60. """Get the npm global configuration"""
  61. configs = []
  62. if dev:
  63. configs.append(("also", "development"))
  64. else:
  65. configs.append(("only", "production"))
  66. configs.append(("save", "false"))
  67. configs.append(("package-lock", "false"))
  68. configs.append(("shrinkwrap", "false"))
  69. return configs
  70. def _run_npm_install(self, d, srctree, registry, dev):
  71. """Run the 'npm install' command without building the addons"""
  72. configs = self._npm_global_configs(dev)
  73. configs.append(("ignore-scripts", "true"))
  74. if registry:
  75. configs.append(("registry", registry))
  76. bb.utils.remove(os.path.join(srctree, "node_modules"), recurse=True)
  77. env = NpmEnvironment(d, configs=configs)
  78. env.run("npm install", workdir=srctree)
  79. def _generate_shrinkwrap(self, d, srctree, dev):
  80. """Check and generate the 'npm-shrinkwrap.json' file if needed"""
  81. configs = self._npm_global_configs(dev)
  82. env = NpmEnvironment(d, configs=configs)
  83. env.run("npm shrinkwrap", workdir=srctree)
  84. return os.path.join(srctree, "npm-shrinkwrap.json")
  85. def _handle_licenses(self, srctree, shrinkwrap_file, dev):
  86. """Return the extra license files and the list of packages"""
  87. licfiles = []
  88. packages = {}
  89. # Licenses from package.json will point to COMMON_LICENSE_DIR so we need
  90. # to associate them explicitely to packages for split_pkg_licenses()
  91. fallback_licenses = dict()
  92. def _find_package_licenses(destdir):
  93. """Either find license files, or use package.json metadata"""
  94. def _get_licenses_from_package_json(package_json):
  95. with open(os.path.join(srctree, package_json), "r") as f:
  96. data = json.load(f)
  97. if "license" in data:
  98. licenses = data["license"].split(" ")
  99. licenses = [license.strip("()") for license in licenses if license != "OR" and license != "AND"]
  100. return [], licenses
  101. else:
  102. return [package_json], None
  103. basedir = os.path.join(srctree, destdir)
  104. licfiles = find_license_files(basedir)
  105. if len(licfiles) > 0:
  106. return licfiles, None
  107. else:
  108. # A license wasn't found in the package directory, so we'll use the package.json metadata
  109. pkg_json = os.path.join(basedir, "package.json")
  110. return _get_licenses_from_package_json(pkg_json)
  111. def _get_package_licenses(destdir, package):
  112. (package_licfiles, package_licenses) = _find_package_licenses(destdir)
  113. if package_licfiles:
  114. licfiles.extend(package_licfiles)
  115. else:
  116. fallback_licenses[package] = package_licenses
  117. # Handle the dependencies
  118. def _handle_dependency(name, params, destdir):
  119. deptree = destdir.split('node_modules/')
  120. suffix = "-".join([npm_package(dep) for dep in deptree])
  121. packages["${PN}" + suffix] = destdir
  122. _get_package_licenses(destdir, "${PN}" + suffix)
  123. with open(shrinkwrap_file, "r") as f:
  124. shrinkwrap = json.load(f)
  125. foreach_dependencies(shrinkwrap, _handle_dependency, dev)
  126. # Handle the parent package
  127. packages["${PN}"] = ""
  128. _get_package_licenses(srctree, "${PN}")
  129. return licfiles, packages, fallback_licenses
  130. # Handle the peer dependencies
  131. def _handle_peer_dependency(self, shrinkwrap_file):
  132. """Check if package has peer dependencies and show warning if it is the case"""
  133. with open(shrinkwrap_file, "r") as f:
  134. shrinkwrap = json.load(f)
  135. packages = shrinkwrap.get("packages", {})
  136. peer_deps = packages.get("", {}).get("peerDependencies", {})
  137. for peer_dep in peer_deps:
  138. peer_dep_yocto_name = npm_package(peer_dep)
  139. bb.warn(peer_dep + " is a peer dependencie of the actual package. " +
  140. "Please add this peer dependencie to the RDEPENDS variable as %s and generate its recipe with devtool"
  141. % peer_dep_yocto_name)
  142. def process(self, srctree, classes, lines_before, lines_after, handled, extravalues):
  143. """Handle the npm recipe creation"""
  144. if "buildsystem" in handled:
  145. return False
  146. files = RecipeHandler.checkfiles(srctree, ["package.json"])
  147. if not files:
  148. return False
  149. with open(files[0], "r") as f:
  150. data = json.load(f)
  151. if "name" not in data or "version" not in data:
  152. return False
  153. extravalues["PN"] = npm_package(data["name"])
  154. extravalues["PV"] = data["version"]
  155. if "description" in data:
  156. extravalues["SUMMARY"] = data["description"]
  157. if "homepage" in data:
  158. extravalues["HOMEPAGE"] = data["homepage"]
  159. dev = bb.utils.to_boolean(str(extravalues.get("NPM_INSTALL_DEV", "0")), False)
  160. registry = self._get_registry(lines_before)
  161. bb.note("Checking if npm is available ...")
  162. # The native npm is used here (and not the host one) to ensure that the
  163. # npm version is high enough to ensure an efficient dependency tree
  164. # resolution and avoid issue with the shrinkwrap file format.
  165. # Moreover the native npm is mandatory for the build.
  166. bindir = self._ensure_npm()
  167. d = bb.data.createCopy(TINFOIL.config_data)
  168. d.prependVar("PATH", bindir + ":")
  169. d.setVar("S", srctree)
  170. bb.note("Generating shrinkwrap file ...")
  171. # To generate the shrinkwrap file the dependencies have to be installed
  172. # first. During the generation process some files may be updated /
  173. # deleted. By default devtool tracks the diffs in the srctree and raises
  174. # errors when finishing the recipe if some diffs are found.
  175. git_exclude_file = os.path.join(srctree, ".git", "info", "exclude")
  176. if os.path.exists(git_exclude_file):
  177. with open(git_exclude_file, "r+") as f:
  178. lines = f.readlines()
  179. for line in ["/node_modules/", "/npm-shrinkwrap.json"]:
  180. if line not in lines:
  181. f.write(line + "\n")
  182. lock_file = os.path.join(srctree, "package-lock.json")
  183. lock_copy = lock_file + ".copy"
  184. if os.path.exists(lock_file):
  185. bb.utils.copyfile(lock_file, lock_copy)
  186. self._run_npm_install(d, srctree, registry, dev)
  187. shrinkwrap_file = self._generate_shrinkwrap(d, srctree, dev)
  188. with open(shrinkwrap_file, "r") as f:
  189. shrinkwrap = json.load(f)
  190. if os.path.exists(lock_copy):
  191. bb.utils.movefile(lock_copy, lock_file)
  192. # Add the shrinkwrap file as 'extrafiles'
  193. shrinkwrap_copy = shrinkwrap_file + ".copy"
  194. bb.utils.copyfile(shrinkwrap_file, shrinkwrap_copy)
  195. extravalues.setdefault("extrafiles", {})
  196. extravalues["extrafiles"]["npm-shrinkwrap.json"] = shrinkwrap_copy
  197. url_local = "npmsw://%s" % shrinkwrap_file
  198. url_recipe= "npmsw://${THISDIR}/${BPN}/npm-shrinkwrap.json"
  199. if dev:
  200. url_local += ";dev=1"
  201. url_recipe += ";dev=1"
  202. # Add the npmsw url in the SRC_URI of the generated recipe
  203. def _handle_srcuri(varname, origvalue, op, newlines):
  204. """Update the version value and add the 'npmsw://' url"""
  205. value = origvalue.replace("version=" + data["version"], "version=${PV}")
  206. value = value.replace("version=latest", "version=${PV}")
  207. values = [line.strip() for line in value.strip('\n').splitlines()]
  208. if "dependencies" in shrinkwrap.get("packages", {}).get("", {}):
  209. values.append(url_recipe)
  210. return values, None, 4, False
  211. (_, newlines) = bb.utils.edit_metadata(lines_before, ["SRC_URI"], _handle_srcuri)
  212. lines_before[:] = [line.rstrip('\n') for line in newlines]
  213. # In order to generate correct licence checksums in the recipe the
  214. # dependencies have to be fetched again using the npmsw url
  215. bb.note("Fetching npm dependencies ...")
  216. bb.utils.remove(os.path.join(srctree, "node_modules"), recurse=True)
  217. fetcher = bb.fetch2.Fetch([url_local], d)
  218. fetcher.download()
  219. fetcher.unpack(srctree)
  220. bb.note("Handling licences ...")
  221. (licfiles, packages, fallback_licenses) = self._handle_licenses(srctree, shrinkwrap_file, dev)
  222. licvalues = match_licenses(licfiles, srctree, d)
  223. split_pkg_licenses(licvalues, packages, lines_after, fallback_licenses)
  224. fallback_licenses_flat = [license for sublist in fallback_licenses.values() for license in sublist]
  225. extravalues["LIC_FILES_CHKSUM"] = generate_common_licenses_chksums(fallback_licenses_flat, d)
  226. extravalues["LICENSE"] = fallback_licenses_flat
  227. classes.append("npm")
  228. handled.append("buildsystem")
  229. # Check if package has peer dependencies and inform the user
  230. self._handle_peer_dependency(shrinkwrap_file)
  231. return True
  232. def register_recipe_handlers(handlers):
  233. """Register the npm handler"""
  234. handlers.append((NpmRecipeHandler(), 60))