commands.py 32 KB

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