commands.py 35 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120
  1. # coding: utf-8
  2. """JupyterLab entry points"""
  3. # Copyright (c) Jupyter Development Team.
  4. # Distributed under the terms of the Modified BSD License.
  5. from __future__ import print_function
  6. import errno
  7. import glob
  8. import hashlib
  9. import json
  10. import logging
  11. import pipes
  12. import os
  13. import re
  14. import shutil
  15. import site
  16. import sys
  17. import tarfile
  18. import tempfile
  19. from distutils.version import LooseVersion
  20. from functools import partial
  21. from jupyter_core.paths import jupyter_config_path
  22. from notebook.nbextensions import GREEN_ENABLED, GREEN_OK, RED_DISABLED, RED_X
  23. from os import path as osp
  24. from os.path import join as pjoin
  25. from subprocess import CalledProcessError, Popen, STDOUT, call
  26. from tornado import gen
  27. from tornado.ioloop import IOLoop
  28. from .semver import Range, gte, lt, lte, gt
  29. if sys.platform == 'win32':
  30. from subprocess import list2cmdline
  31. else:
  32. def list2cmdline(cmd_list):
  33. return ' '.join(map(pipes.quote, cmd_list))
  34. here = osp.dirname(osp.abspath(__file__))
  35. logging.basicConfig(format='%(message)s', level=logging.INFO)
  36. def get_app_dir(app_dir=None):
  37. """Get the configured JupyterLab app directory.
  38. """
  39. # Default to the override environment variable.
  40. app_dir = app_dir or os.environ.get('JUPYTERLAB_DIR')
  41. if app_dir:
  42. return os.path.realpath(app_dir)
  43. # Use the default locations for data_files.
  44. app_dir = pjoin(sys.prefix, 'share', 'jupyter', 'lab')
  45. # Check for a user level install.
  46. if hasattr(site, 'getuserbase'):
  47. userbase = site.getuserbase()
  48. if here.startswith(userbase) and not app_dir.startswith(userbase):
  49. app_dir = pjoin(userbase, 'share', 'jupyter', 'lab')
  50. # Check for a system install in '/usr/local/share'.
  51. elif (sys.prefix.startswith('/usr') and not
  52. os.path.exists(app_dir) and
  53. os.path.exists('/usr/local/share/jupyter/lab')):
  54. app_dir = '/usr/local/share/jupyter/lab'
  55. return os.path.realpath(app_dir)
  56. def get_user_settings_dir():
  57. """Get the configured JupyterLab app directory.
  58. """
  59. settings_dir = os.environ.get('JUPYTERLAB_SETTINGS_DIR')
  60. settings_dir = settings_dir or pjoin(
  61. jupyter_config_path()[0], 'lab', 'user-settings'
  62. )
  63. return os.path.realpath(settings_dir)
  64. @gen.coroutine
  65. def run(cmd, **kwargs):
  66. """Run a command in the given working directory.
  67. """
  68. logger = kwargs.pop('logger', logging) or logging
  69. abort_callback = kwargs.pop('abort_callback', None)
  70. logger.info('> ' + list2cmdline(cmd))
  71. kwargs.setdefault('shell', sys.platform == 'win32')
  72. kwargs.setdefault('env', os.environ)
  73. kwargs.setdefault('stderr', STDOUT)
  74. proc = None
  75. yield gen.moment # Sync up to the iterator
  76. try:
  77. proc = Popen(cmd, **kwargs)
  78. # Poll the process once per second until finished.
  79. while 1:
  80. yield gen.sleep(1)
  81. if proc.poll() is not None:
  82. break
  83. if abort_callback and abort_callback():
  84. raise ValueError('Aborted')
  85. except CalledProcessError as error:
  86. output = error.output.decode('utf-8')
  87. logger.info(output)
  88. raise error
  89. finally:
  90. if proc:
  91. proc.wait()
  92. def ensure_dev_build(logger=None):
  93. """Ensure that the dev build assets are there"""
  94. cmd = [get_npm_name(), 'run', 'build']
  95. func = partial(run, cmd, cwd=os.path.dirname(here), logger=logger)
  96. loop = IOLoop.instance()
  97. loop.instance().run_sync(func)
  98. def watch(cwd, logger=None):
  99. """Run watch mode in a given directory"""
  100. loop = IOLoop.instance()
  101. loop.add_callback(run, [get_npm_name(), 'run', 'watch'], cwd=cwd,
  102. logger=logger)
  103. def install_extension(extension, app_dir=None, logger=None):
  104. """Install an extension package into JupyterLab.
  105. Follows the semantics of https://docs.npmjs.com/cli/install.
  106. The extension is first validated.
  107. If link is true, the source directory is linked using `npm link`.
  108. """
  109. func = partial(install_extension_async, extension, app_dir=app_dir,
  110. logger=logger)
  111. return IOLoop.instance().run_sync(func)
  112. @gen.coroutine
  113. def install_extension_async(extension, app_dir=None, logger=None, abort_callback=None):
  114. """Install an extension package into JupyterLab.
  115. Follows the semantics of https://docs.npmjs.com/cli/install.
  116. The extension is first validated.
  117. If link is true, the source directory is linked using `npm link`.
  118. """
  119. yield check_node()
  120. app_dir = get_app_dir(app_dir)
  121. logger = logger or logging
  122. if app_dir == here:
  123. raise ValueError('Cannot install extensions in core app')
  124. extension = _normalize_path(extension)
  125. # Check for a core extensions here.
  126. data = _get_core_data()
  127. if extension in _get_core_extensions():
  128. config = _get_build_config(app_dir)
  129. uninstalled = config.get('uninstalled_core_extensions', [])
  130. if extension in uninstalled:
  131. uninstalled.remove(extension)
  132. config['uninstalled_core_extensions'] = uninstalled
  133. _write_build_config(config, app_dir, logger=logger)
  134. return
  135. # Run npm install if needed
  136. if (os.path.exists(extension) and
  137. os.path.isdir(extension) and
  138. not os.path.exists(pjoin(extension, 'node_modules'))):
  139. yield run([get_npm_name(), 'install'], cwd=extension, logger=logger,
  140. abort_callback=abort_callback)
  141. _ensure_app_dirs(app_dir, logger)
  142. target = pjoin(app_dir, 'extensions', 'temp')
  143. if os.path.exists(target):
  144. shutil.rmtree(target)
  145. os.makedirs(target)
  146. # npm pack the extension
  147. yield run([get_npm_name(), 'pack', extension], cwd=target, logger=logger,
  148. abort_callback=abort_callback)
  149. fnames = glob.glob(pjoin(target, '*.*'))
  150. if not fnames:
  151. raise ValueError('Invalid extension')
  152. fname = os.path.basename(fnames[0])
  153. data = _read_package(pjoin(target, fname))
  154. # Remove the tarball if the package is not an extension.
  155. if not _is_extension(data):
  156. shutil.rmtree(target)
  157. msg = '%s is not a valid JupyterLab extension' % extension
  158. raise ValueError(msg)
  159. # Remove the tarball if the package is not compatible.
  160. core_data = _get_core_data()
  161. deps = data.get('dependencies', dict())
  162. errors = _validate_compatibility(extension, deps, core_data)
  163. if errors:
  164. shutil.rmtree(target)
  165. msg = _format_compatibility_errors(
  166. data['name'], data['version'], errors
  167. )
  168. raise ValueError(msg)
  169. # Check for existing app extension of the same name.
  170. extensions = _get_extensions(app_dir)
  171. if data['name'] in extensions:
  172. other = extensions[data['name']]
  173. path = other['path']
  174. if osp.exists(path) and other['location'] == 'app':
  175. os.remove(path)
  176. # Remove an existing extension tarball.
  177. ext_path = pjoin(app_dir, 'extensions', fname)
  178. if os.path.exists(ext_path):
  179. os.remove(ext_path)
  180. shutil.move(pjoin(target, fname), pjoin(app_dir, 'extensions'))
  181. shutil.rmtree(target)
  182. # Remove any existing package from staging/node_modules to force
  183. # npm to re-install it from the tarball.
  184. target = pjoin(app_dir, 'staging', 'node_modules', data['name'])
  185. if os.path.exists(target):
  186. shutil.rmtree(target)
  187. def link_package(path, app_dir=None, logger=None):
  188. """Link a package against the JupyterLab build."""
  189. func = partial(link_package_async, path, app_dir=app_dir, logger=logger)
  190. return IOLoop.instance().run_sync(func)
  191. @gen.coroutine
  192. def link_package_async(path, app_dir=None, logger=None, abort_callback=None):
  193. """Link a package against the JupyterLab build.
  194. """
  195. logger = logger or logging
  196. app_dir = get_app_dir(app_dir)
  197. if app_dir == here:
  198. raise ValueError('Cannot link packages in core app')
  199. path = _normalize_path(path)
  200. _ensure_app_dirs(app_dir, logger)
  201. # Verify the package.json data.
  202. pkg_path = osp.join(path, 'package.json')
  203. if not osp.exists(pkg_path):
  204. msg = 'Linked package must point to a directory with package.json'
  205. raise ValueError(msg)
  206. with open(pkg_path) as fid:
  207. data = json.load(fid)
  208. # Check for a core extensions here.
  209. core_extensions = _get_core_extensions()
  210. if data['name'] in core_extensions:
  211. raise ValueError('Cannot link a core extension')
  212. is_extension = _is_extension(data)
  213. if is_extension:
  214. yield install_extension_async(path, app_dir, abort_callback=abort_callback)
  215. else:
  216. msg = ('*** Note: Linking non-extension package "%s" (lacks ' +
  217. '`jupyterlab.extension` metadata)')
  218. logger.info(msg % data['name'])
  219. core_data = _get_core_data()
  220. deps = data.get('dependencies', dict())
  221. name = data['name']
  222. errors = _validate_compatibility(name, deps, core_data)
  223. if errors:
  224. msg = _format_compatibility_errors(name, data['version'], errors)
  225. raise ValueError(msg)
  226. config = _get_build_config(app_dir)
  227. config.setdefault('linked_packages', dict())
  228. config['linked_packages'][data['name']] = path
  229. _write_build_config(config, app_dir, logger=logger)
  230. def unlink_package(package, app_dir=None, logger=None):
  231. """Unlink a package from JupyterLab by path or name.
  232. """
  233. logger = logger or logging
  234. package = _normalize_path(package)
  235. name = None
  236. app_dir = get_app_dir(app_dir)
  237. if app_dir == here:
  238. raise ValueError('Cannot link packages in core app')
  239. config = _get_build_config(app_dir)
  240. linked = config.setdefault('linked_packages', dict())
  241. for (key, value) in linked.items():
  242. if value == package or key == package:
  243. name = key
  244. break
  245. if not name:
  246. logger.warn('No package matching "%s" is linked' % package)
  247. return False
  248. del linked[name]
  249. config['linked_packages'] = linked
  250. _write_build_config(config, app_dir, logger=logger)
  251. extensions = _get_extensions(app_dir)
  252. if name in extensions:
  253. uninstall_extension(name, app_dir)
  254. return True
  255. def enable_extension(extension, app_dir=None, logger=None):
  256. """Enable a JupyterLab extension.
  257. """
  258. _toggle_extension(extension, False, app_dir, logger)
  259. def disable_extension(extension, app_dir=None, logger=None):
  260. """Disable a JupyterLab package.
  261. """
  262. _toggle_extension(extension, True, app_dir, logger)
  263. def get_npm_name():
  264. """Get the appropriate npm executable name.
  265. """
  266. return 'npm.cmd' if os.name == 'nt' else 'npm'
  267. @gen.coroutine
  268. def check_node():
  269. """Check for the existence of node and whether it is the right version.
  270. """
  271. try:
  272. yield run(['node', 'node-version-check.js'], cwd=here)
  273. except Exception as e:
  274. raise ValueError('`node` version 5+ is required, see extensions in README')
  275. def build_check(app_dir=None, logger=None):
  276. """Determine whether JupyterLab should be built.
  277. Returns a dictionary with information about the build.
  278. """
  279. func = partial(build_check_async, app_dir=app_dir, logger=logger)
  280. return IOLoop.instance().run_sync(func)
  281. @gen.coroutine
  282. def build_check_async(app_dir=None, logger=None):
  283. """Determine whether JupyterLab should be built.
  284. Returns a tuple of whether a build is recommend and the message.
  285. """
  286. logger = logger or logging
  287. app_dir = get_app_dir(app_dir)
  288. # Check for installed extensions
  289. extensions = _get_extensions(app_dir)
  290. linked = _get_linked_packages(app_dir=app_dir)
  291. # Check for no application.
  292. pkg_path = pjoin(app_dir, 'static', 'package.json')
  293. if not os.path.exists(pkg_path):
  294. raise gen.Return((True,
  295. 'Installed extensions with no built application'))
  296. with open(pkg_path) as fid:
  297. static_data = json.load(fid)
  298. # Look for mismatched version.
  299. static_version = static_data['jupyterlab'].get('version', '')
  300. core_version = static_data['jupyterlab']['version']
  301. if LooseVersion(static_version) != LooseVersion(core_version):
  302. msg = 'Version mismatch: %s (built), %s (current)'
  303. raise gen.Return((True, msg % (static_version, core_version)))
  304. # Look for mismatched extensions.
  305. template_data = _get_package_template(app_dir, logger)
  306. template_exts = template_data['jupyterlab']['extensions']
  307. if set(template_exts) != set(static_data['jupyterlab']['extensions']):
  308. raise gen.Return((True, 'Installed extensions changed'))
  309. template_mime_exts = set(template_data['jupyterlab']['mimeExtensions'])
  310. staging_mime_exts = set(static_data['jupyterlab']['mimeExtensions'])
  311. if template_mime_exts != staging_mime_exts:
  312. raise gen.Return((True, 'Installed extensions changed'))
  313. deps = static_data.get('dependencies', dict())
  314. # Look for mismatched extension paths.
  315. names = set(extensions)
  316. names.update(linked)
  317. for name in names:
  318. # Check for dependencies that were rejected as incompatible.
  319. if name not in template_data['dependencies']:
  320. continue
  321. path = deps[name]
  322. if path.startswith('file:'):
  323. path = path.replace('file:', '')
  324. path = os.path.abspath(pjoin(app_dir, 'staging', path))
  325. template_path = template_data['dependencies'][name]
  326. if sys.platform == 'win32':
  327. path = path.lower()
  328. template_path = template_path.lower()
  329. if path != template_path:
  330. if name in extensions:
  331. raise gen.Return((True, 'Installed extensions changed'))
  332. raise gen.Return((True, 'Linked package changed'))
  333. # Look for linked packages that have changed.
  334. for (name, path) in linked.items():
  335. normed_name = name.replace('@', '').replace('/', '-')
  336. if name in extensions:
  337. root = pjoin(app_dir, 'extensions')
  338. else:
  339. root = pjoin(app_dir, 'staging', 'linked_packages')
  340. path0 = glob.glob(pjoin(root, '%s*.tgz' % normed_name))[0]
  341. existing = _tarsum(path0)
  342. tempdir = tempfile.mkdtemp()
  343. cmd = [get_npm_name(), 'pack', linked[name]]
  344. yield run(cmd, cwd=tempdir, logger=logger)
  345. path1 = glob.glob(pjoin(tempdir, '*.tgz'))[0]
  346. current = _tarsum(path1)
  347. if current != existing:
  348. raise gen.Return((True, 'Linked package changed content'))
  349. raise gen.Return((False, ''))
  350. def validate_compatibility(extension, app_dir=None, logger=None):
  351. """Validate the compatibility of an extension.
  352. """
  353. app_dir = get_app_dir(app_dir)
  354. extensions = _get_extensions(app_dir)
  355. if extension not in extensions:
  356. raise ValueError('%s is not an installed extension')
  357. deps = extensions[extension].get('dependencies', dict())
  358. core_data = _get_core_data()
  359. return _validate_compatibility(extension, deps, core_data)
  360. def uninstall_extension(name, app_dir=None, logger=None):
  361. """Uninstall an extension by name.
  362. """
  363. logger = logger or logging
  364. app_dir = get_app_dir(app_dir)
  365. if app_dir == here:
  366. raise ValueError('Cannot install packages in core app')
  367. # Allow for uninstalled core extensions here.
  368. data = _get_core_data()
  369. if name in _get_core_extensions():
  370. logger.info('Uninstalling core extension %s' % name)
  371. config = _get_build_config(app_dir)
  372. uninstalled = config.get('uninstalled_core_extensions', [])
  373. if name not in uninstalled:
  374. uninstalled.append(name)
  375. config['uninstalled_core_extensions'] = uninstalled
  376. _write_build_config(config, app_dir, logger=logger)
  377. return True
  378. for (extname, data) in _get_extensions(app_dir).items():
  379. path = data['path']
  380. if extname == name:
  381. msg = 'Uninstalling %s from %s' % (name, os.path.dirname(path))
  382. logger.info(msg)
  383. os.remove(path)
  384. return True
  385. logger.warn('No labextension named "%s" installed' % name)
  386. return False
  387. def list_extensions(app_dir=None, logger=None):
  388. """List the extensions.
  389. """
  390. logger = logger or logging
  391. app_dir = get_app_dir(app_dir)
  392. extensions = _get_extensions(app_dir)
  393. disabled = _get_disabled(app_dir)
  394. all_linked = _get_linked_packages(app_dir, logger=logger)
  395. app = []
  396. sys = []
  397. linked = []
  398. errors = dict()
  399. core_data = _get_core_data()
  400. # We want to organize by dir.
  401. sys_path = pjoin(get_app_dir(), 'extensions')
  402. for (key, value) in extensions.items():
  403. deps = extensions[key].get('dependencies', dict())
  404. errors[key] = _validate_compatibility(key, deps, core_data)
  405. if key in all_linked:
  406. linked.append(key)
  407. if value['path'] == sys_path and sys_path != app_dir:
  408. sys.append(key)
  409. continue
  410. app.append(key)
  411. logger.info('JupyterLab v%s' % core_data['jupyterlab']['version'])
  412. logger.info('Known labextensions:')
  413. if app:
  414. logger.info(' app dir: %s' % app_dir)
  415. for item in sorted(app):
  416. logger.info(item)
  417. version = extensions[item]['version']
  418. extra = ''
  419. if is_disabled(item, disabled):
  420. extra += ' %s' % RED_DISABLED
  421. else:
  422. extra += ' %s' % GREEN_ENABLED
  423. if errors[item]:
  424. extra += ' %s' % RED_X
  425. else:
  426. extra += ' %s' % GREEN_OK
  427. logger.info(' %s v%s%s' % (item, version, extra))
  428. if errors[item]:
  429. msg = _format_compatibility_errors(item, version, errors[item])
  430. logger.warn(msg + '\n')
  431. if sys:
  432. logger.info(' sys dir: %s' % sys_path)
  433. for item in sorted(sys):
  434. version = extensions[item]['version']
  435. extra = ''
  436. if item in disabled:
  437. extra += ' %s' % RED_DISABLED
  438. else:
  439. extra += ' %s' % GREEN_ENABLED
  440. logger.info(' %s%s' % (item, extra))
  441. if errors[item]:
  442. extra += ' %s' % RED_X
  443. else:
  444. extra += ' %s' % GREEN_OK
  445. if item in linked:
  446. extra += '*'
  447. logger.info(' %s v%s%s' % (item, version, extra))
  448. if errors[item]:
  449. msg = _format_compatibility_errors(item, version, errors[item])
  450. logger.warn(msg + '\n')
  451. if linked:
  452. logger.info(' linked extensions:')
  453. for item in sorted(linked):
  454. logger.info(' %s: %s' % (item, all_linked[item]))
  455. if len(all_linked) > len(linked):
  456. logger.info(' linked packages:')
  457. for key in sorted(all_linked.keys()):
  458. if (key in linked):
  459. continue
  460. logger.info(' %s: %s' % (key, all_linked[key]))
  461. # Handle uninstalled and disabled core packages
  462. uninstalled_core = _get_uinstalled_core_extensions(app_dir)
  463. if uninstalled_core:
  464. logger.info('\nUninstalled core extensions:')
  465. [logger.info(' %s' % item) for item in sorted(uninstalled_core)]
  466. core_extensions = _get_core_extensions()
  467. disabled_core = []
  468. for key in core_extensions:
  469. if key in disabled:
  470. disabled_core.append(key)
  471. if disabled_core:
  472. logger.info('\nDisabled core extensions:')
  473. [logger.info(' %s' % item) for item in sorted(disabled_core)]
  474. def clean(app_dir=None):
  475. """Clean the JupyterLab application directory."""
  476. app_dir = get_app_dir(app_dir)
  477. if app_dir == here:
  478. raise ValueError('Cannot clean the core app')
  479. for name in ['static', 'staging']:
  480. target = pjoin(app_dir, name)
  481. if osp.exists(target):
  482. shutil.rmtree(target)
  483. def build(app_dir=None, name=None, version=None, logger=None):
  484. """Build the JupyterLab application.
  485. """
  486. func = partial(build_async, app_dir=app_dir, name=name, version=version,
  487. logger=logger)
  488. return IOLoop.instance().run_sync(func)
  489. def is_disabled(name, disabled=[]):
  490. for pattern in disabled:
  491. if name == pattern:
  492. return True
  493. if re.compile(pattern).match(name) != None:
  494. return True
  495. return False
  496. @gen.coroutine
  497. def build_async(app_dir=None, name=None, version=None, logger=None, abort_callback=None):
  498. """Build the JupyterLab application.
  499. """
  500. # Set up the build directory.
  501. logger = logger or logging
  502. app_dir = get_app_dir(app_dir)
  503. # Set up the build directory.
  504. yield check_node()
  505. if app_dir == here:
  506. raise ValueError('Cannot build extensions in the core app')
  507. _ensure_package(app_dir, name=name, version=version, logger=logger)
  508. staging = pjoin(app_dir, 'staging')
  509. extensions = _get_extensions(app_dir)
  510. # Ensure an empty linked_packages directory
  511. linked_packages = pjoin(staging, 'linked_packages')
  512. if osp.exists(linked_packages):
  513. shutil.rmtree(linked_packages)
  514. os.makedirs(linked_packages)
  515. # Install the linked packages.
  516. for (name, path) in _get_linked_packages(app_dir, logger=logger).items():
  517. # Handle linked extensions.
  518. if name in extensions:
  519. yield install_extension_async(path, app_dir, abort_callback=abort_callback)
  520. # Handle linked packages that are not extensions.
  521. elif name not in extensions:
  522. yield _install_linked_package(staging, name, path, logger, abort_callback=abort_callback)
  523. npm = get_npm_name()
  524. # Make sure packages are installed.
  525. yield run([npm, 'install'], cwd=staging, logger=logger, abort_callback=abort_callback)
  526. # Build the app.
  527. yield run([npm, 'run', 'clean'], cwd=staging, logger=logger, abort_callback= abort_callback)
  528. yield run([npm, 'run', 'build'], cwd=staging, logger=logger, abort_callback= abort_callback)
  529. # Move the app to the static dir.
  530. static = pjoin(app_dir, 'static')
  531. if os.path.exists(static):
  532. shutil.rmtree(static)
  533. shutil.copytree(pjoin(app_dir, 'staging', 'build'), static)
  534. @gen.coroutine
  535. def _install_linked_package(staging, name, path, logger, abort_callback=None):
  536. """Install a linked non-extension package using a package tarball
  537. to prevent it from being treated as a symlink.
  538. """
  539. # Remove any existing package from staging/node_modules
  540. target = pjoin(staging, 'node_modules', name)
  541. target = target.replace('/', os.sep)
  542. if os.path.exists(target):
  543. shutil.rmtree(target)
  544. linked = pjoin(staging, 'linked_packages')
  545. target = pjoin(linked, 'temp')
  546. if os.path.exists(target):
  547. shutil.rmtree(target)
  548. os.makedirs(target)
  549. # npm pack the extension
  550. yield run([get_npm_name(), 'pack', path], cwd=target, logger=logger, abort_callback=abort_callback)
  551. fname = os.path.basename(glob.glob(pjoin(target, '*.*'))[0])
  552. data = _read_package(pjoin(target, fname))
  553. # Remove the tarball if the package is not compatible.
  554. core_data = _get_core_data()
  555. deps = data.get('dependencies', dict())
  556. errors = _validate_compatibility(path, deps, core_data)
  557. if errors:
  558. shutil.rmtree(target)
  559. msg = _format_compatibility_errors(
  560. data['name'], data['version'], errors
  561. )
  562. raise ValueError(msg)
  563. # Remove an existing extension tarball.
  564. ext_path = pjoin(linked, fname)
  565. if os.path.exists(ext_path):
  566. os.remove(ext_path)
  567. # Move
  568. shutil.move(pjoin(target, fname), linked)
  569. shutil.rmtree(target)
  570. def _get_build_config(app_dir):
  571. """Get the build config data for the given app dir
  572. """
  573. target = pjoin(app_dir, 'settings', 'build_config.json')
  574. if not os.path.exists(target):
  575. return {}
  576. else:
  577. with open(target) as fid:
  578. return json.load(fid)
  579. def _get_page_config(app_dir):
  580. """Get the page config data for the given app dir
  581. """
  582. target = pjoin(app_dir, 'settings', 'page_config.json')
  583. if not os.path.exists(target):
  584. return {}
  585. else:
  586. with open(target) as fid:
  587. return json.load(fid)
  588. def _validate_compatibility(extension, deps, core_data):
  589. """Validate the compatibility of an extension.
  590. """
  591. core_deps = core_data['dependencies']
  592. singletons = core_data['jupyterlab']['singletonPackages']
  593. errors = []
  594. for (key, value) in deps.items():
  595. if key in singletons:
  596. overlap = _test_overlap(core_deps[key], value)
  597. if overlap is False:
  598. errors.append((key, core_deps[key], value))
  599. return errors
  600. def _get_core_data():
  601. """Get the data for the app template.
  602. """
  603. with open(pjoin(here, 'package.app.json')) as fid:
  604. return json.load(fid)
  605. # Provide the application version.
  606. app_version = _get_core_data()['jupyterlab']['version']
  607. def _test_overlap(spec1, spec2):
  608. """Test whether two version specs overlap.
  609. Returns `None` if we cannot determine compatibility,
  610. otherwise whether there is an overlap
  611. """
  612. # Test for overlapping semver ranges.
  613. r1 = Range(spec1, True)
  614. r2 = Range(spec2, True)
  615. # If either range is empty, we cannot verify.
  616. if not r1.range or not r2.range:
  617. return
  618. x1 = r1.set[0][0].semver
  619. x2 = r1.set[0][-1].semver
  620. y1 = r2.set[0][0].semver
  621. y2 = r2.set[0][-1].semver
  622. o1 = r1.set[0][0].operator
  623. o2 = r2.set[0][0].operator
  624. # We do not handle (<) specifiers.
  625. if (o1.startswith('<') or o2.startswith('<')):
  626. return
  627. # Handle single value specifiers.
  628. lx = lte if x1 == x2 else lt
  629. ly = lte if y1 == y2 else lt
  630. gx = gte if x1 == x2 else gt
  631. gy = gte if x1 == x2 else gt
  632. # Handle unbounded (>) specifiers.
  633. def noop(x, y, z):
  634. return True
  635. if x1 == x2 and o1.startswith('>'):
  636. lx = noop
  637. if y1 == y2 and o2.startswith('>'):
  638. ly = noop
  639. # Check for overlap.
  640. return (
  641. gte(x1, y1, True) and ly(x1, y2, True) or
  642. gy(x2, y1, True) and ly(x2, y2, True) or
  643. gte(y1, x1, True) and lx(y1, x2, True) or
  644. gx(y2, x1, True) and lx(y2, x2, True)
  645. )
  646. def _format_compatibility_errors(name, version, errors):
  647. """Format a message for compatibility errors.
  648. """
  649. msgs = []
  650. l0 = 10
  651. l1 = 10
  652. for error in errors:
  653. pkg, jlab, ext = error
  654. jlab = str(Range(jlab, True))
  655. ext = str(Range(ext, True))
  656. msgs.append((pkg, jlab, ext))
  657. l0 = max(l0, len(pkg) + 1)
  658. l1 = max(l1, len(jlab) + 1)
  659. msg = '\n"%s@%s" is not compatible with the current JupyterLab'
  660. msg = msg % (name, version)
  661. msg += '\nConflicting Dependencies:\n'
  662. msg += 'JupyterLab'.ljust(l0)
  663. msg += 'Extension'.ljust(l1)
  664. msg += 'Package\n'
  665. for (pkg, jlab, ext) in msgs:
  666. msg += jlab.ljust(l0) + ext.ljust(l1) + pkg + '\n'
  667. return msg
  668. def _toggle_extension(extension, value, app_dir=None, logger=None):
  669. """Enable or disable a lab extension.
  670. """
  671. app_dir = get_app_dir(app_dir)
  672. extensions = _get_extensions(app_dir)
  673. config = _get_build_config(app_dir)
  674. disabled = config.get('disabledExtensions', [])
  675. if value and extension not in disabled:
  676. disabled.append(extension)
  677. if not value and extension in disabled:
  678. disabled.remove(extension)
  679. config['disabledExtensions'] = disabled
  680. _write_page_config(config, app_dir, logger=logger)
  681. def _write_build_config(config, app_dir, logger):
  682. """Write the build config to the app dir.
  683. """
  684. _ensure_app_dirs(app_dir, logger)
  685. target = pjoin(app_dir, 'settings', 'build_config.json')
  686. with open(target, 'w') as fid:
  687. json.dump(config, fid, indent=4)
  688. def _write_page_config(config, app_dir, logger):
  689. """Write the build config to the app dir.
  690. """
  691. _ensure_app_dirs(app_dir, logger)
  692. target = pjoin(app_dir, 'settings', 'page_config.json')
  693. with open(target, 'w') as fid:
  694. json.dump(config, fid, indent=4)
  695. def _ensure_package(app_dir, logger=None, name=None, version=None):
  696. """Make sure the build dir is set up.
  697. """
  698. logger = logger or logging
  699. _ensure_app_dirs(app_dir, logger)
  700. # Look for mismatched version.
  701. staging = pjoin(app_dir, 'staging')
  702. pkg_path = pjoin(staging, 'package.json')
  703. if os.path.exists(pkg_path):
  704. with open(pkg_path) as fid:
  705. data = json.load(fid)
  706. if data['jupyterlab'].get('version', '') != version:
  707. shutil.rmtree(staging)
  708. os.makedirs(staging)
  709. for fname in ['index.app.js', 'webpack.config.js', '.npmrc']:
  710. dest = pjoin(staging, fname.replace('.app', ''))
  711. shutil.copy(pjoin(here, fname), dest)
  712. # Template the package.json file.
  713. data = _get_package_template(app_dir, logger)
  714. if version:
  715. data['jupyterlab']['version'] = version
  716. if name:
  717. data['jupyterlab']['name'] = name
  718. data['jupyterlab']['linkedPackages'] = _get_linked_packages(app_dir)
  719. pkg_path = pjoin(staging, 'package.json')
  720. with open(pkg_path, 'w') as fid:
  721. json.dump(data, fid, indent=4)
  722. def _ensure_app_dirs(app_dir, logger):
  723. """Ensure that the application directories exist"""
  724. dirs = ['extensions', 'settings', 'staging', 'schemas', 'themes']
  725. for dname in dirs:
  726. path = pjoin(app_dir, dname)
  727. if not osp.exists(path):
  728. try:
  729. os.makedirs(path)
  730. except OSError as e:
  731. if e.errno != errno.EEXIST:
  732. raise
  733. def _get_package_template(app_dir, logger):
  734. # Get the template the for package.json file.
  735. data = _get_core_data()
  736. extensions = _get_extensions(app_dir)
  737. # Handle extensions
  738. for (key, value) in extensions.items():
  739. # Reject incompatible extensions with a message.
  740. deps = value.get('dependencies', dict())
  741. errors = _validate_compatibility(key, deps, data)
  742. if errors:
  743. msg = _format_compatibility_errors(key, value['version'], errors)
  744. logger.warn(msg + '\n')
  745. continue
  746. data['dependencies'][key] = value['path']
  747. jlab_data = value['jupyterlab']
  748. for item in ['extension', 'mimeExtension']:
  749. ext = jlab_data.get(item, False)
  750. if not ext:
  751. continue
  752. if ext is True:
  753. ext = ''
  754. data['jupyterlab'][item + 's'][key] = ext
  755. # Handle linked packages.
  756. linked = _get_linked_packages(app_dir, logger)
  757. for (key, path) in linked.items():
  758. if key in extensions:
  759. continue
  760. data['dependencies'][key] = path
  761. # Handle uninstalled core extensions.
  762. for item in _get_uinstalled_core_extensions(app_dir):
  763. if item in data['jupyterlab']['extensions']:
  764. data['jupyterlab']['extensions'].pop(item)
  765. else:
  766. data['jupyterlab']['mimeExtensions'].pop(item)
  767. # Remove from dependencies as well.
  768. data['dependencies'].pop(item)
  769. return data
  770. def _is_extension(data):
  771. """Detect if a package is an extension using its metadata.
  772. """
  773. if 'jupyterlab' not in data:
  774. return False
  775. if not isinstance(data['jupyterlab'], dict):
  776. return False
  777. is_extension = data['jupyterlab'].get('extension', False)
  778. is_mime_extension = data['jupyterlab'].get('mimeExtension', False)
  779. return is_extension or is_mime_extension
  780. def _get_uinstalled_core_extensions(app_dir):
  781. """Get the uninstalled core extensions.
  782. """
  783. config = _get_build_config(app_dir)
  784. return config.get('uninstalled_core_extensions', [])
  785. def _validate_package(data, extension):
  786. """Validate package.json data.
  787. """
  788. msg = '%s is not a valid JupyterLab extension' % extension
  789. if not _is_extension(data):
  790. raise ValueError(msg)
  791. def _get_disabled(app_dir):
  792. """Get the disabled extensions.
  793. """
  794. config = _get_page_config(app_dir)
  795. return config.get('disabledExtensions', [])
  796. def _get_core_extensions():
  797. """Get the core extensions.
  798. """
  799. data = _get_core_data()['jupyterlab']
  800. return list(data['extensions']) + list(data['mimeExtensions'])
  801. def _get_extensions(app_dir):
  802. """Get the extensions in a given app dir.
  803. """
  804. extensions = dict()
  805. # Get system level packages
  806. sys_path = pjoin(get_app_dir(), 'extensions')
  807. app_path = pjoin(app_dir, 'extensions')
  808. for target in glob.glob(pjoin(sys_path, '*.tgz')):
  809. location = 'app' if app_path == sys_path else 'system'
  810. data = _read_package(target)
  811. deps = data.get('dependencies', dict())
  812. extensions[data['name']] = dict(path=os.path.realpath(target),
  813. version=data['version'],
  814. jupyterlab=data['jupyterlab'],
  815. dependencies=deps,
  816. location=location)
  817. # Look in app_dir if different
  818. app_path = pjoin(app_dir, 'extensions')
  819. if app_path == sys_path or not os.path.exists(app_path):
  820. return extensions
  821. for target in glob.glob(pjoin(app_path, '*.tgz')):
  822. data = _read_package(target)
  823. deps = data.get('dependencies', dict())
  824. extensions[data['name']] = dict(path=os.path.realpath(target),
  825. version=data['version'],
  826. jupyterlab=data['jupyterlab'],
  827. dependencies=deps,
  828. location='app')
  829. return extensions
  830. def _get_linked_packages(app_dir=None, logger=None):
  831. """Get the linked packages metadata.
  832. """
  833. logger = logger or logging
  834. app_dir = get_app_dir(app_dir)
  835. config = _get_build_config(app_dir)
  836. linked = config.get('linked_packages', dict())
  837. dead = []
  838. for (name, path) in linked.items():
  839. if not os.path.exists(path):
  840. dead.append(name)
  841. if dead:
  842. extensions = _get_extensions(app_dir)
  843. for name in dead:
  844. path = linked[name]
  845. if name in extensions:
  846. uninstall_extension(name)
  847. logger.warn('**Note: Removing dead linked extension "%s"' % name)
  848. else:
  849. logger.warn('**Note: Removing dead linked package "%s"' % name)
  850. del linked[name]
  851. if dead:
  852. config['linked_packages'] = linked
  853. _write_build_config(config, app_dir, logger=logger)
  854. return config.get('linked_packages', dict())
  855. def _read_package(target):
  856. """Read the package data in a given target tarball.
  857. """
  858. tar = tarfile.open(target, "r:gz")
  859. f = tar.extractfile('package/package.json')
  860. data = json.loads(f.read().decode('utf8'))
  861. tar.close()
  862. return data
  863. def _copy_tar_files(fname, source, dest):
  864. """Copy the files from a target path to the destination.
  865. """
  866. tar = tarfile.open(fname, "r:gz")
  867. subdir_and_files = [
  868. tarinfo for tarinfo in tar.getmembers()
  869. if tarinfo.name.startswith('package/' + source)
  870. ]
  871. offset = len('package/' + source + '/')
  872. for member in subdir_and_files:
  873. member.path = member.path[offset:]
  874. tar.extractall(path=dest, members=subdir_and_files)
  875. tar.close()
  876. def _normalize_path(extension):
  877. """Normalize a given extension if it is a path.
  878. """
  879. extension = osp.expanduser(extension)
  880. if osp.exists(extension):
  881. extension = osp.abspath(extension)
  882. return extension
  883. def _tarsum(input_file):
  884. """
  885. Compute the recursive sha sum of a tar file.
  886. """
  887. tar = tarfile.open(input_file, "r:gz")
  888. chunk_size = 100 * 1024
  889. for member in tar:
  890. if not member.isfile():
  891. continue
  892. f = tar.extractfile(member)
  893. h = hashlib.new("sha256")
  894. data = f.read(chunk_size)
  895. while data:
  896. h.update(data)
  897. data = f.read(chunk_size)
  898. return h.hexdigest()