create_npm.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. # Recipe creation tool - node.js NPM module support plugin
  2. #
  3. # Copyright (C) 2016 Intel Corporation
  4. #
  5. # This program is free software; you can redistribute it and/or modify
  6. # it under the terms of the GNU General Public License version 2 as
  7. # published by the Free Software Foundation.
  8. #
  9. # This program is distributed in the hope that it will be useful,
  10. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. # GNU General Public License for more details.
  13. #
  14. # You should have received a copy of the GNU General Public License along
  15. # with this program; if not, write to the Free Software Foundation, Inc.,
  16. # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
  17. import os
  18. import logging
  19. import subprocess
  20. import tempfile
  21. import shutil
  22. import json
  23. from recipetool.create import RecipeHandler, split_pkg_licenses, handle_license_vars
  24. logger = logging.getLogger('recipetool')
  25. tinfoil = None
  26. def tinfoil_init(instance):
  27. global tinfoil
  28. tinfoil = instance
  29. class NpmRecipeHandler(RecipeHandler):
  30. lockdownpath = None
  31. def _ensure_npm(self, fixed_setup=False):
  32. if not tinfoil.recipes_parsed:
  33. tinfoil.parse_recipes()
  34. try:
  35. rd = tinfoil.parse_recipe('nodejs-native')
  36. except bb.providers.NoProvider:
  37. if fixed_setup:
  38. msg = 'nodejs-native is required for npm but is not available within this SDK'
  39. else:
  40. msg = 'nodejs-native is required for npm but is not available - you will likely need to add a layer that provides nodejs'
  41. logger.error(msg)
  42. return None
  43. bindir = rd.getVar('STAGING_BINDIR_NATIVE')
  44. npmpath = os.path.join(bindir, 'npm')
  45. if not os.path.exists(npmpath):
  46. tinfoil.build_targets('nodejs-native', 'addto_recipe_sysroot')
  47. if not os.path.exists(npmpath):
  48. logger.error('npm required to process specified source, but nodejs-native did not seem to populate it')
  49. return None
  50. return bindir
  51. def _handle_license(self, data):
  52. '''
  53. Handle the license value from an npm package.json file
  54. '''
  55. license = None
  56. if 'license' in data:
  57. license = data['license']
  58. if isinstance(license, dict):
  59. license = license.get('type', None)
  60. if license:
  61. if 'OR' in license:
  62. license = license.replace('OR', '|')
  63. license = license.replace('AND', '&')
  64. license = license.replace(' ', '_')
  65. if not license[0] == '(':
  66. license = '(' + license + ')'
  67. else:
  68. license = license.replace('AND', '&')
  69. if license[0] == '(':
  70. license = license[1:]
  71. if license[-1] == ')':
  72. license = license[:-1]
  73. license = license.replace('MIT/X11', 'MIT')
  74. license = license.replace('Public Domain', 'PD')
  75. license = license.replace('SEE LICENSE IN EULA',
  76. 'SEE-LICENSE-IN-EULA')
  77. return license
  78. def _shrinkwrap(self, srctree, localfilesdir, extravalues, lines_before, d):
  79. try:
  80. runenv = dict(os.environ, PATH=d.getVar('PATH'))
  81. bb.process.run('npm shrinkwrap', cwd=srctree, stderr=subprocess.STDOUT, env=runenv, shell=True)
  82. except bb.process.ExecutionError as e:
  83. logger.warning('npm shrinkwrap failed:\n%s' % e.stdout)
  84. return
  85. tmpfile = os.path.join(localfilesdir, 'npm-shrinkwrap.json')
  86. shutil.move(os.path.join(srctree, 'npm-shrinkwrap.json'), tmpfile)
  87. extravalues.setdefault('extrafiles', {})
  88. extravalues['extrafiles']['npm-shrinkwrap.json'] = tmpfile
  89. lines_before.append('NPM_SHRINKWRAP := "${THISDIR}/${PN}/npm-shrinkwrap.json"')
  90. def _lockdown(self, srctree, localfilesdir, extravalues, lines_before, d):
  91. runenv = dict(os.environ, PATH=d.getVar('PATH'))
  92. if not NpmRecipeHandler.lockdownpath:
  93. NpmRecipeHandler.lockdownpath = tempfile.mkdtemp('recipetool-npm-lockdown')
  94. bb.process.run('npm install lockdown --prefix %s' % NpmRecipeHandler.lockdownpath,
  95. cwd=srctree, stderr=subprocess.STDOUT, env=runenv, shell=True)
  96. relockbin = os.path.join(NpmRecipeHandler.lockdownpath, 'node_modules', 'lockdown', 'relock.js')
  97. if not os.path.exists(relockbin):
  98. logger.warning('Could not find relock.js within lockdown directory; skipping lockdown')
  99. return
  100. try:
  101. bb.process.run('node %s' % relockbin, cwd=srctree, stderr=subprocess.STDOUT, env=runenv, shell=True)
  102. except bb.process.ExecutionError as e:
  103. logger.warning('lockdown-relock failed:\n%s' % e.stdout)
  104. return
  105. tmpfile = os.path.join(localfilesdir, 'lockdown.json')
  106. shutil.move(os.path.join(srctree, 'lockdown.json'), tmpfile)
  107. extravalues.setdefault('extrafiles', {})
  108. extravalues['extrafiles']['lockdown.json'] = tmpfile
  109. lines_before.append('NPM_LOCKDOWN := "${THISDIR}/${PN}/lockdown.json"')
  110. def _handle_dependencies(self, d, deps, optdeps, devdeps, lines_before, srctree):
  111. import scriptutils
  112. # If this isn't a single module we need to get the dependencies
  113. # and add them to SRC_URI
  114. def varfunc(varname, origvalue, op, newlines):
  115. if varname == 'SRC_URI':
  116. if not origvalue.startswith('npm://'):
  117. src_uri = origvalue.split()
  118. deplist = {}
  119. for dep, depver in optdeps.items():
  120. depdata = self.get_npm_data(dep, depver, d)
  121. if self.check_npm_optional_dependency(depdata):
  122. deplist[dep] = depdata
  123. for dep, depver in devdeps.items():
  124. depdata = self.get_npm_data(dep, depver, d)
  125. if self.check_npm_optional_dependency(depdata):
  126. deplist[dep] = depdata
  127. for dep, depver in deps.items():
  128. depdata = self.get_npm_data(dep, depver, d)
  129. deplist[dep] = depdata
  130. extra_urls = []
  131. for dep, depdata in deplist.items():
  132. version = depdata.get('version', None)
  133. if version:
  134. url = 'npm://registry.npmjs.org;name=%s;version=%s;subdir=node_modules/%s' % (dep, version, dep)
  135. extra_urls.append(url)
  136. if extra_urls:
  137. scriptutils.fetch_url(tinfoil, ' '.join(extra_urls), None, srctree, logger)
  138. src_uri.extend(extra_urls)
  139. return src_uri, None, -1, True
  140. return origvalue, None, 0, True
  141. updated, newlines = bb.utils.edit_metadata(lines_before, ['SRC_URI'], varfunc)
  142. if updated:
  143. del lines_before[:]
  144. for line in newlines:
  145. # Hack to avoid newlines that edit_metadata inserts
  146. if line.endswith('\n'):
  147. line = line[:-1]
  148. lines_before.append(line)
  149. return updated
  150. def process(self, srctree, classes, lines_before, lines_after, handled, extravalues):
  151. import bb.utils
  152. import oe.package
  153. from collections import OrderedDict
  154. if 'buildsystem' in handled:
  155. return False
  156. def read_package_json(fn):
  157. with open(fn, 'r', errors='surrogateescape') as f:
  158. return json.loads(f.read())
  159. files = RecipeHandler.checkfiles(srctree, ['package.json'])
  160. if files:
  161. d = bb.data.createCopy(tinfoil.config_data)
  162. npm_bindir = self._ensure_npm()
  163. if not npm_bindir:
  164. sys.exit(14)
  165. d.prependVar('PATH', '%s:' % npm_bindir)
  166. data = read_package_json(files[0])
  167. if 'name' in data and 'version' in data:
  168. extravalues['PN'] = data['name']
  169. extravalues['PV'] = data['version']
  170. classes.append('npm')
  171. handled.append('buildsystem')
  172. if 'description' in data:
  173. extravalues['SUMMARY'] = data['description']
  174. if 'homepage' in data:
  175. extravalues['HOMEPAGE'] = data['homepage']
  176. fetchdev = extravalues['fetchdev'] or None
  177. deps, optdeps, devdeps = self.get_npm_package_dependencies(data, fetchdev)
  178. self._handle_dependencies(d, deps, optdeps, devdeps, lines_before, srctree)
  179. # Shrinkwrap
  180. localfilesdir = tempfile.mkdtemp(prefix='recipetool-npm')
  181. self._shrinkwrap(srctree, localfilesdir, extravalues, lines_before, d)
  182. # Lockdown
  183. self._lockdown(srctree, localfilesdir, extravalues, lines_before, d)
  184. # Split each npm module out to is own package
  185. npmpackages = oe.package.npm_split_package_dirs(srctree)
  186. licvalues = None
  187. for item in handled:
  188. if isinstance(item, tuple):
  189. if item[0] == 'license':
  190. licvalues = item[1]
  191. break
  192. if not licvalues:
  193. licvalues = handle_license_vars(srctree, lines_before, handled, extravalues, d)
  194. if licvalues:
  195. # Augment the license list with information we have in the packages
  196. licenses = {}
  197. license = self._handle_license(data)
  198. if license:
  199. licenses['${PN}'] = license
  200. for pkgname, pkgitem in npmpackages.items():
  201. _, pdata = pkgitem
  202. license = self._handle_license(pdata)
  203. if license:
  204. licenses[pkgname] = license
  205. # Now write out the package-specific license values
  206. # We need to strip out the json data dicts for this since split_pkg_licenses
  207. # isn't expecting it
  208. packages = OrderedDict((x,y[0]) for x,y in npmpackages.items())
  209. packages['${PN}'] = ''
  210. pkglicenses = split_pkg_licenses(licvalues, packages, lines_after, licenses)
  211. all_licenses = list(set([item.replace('_', ' ') for pkglicense in pkglicenses.values() for item in pkglicense]))
  212. if '&' in all_licenses:
  213. all_licenses.remove('&')
  214. extravalues['LICENSE'] = ' & '.join(all_licenses)
  215. # Need to move S setting after inherit npm
  216. for i, line in enumerate(lines_before):
  217. if line.startswith('S ='):
  218. lines_before.pop(i)
  219. lines_after.insert(0, '# Must be set after inherit npm since that itself sets S')
  220. lines_after.insert(1, line)
  221. break
  222. return True
  223. return False
  224. # FIXME this is duplicated from lib/bb/fetch2/npm.py
  225. def _parse_view(self, output):
  226. '''
  227. Parse the output of npm view --json; the last JSON result
  228. is assumed to be the one that we're interested in.
  229. '''
  230. pdata = None
  231. outdeps = {}
  232. datalines = []
  233. bracelevel = 0
  234. for line in output.splitlines():
  235. if bracelevel:
  236. datalines.append(line)
  237. elif '{' in line:
  238. datalines = []
  239. datalines.append(line)
  240. bracelevel = bracelevel + line.count('{') - line.count('}')
  241. if datalines:
  242. pdata = json.loads('\n'.join(datalines))
  243. return pdata
  244. # FIXME this is effectively duplicated from lib/bb/fetch2/npm.py
  245. # (split out from _getdependencies())
  246. def get_npm_data(self, pkg, version, d):
  247. import bb.fetch2
  248. pkgfullname = pkg
  249. if version != '*' and not '/' in version:
  250. pkgfullname += "@'%s'" % version
  251. logger.debug(2, "Calling getdeps on %s" % pkg)
  252. runenv = dict(os.environ, PATH=d.getVar('PATH'))
  253. fetchcmd = "npm view %s --json" % pkgfullname
  254. output, _ = bb.process.run(fetchcmd, stderr=subprocess.STDOUT, env=runenv, shell=True)
  255. data = self._parse_view(output)
  256. return data
  257. # FIXME this is effectively duplicated from lib/bb/fetch2/npm.py
  258. # (split out from _getdependencies())
  259. def get_npm_package_dependencies(self, pdata, fetchdev):
  260. dependencies = pdata.get('dependencies', {})
  261. optionalDependencies = pdata.get('optionalDependencies', {})
  262. dependencies.update(optionalDependencies)
  263. if fetchdev:
  264. devDependencies = pdata.get('devDependencies', {})
  265. dependencies.update(devDependencies)
  266. else:
  267. devDependencies = {}
  268. depsfound = {}
  269. optdepsfound = {}
  270. devdepsfound = {}
  271. for dep in dependencies:
  272. if dep in optionalDependencies:
  273. optdepsfound[dep] = dependencies[dep]
  274. elif dep in devDependencies:
  275. devdepsfound[dep] = dependencies[dep]
  276. else:
  277. depsfound[dep] = dependencies[dep]
  278. return depsfound, optdepsfound, devdepsfound
  279. # FIXME this is effectively duplicated from lib/bb/fetch2/npm.py
  280. # (split out from _getdependencies())
  281. def check_npm_optional_dependency(self, pdata):
  282. pkg_os = pdata.get('os', None)
  283. if pkg_os:
  284. if not isinstance(pkg_os, list):
  285. pkg_os = [pkg_os]
  286. blacklist = False
  287. for item in pkg_os:
  288. if item.startswith('!'):
  289. blacklist = True
  290. break
  291. if (not blacklist and 'linux' not in pkg_os) or '!linux' in pkg_os:
  292. pkg = pdata.get('name', 'Unnamed package')
  293. logger.debug(2, "Skipping %s since it's incompatible with Linux" % pkg)
  294. return False
  295. return True
  296. def register_recipe_handlers(handlers):
  297. handlers.append((NpmRecipeHandler(), 60))