create_npm.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  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, check_npm
  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 _handle_license(self, data):
  32. '''
  33. Handle the license value from an npm package.json file
  34. '''
  35. license = None
  36. if 'license' in data:
  37. license = data['license']
  38. if isinstance(license, dict):
  39. license = license.get('type', None)
  40. if license:
  41. if 'OR' in license:
  42. license = license.replace('OR', '|')
  43. license = license.replace('AND', '&')
  44. license = license.replace(' ', '_')
  45. if not license[0] == '(':
  46. license = '(' + license + ')'
  47. print('LICENSE: {}'.format(license))
  48. else:
  49. license = license.replace('AND', '&')
  50. if license[0] == '(':
  51. license = license[1:]
  52. if license[-1] == ')':
  53. license = license[:-1]
  54. license = license.replace('MIT/X11', 'MIT')
  55. license = license.replace('Public Domain', 'PD')
  56. license = license.replace('SEE LICENSE IN EULA',
  57. 'SEE-LICENSE-IN-EULA')
  58. return license
  59. def _shrinkwrap(self, srctree, localfilesdir, extravalues, lines_before, d):
  60. try:
  61. runenv = dict(os.environ, PATH=d.getVar('PATH'))
  62. bb.process.run('npm shrinkwrap', cwd=srctree, stderr=subprocess.STDOUT, env=runenv, shell=True)
  63. except bb.process.ExecutionError as e:
  64. logger.warn('npm shrinkwrap failed:\n%s' % e.stdout)
  65. return
  66. tmpfile = os.path.join(localfilesdir, 'npm-shrinkwrap.json')
  67. shutil.move(os.path.join(srctree, 'npm-shrinkwrap.json'), tmpfile)
  68. extravalues.setdefault('extrafiles', {})
  69. extravalues['extrafiles']['npm-shrinkwrap.json'] = tmpfile
  70. lines_before.append('NPM_SHRINKWRAP := "${THISDIR}/${PN}/npm-shrinkwrap.json"')
  71. def _lockdown(self, srctree, localfilesdir, extravalues, lines_before, d):
  72. runenv = dict(os.environ, PATH=d.getVar('PATH'))
  73. if not NpmRecipeHandler.lockdownpath:
  74. NpmRecipeHandler.lockdownpath = tempfile.mkdtemp('recipetool-npm-lockdown')
  75. bb.process.run('npm install lockdown --prefix %s' % NpmRecipeHandler.lockdownpath,
  76. cwd=srctree, stderr=subprocess.STDOUT, env=runenv, shell=True)
  77. relockbin = os.path.join(NpmRecipeHandler.lockdownpath, 'node_modules', 'lockdown', 'relock.js')
  78. if not os.path.exists(relockbin):
  79. logger.warn('Could not find relock.js within lockdown directory; skipping lockdown')
  80. return
  81. try:
  82. bb.process.run('node %s' % relockbin, cwd=srctree, stderr=subprocess.STDOUT, env=runenv, shell=True)
  83. except bb.process.ExecutionError as e:
  84. logger.warn('lockdown-relock failed:\n%s' % e.stdout)
  85. return
  86. tmpfile = os.path.join(localfilesdir, 'lockdown.json')
  87. shutil.move(os.path.join(srctree, 'lockdown.json'), tmpfile)
  88. extravalues.setdefault('extrafiles', {})
  89. extravalues['extrafiles']['lockdown.json'] = tmpfile
  90. lines_before.append('NPM_LOCKDOWN := "${THISDIR}/${PN}/lockdown.json"')
  91. def _handle_dependencies(self, d, deps, optdeps, devdeps, lines_before, srctree):
  92. import scriptutils
  93. # If this isn't a single module we need to get the dependencies
  94. # and add them to SRC_URI
  95. def varfunc(varname, origvalue, op, newlines):
  96. if varname == 'SRC_URI':
  97. if not origvalue.startswith('npm://'):
  98. src_uri = origvalue.split()
  99. deplist = {}
  100. for dep, depver in optdeps.items():
  101. depdata = self.get_npm_data(dep, depver, d)
  102. if self.check_npm_optional_dependency(depdata):
  103. deplist[dep] = depdata
  104. for dep, depver in devdeps.items():
  105. depdata = self.get_npm_data(dep, depver, d)
  106. if self.check_npm_optional_dependency(depdata):
  107. deplist[dep] = depdata
  108. for dep, depver in deps.items():
  109. depdata = self.get_npm_data(dep, depver, d)
  110. deplist[dep] = depdata
  111. extra_urls = []
  112. for dep, depdata in deplist.items():
  113. version = depdata.get('version', None)
  114. if version:
  115. url = 'npm://registry.npmjs.org;name=%s;version=%s;subdir=node_modules/%s' % (dep, version, dep)
  116. extra_urls.append(url)
  117. if extra_urls:
  118. scriptutils.fetch_url(tinfoil, ' '.join(extra_urls), None, srctree, logger)
  119. src_uri.extend(extra_urls)
  120. return src_uri, None, -1, True
  121. return origvalue, None, 0, True
  122. updated, newlines = bb.utils.edit_metadata(lines_before, ['SRC_URI'], varfunc)
  123. if updated:
  124. del lines_before[:]
  125. for line in newlines:
  126. # Hack to avoid newlines that edit_metadata inserts
  127. if line.endswith('\n'):
  128. line = line[:-1]
  129. lines_before.append(line)
  130. return updated
  131. def _replace_license_vars(self, srctree, lines_before, handled, extravalues, d):
  132. for item in handled:
  133. if isinstance(item, tuple):
  134. if item[0] == 'license':
  135. del item
  136. break
  137. calledvars = []
  138. def varfunc(varname, origvalue, op, newlines):
  139. if varname in ['LICENSE', 'LIC_FILES_CHKSUM']:
  140. for i, e in enumerate(reversed(newlines)):
  141. if not e.startswith('#'):
  142. stop = i
  143. while stop > 0:
  144. newlines.pop()
  145. stop -= 1
  146. break
  147. calledvars.append(varname)
  148. if len(calledvars) > 1:
  149. # The second time around, put the new license text in
  150. insertpos = len(newlines)
  151. handle_license_vars(srctree, newlines, handled, extravalues, d)
  152. return None, None, 0, True
  153. return origvalue, None, 0, True
  154. updated, newlines = bb.utils.edit_metadata(lines_before, ['LICENSE', 'LIC_FILES_CHKSUM'], varfunc)
  155. if updated:
  156. del lines_before[:]
  157. lines_before.extend(newlines)
  158. else:
  159. raise Exception('Did not find license variables')
  160. def process(self, srctree, classes, lines_before, lines_after, handled, extravalues):
  161. import bb.utils
  162. import oe
  163. from collections import OrderedDict
  164. if 'buildsystem' in handled:
  165. return False
  166. def read_package_json(fn):
  167. with open(fn, 'r', errors='surrogateescape') as f:
  168. return json.loads(f.read())
  169. files = RecipeHandler.checkfiles(srctree, ['package.json'])
  170. if files:
  171. d = bb.data.createCopy(tinfoil.config_data)
  172. npm_bindir = check_npm(tinfoil, self._devtool)
  173. d.prependVar('PATH', '%s:' % npm_bindir)
  174. data = read_package_json(files[0])
  175. if 'name' in data and 'version' in data:
  176. extravalues['PN'] = data['name']
  177. extravalues['PV'] = data['version']
  178. classes.append('npm')
  179. handled.append('buildsystem')
  180. if 'description' in data:
  181. extravalues['SUMMARY'] = data['description']
  182. if 'homepage' in data:
  183. extravalues['HOMEPAGE'] = data['homepage']
  184. fetchdev = extravalues['fetchdev'] or None
  185. deps, optdeps, devdeps = self.get_npm_package_dependencies(data, fetchdev)
  186. updated = self._handle_dependencies(d, deps, optdeps, devdeps, lines_before, srctree)
  187. if updated:
  188. # We need to redo the license stuff
  189. self._replace_license_vars(srctree, lines_before, handled, extravalues, d)
  190. # Shrinkwrap
  191. localfilesdir = tempfile.mkdtemp(prefix='recipetool-npm')
  192. self._shrinkwrap(srctree, localfilesdir, extravalues, lines_before, d)
  193. # Lockdown
  194. self._lockdown(srctree, localfilesdir, extravalues, lines_before, d)
  195. # Split each npm module out to is own package
  196. npmpackages = oe.package.npm_split_package_dirs(srctree)
  197. for item in handled:
  198. if isinstance(item, tuple):
  199. if item[0] == 'license':
  200. licvalues = item[1]
  201. break
  202. if licvalues:
  203. # Augment the license list with information we have in the packages
  204. licenses = {}
  205. license = self._handle_license(data)
  206. if license:
  207. licenses['${PN}'] = license
  208. for pkgname, pkgitem in npmpackages.items():
  209. _, pdata = pkgitem
  210. license = self._handle_license(pdata)
  211. if license:
  212. licenses[pkgname] = license
  213. # Now write out the package-specific license values
  214. # We need to strip out the json data dicts for this since split_pkg_licenses
  215. # isn't expecting it
  216. packages = OrderedDict((x,y[0]) for x,y in npmpackages.items())
  217. packages['${PN}'] = ''
  218. pkglicenses = split_pkg_licenses(licvalues, packages, lines_after, licenses)
  219. all_licenses = list(set([item.replace('_', ' ') for pkglicense in pkglicenses.values() for item in pkglicense]))
  220. if '&' in all_licenses:
  221. all_licenses.remove('&')
  222. # Go back and update the LICENSE value since we have a bit more
  223. # information than when that was written out (and we know all apply
  224. # vs. there being a choice, so we can join them with &)
  225. for i, line in enumerate(lines_before):
  226. if line.startswith('LICENSE = '):
  227. lines_before[i] = 'LICENSE = "%s"' % ' & '.join(all_licenses)
  228. break
  229. # Need to move S setting after inherit npm
  230. for i, line in enumerate(lines_before):
  231. if line.startswith('S ='):
  232. lines_before.pop(i)
  233. lines_after.insert(0, '# Must be set after inherit npm since that itself sets S')
  234. lines_after.insert(1, line)
  235. break
  236. return True
  237. return False
  238. # FIXME this is duplicated from lib/bb/fetch2/npm.py
  239. def _parse_view(self, output):
  240. '''
  241. Parse the output of npm view --json; the last JSON result
  242. is assumed to be the one that we're interested in.
  243. '''
  244. pdata = None
  245. outdeps = {}
  246. datalines = []
  247. bracelevel = 0
  248. for line in output.splitlines():
  249. if bracelevel:
  250. datalines.append(line)
  251. elif '{' in line:
  252. datalines = []
  253. datalines.append(line)
  254. bracelevel = bracelevel + line.count('{') - line.count('}')
  255. if datalines:
  256. pdata = json.loads('\n'.join(datalines))
  257. return pdata
  258. # FIXME this is effectively duplicated from lib/bb/fetch2/npm.py
  259. # (split out from _getdependencies())
  260. def get_npm_data(self, pkg, version, d):
  261. import bb.fetch2
  262. pkgfullname = pkg
  263. if version != '*' and not '/' in version:
  264. pkgfullname += "@'%s'" % version
  265. logger.debug(2, "Calling getdeps on %s" % pkg)
  266. runenv = dict(os.environ, PATH=d.getVar('PATH'))
  267. fetchcmd = "npm view %s --json" % pkgfullname
  268. output, _ = bb.process.run(fetchcmd, stderr=subprocess.STDOUT, env=runenv, shell=True)
  269. data = self._parse_view(output)
  270. return data
  271. # FIXME this is effectively duplicated from lib/bb/fetch2/npm.py
  272. # (split out from _getdependencies())
  273. def get_npm_package_dependencies(self, pdata, fetchdev):
  274. dependencies = pdata.get('dependencies', {})
  275. optionalDependencies = pdata.get('optionalDependencies', {})
  276. dependencies.update(optionalDependencies)
  277. if fetchdev:
  278. devDependencies = pdata.get('devDependencies', {})
  279. dependencies.update(devDependencies)
  280. else:
  281. devDependencies = {}
  282. depsfound = {}
  283. optdepsfound = {}
  284. devdepsfound = {}
  285. for dep in dependencies:
  286. if dep in optionalDependencies:
  287. optdepsfound[dep] = dependencies[dep]
  288. elif dep in devDependencies:
  289. devdepsfound[dep] = dependencies[dep]
  290. else:
  291. depsfound[dep] = dependencies[dep]
  292. return depsfound, optdepsfound, devdepsfound
  293. # FIXME this is effectively duplicated from lib/bb/fetch2/npm.py
  294. # (split out from _getdependencies())
  295. def check_npm_optional_dependency(self, pdata):
  296. pkg_os = pdata.get('os', None)
  297. if pkg_os:
  298. if not isinstance(pkg_os, list):
  299. pkg_os = [pkg_os]
  300. blacklist = False
  301. for item in pkg_os:
  302. if item.startswith('!'):
  303. blacklist = True
  304. break
  305. if (not blacklist and 'linux' not in pkg_os) or '!linux' in pkg_os:
  306. logger.debug(2, "Skipping %s since it's incompatible with Linux" % pkg)
  307. return False
  308. return True
  309. def register_recipe_handlers(handlers):
  310. handlers.append((NpmRecipeHandler(), 60))