commands.py 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230
  1. # coding: utf-8
  2. """JupyterLab command handler"""
  3. # Copyright (c) Jupyter Development Team.
  4. # Distributed under the terms of the Modified BSD License.
  5. from __future__ import print_function
  6. from distutils.version import LooseVersion
  7. import errno
  8. import glob
  9. import hashlib
  10. import json
  11. import logging
  12. import os
  13. import os.path as osp
  14. import re
  15. import shutil
  16. import site
  17. import sys
  18. import tarfile
  19. from threading import Event
  20. from ipython_genutils.tempdir import TemporaryDirectory
  21. from ipython_genutils.py3compat import which
  22. from jupyter_core.paths import jupyter_config_path
  23. from notebook.nbextensions import GREEN_ENABLED, GREEN_OK, RED_DISABLED, RED_X
  24. from .semver import Range, gte, lt, lte, gt
  25. from .jlpmapp import YARN_PATH, HERE
  26. from .process import Process, WatchHelper
  27. # The regex for expecting the webpack output.
  28. WEBPACK_EXPECT = re.compile(r'.*/index.out.js')
  29. # The dev mode directory.
  30. DEV_DIR = osp.realpath(os.path.join(HERE, '..', 'dev_mode'))
  31. def pjoin(*args):
  32. """Join paths to create a real path.
  33. """
  34. return osp.realpath(osp.join(*args))
  35. def get_user_settings_dir():
  36. """Get the configured JupyterLab app directory.
  37. """
  38. settings_dir = os.environ.get('JUPYTERLAB_SETTINGS_DIR')
  39. settings_dir = settings_dir or pjoin(
  40. jupyter_config_path()[0], 'lab', 'user-settings'
  41. )
  42. return osp.realpath(settings_dir)
  43. def get_app_dir():
  44. """Get the configured JupyterLab app directory.
  45. """
  46. # Default to the override environment variable.
  47. if os.environ.get('JUPYTERLAB_DIR'):
  48. return osp.realpath(os.environ['JUPYTERLAB_DIR'])
  49. # Use the default locations for data_files.
  50. app_dir = pjoin(sys.prefix, 'share', 'jupyter', 'lab')
  51. # Check for a user level install.
  52. # Ensure that USER_BASE is defined
  53. if hasattr(site, 'getuserbase'):
  54. site.getuserbase()
  55. userbase = getattr(site, 'USER_BASE', None)
  56. if HERE.startswith(userbase) and not app_dir.startswith(userbase):
  57. app_dir = pjoin(userbase, 'share', 'jupyter', 'lab')
  58. # Check for a system install in '/usr/local/share'.
  59. elif (sys.prefix.startswith('/usr') and not
  60. osp.exists(app_dir) and
  61. osp.exists('/usr/local/share/jupyter/lab')):
  62. app_dir = '/usr/local/share/jupyter/lab'
  63. return osp.realpath(app_dir)
  64. def ensure_dev(logger=None):
  65. """Ensure that the dev assets are available.
  66. """
  67. parent = pjoin(HERE, '..')
  68. if not osp.exists(pjoin(parent, 'node_modules')):
  69. yarn_proc = Process(['node', YARN_PATH], cwd=parent, logger=logger)
  70. yarn_proc.wait()
  71. if not osp.exists(pjoin(parent, 'dev_mode', 'build')):
  72. yarn_proc = Process(['node', YARN_PATH, 'build'], cwd=parent,
  73. logger=logger)
  74. yarn_proc.wait()
  75. def watch_dev(logger=None):
  76. """Run watch mode in a given directory.
  77. Parameters
  78. ----------
  79. logger: :class:`~logger.Logger`, optional
  80. The logger instance.
  81. Returns
  82. -------
  83. A list of `WatchHelper` objects.
  84. """
  85. parent = pjoin(HERE, '..')
  86. if not osp.exists(pjoin(parent, 'node_modules')):
  87. yarn_proc = Process(['node', YARN_PATH], cwd=parent, logger=logger)
  88. yarn_proc.wait()
  89. logger = logger or logging.getLogger('jupyterlab')
  90. ts_dir = osp.realpath(osp.join(HERE, '..', 'packages', 'metapackage'))
  91. # Run typescript watch and wait for compilation.
  92. ts_regex = r'.* Compilation complete\. Watching for file changes\.'
  93. ts_proc = WatchHelper(['node', YARN_PATH, 'run', 'watch'],
  94. cwd=ts_dir, logger=logger, startup_regex=ts_regex)
  95. # Run the metapackage file watcher.
  96. tsf_regex = 'Watching the metapackage files...'
  97. tsf_proc = WatchHelper(['node', YARN_PATH, 'run', 'watch:files'],
  98. cwd=ts_dir, logger=logger, startup_regex=tsf_regex)
  99. # Run webpack watch and wait for compilation.
  100. wp_proc = WatchHelper(['node', YARN_PATH, 'run', 'watch'],
  101. cwd=DEV_DIR, logger=logger,
  102. startup_regex=WEBPACK_EXPECT)
  103. return [ts_proc, tsf_proc, wp_proc]
  104. def watch(app_dir=None, logger=None):
  105. """Watch the application.
  106. Parameters
  107. ----------
  108. app_dir: string, optional
  109. The application directory.
  110. logger: :class:`~logger.Logger`, optional
  111. The logger instance.
  112. Returns
  113. -------
  114. A list of processes to run asynchronously.
  115. """
  116. handler = _AppHandler(app_dir, logger)
  117. return handler.watch()
  118. def install_extension(extension, app_dir=None, logger=None):
  119. """Install an extension package into JupyterLab.
  120. The extension is first validated.
  121. """
  122. handler = _AppHandler(app_dir, logger)
  123. return handler.install_extension(extension)
  124. def uninstall_extension(name, app_dir=None, logger=None):
  125. """Uninstall an extension by name or path.
  126. """
  127. handler = _AppHandler(app_dir, logger)
  128. return handler.uninstall_extension(name)
  129. def clean(app_dir=None):
  130. """Clean the JupyterLab application directory."""
  131. app_dir = app_dir or get_app_dir()
  132. if app_dir == pjoin(HERE, 'dev'):
  133. raise ValueError('Cannot clean the dev app')
  134. if app_dir == pjoin(HERE, 'core'):
  135. raise ValueError('Cannot clean the core app')
  136. for name in ['staging']:
  137. target = pjoin(app_dir, name)
  138. if osp.exists(target):
  139. shutil.rmtree(target)
  140. def build(app_dir=None, name=None, version=None, logger=None,
  141. command='build:prod', kill_event=None,
  142. clean_staging=False):
  143. """Build the JupyterLab application.
  144. """
  145. handler = _AppHandler(app_dir, logger, kill_event=kill_event)
  146. return handler.build(name=name, version=version,
  147. command=command, clean_staging=clean_staging)
  148. def get_app_info(app_dir=None, logger=None):
  149. """Get a dictionary of information about the app.
  150. """
  151. handler = _AppHandler(app_dir, logger)
  152. return handler.info
  153. def enable_extension(extension, app_dir=None, logger=None):
  154. """Enable a JupyterLab extension.
  155. """
  156. handler = _AppHandler(app_dir, logger)
  157. return handler.toggle_extension(extension, False)
  158. def disable_extension(extension, app_dir=None, logger=None):
  159. """Disable a JupyterLab package.
  160. """
  161. handler = _AppHandler(app_dir, logger)
  162. return handler.toggle_extension(extension, True)
  163. def build_check(app_dir=None, logger=None):
  164. """Determine whether JupyterLab should be built.
  165. Returns a list of messages.
  166. """
  167. handler = _AppHandler(app_dir, logger)
  168. return handler.build_check()
  169. def list_extensions(app_dir=None, logger=None):
  170. """List the extensions.
  171. """
  172. handler = _AppHandler(app_dir, logger)
  173. return handler.list_extensions()
  174. def link_package(path, app_dir=None, logger=None):
  175. """Link a package against the JupyterLab build."""
  176. handler = _AppHandler(app_dir, logger)
  177. return handler.link_package(path)
  178. def unlink_package(package, app_dir=None, logger=None):
  179. """Unlink a package from JupyterLab by path or name.
  180. """
  181. handler = _AppHandler(app_dir, logger)
  182. return handler.unlink_package(package)
  183. def get_app_version():
  184. """Get the application version."""
  185. return _get_core_data()['jupyterlab']['version']
  186. # ----------------------------------------------------------------------
  187. # Implementation details
  188. # ----------------------------------------------------------------------
  189. class _AppHandler(object):
  190. def __init__(self, app_dir, logger=None, kill_event=None):
  191. if app_dir and app_dir.startswith(HERE):
  192. raise ValueError('Cannot run lab extension commands in core app')
  193. self.app_dir = app_dir or get_app_dir()
  194. self.sys_dir = get_app_dir()
  195. self.logger = logger or logging.getLogger('jupyterlab')
  196. self.info = self._get_app_info()
  197. self.kill_event = kill_event or Event()
  198. def install_extension(self, extension, existing=None):
  199. """Install an extension package into JupyterLab.
  200. The extension is first validated.
  201. """
  202. extension = _normalize_path(extension)
  203. extensions = self.info['extensions']
  204. # Check for a core extensions.
  205. if extension in self.info['core_extensions']:
  206. config = self._read_build_config()
  207. uninstalled = config.get('uninstalled_core_extensions', [])
  208. if extension in uninstalled:
  209. uninstalled.remove(extension)
  210. config['uninstalled_core_extensions'] = uninstalled
  211. self._write_build_config(config)
  212. return
  213. # Create the app dirs if needed.
  214. self._ensure_app_dirs()
  215. # Install the package using a temporary directory.
  216. with TemporaryDirectory() as tempdir:
  217. info = self._install_extension(extension, tempdir)
  218. name = info['name']
  219. # Local directories get name mangled and stored in metadata.
  220. if info['is_dir']:
  221. config = self._read_build_config()
  222. local = config.setdefault('local_extensions', dict())
  223. local[name] = info['source']
  224. self._write_build_config(config)
  225. # Remove an existing extension with the same name and different path
  226. if name in extensions:
  227. other = extensions[name]
  228. if other['path'] != info['path'] and other['location'] == 'app':
  229. os.remove(other['path'])
  230. def build(self, name=None, version=None, command='build:prod',
  231. clean_staging=False):
  232. """Build the application.
  233. """
  234. # Set up the build directory.
  235. app_dir = self.app_dir
  236. self._populate_staging(
  237. name=name, version=version, clean=clean_staging
  238. )
  239. staging = pjoin(app_dir, 'staging')
  240. # Make sure packages are installed.
  241. self._run(['node', YARN_PATH, 'install'], cwd=staging)
  242. # Build the app.
  243. self._run(['node', YARN_PATH, 'run', command], cwd=staging)
  244. def watch(self):
  245. """Start the application watcher and then run the watch in
  246. the background.
  247. """
  248. staging = pjoin(self.app_dir, 'staging')
  249. self._populate_staging()
  250. # Make sure packages are installed.
  251. self._run(['node', YARN_PATH, 'install'], cwd=staging)
  252. proc = WatchHelper(['node', YARN_PATH, 'run', 'watch'],
  253. cwd=pjoin(self.app_dir, 'staging'),
  254. startup_regex=WEBPACK_EXPECT,
  255. logger=self.logger)
  256. return [proc]
  257. def list_extensions(self):
  258. """Print an output of the extensions.
  259. """
  260. logger = self.logger
  261. info = self.info
  262. logger.info('JupyterLab v%s' % info['version'])
  263. if info['extensions']:
  264. info['compat_errors'] = self._get_extension_compat()
  265. logger.info('Known labextensions:')
  266. self._list_extensions(info, 'app')
  267. self._list_extensions(info, 'sys')
  268. else:
  269. logger.info('No installed extensions')
  270. local = info['local_extensions']
  271. if local:
  272. logger.info('\n local extensions:')
  273. for name in sorted(local):
  274. logger.info(' %s: %s' % (name, local[name]))
  275. linked_packages = info['linked_packages']
  276. if linked_packages:
  277. logger.info('\n linked packages:')
  278. for key in sorted(linked_packages):
  279. source = linked_packages[key]['source']
  280. logger.info(' %s: %s' % (key, source))
  281. uninstalled_core = info['uninstalled_core']
  282. if uninstalled_core:
  283. logger.info('\nUninstalled core extensions:')
  284. [logger.info(' %s' % item) for item in sorted(uninstalled_core)]
  285. disabled_core = info['disabled_core']
  286. if disabled_core:
  287. logger.info('\nDisabled core extensions:')
  288. [logger.info(' %s' % item) for item in sorted(disabled_core)]
  289. messages = self.build_check(fast=True)
  290. if messages:
  291. logger.info('\nBuild recommended:')
  292. [logger.info(' %s' % item) for item in messages]
  293. def build_check(self, fast=False):
  294. """Determine whether JupyterLab should be built.
  295. Returns a list of messages.
  296. """
  297. app_dir = self.app_dir
  298. local = self.info['local_extensions']
  299. linked = self.info['linked_packages']
  300. messages = []
  301. # Check for no application.
  302. pkg_path = pjoin(app_dir, 'static', 'package.json')
  303. if not osp.exists(pkg_path):
  304. return ['No built application']
  305. with open(pkg_path) as fid:
  306. static_data = json.load(fid)
  307. old_jlab = static_data['jupyterlab']
  308. old_deps = static_data.get('dependencies', dict())
  309. # Look for mismatched version.
  310. static_version = old_jlab.get('version', '')
  311. core_version = old_jlab['version']
  312. if LooseVersion(static_version) != LooseVersion(core_version):
  313. msg = 'Version mismatch: %s (built), %s (current)'
  314. return [msg % (static_version, core_version)]
  315. # Look for mismatched extensions.
  316. new_package = self._get_package_template(silent=fast)
  317. new_jlab = new_package['jupyterlab']
  318. new_deps = new_package.get('dependencies', dict())
  319. for ext_type in ['extensions', 'mimeExtensions']:
  320. # Extensions that were added.
  321. for ext in new_jlab[ext_type]:
  322. if ext not in old_jlab[ext_type]:
  323. messages.append('%s needs to be included' % ext)
  324. # Extensions that were removed.
  325. for ext in old_jlab[ext_type]:
  326. if ext not in new_jlab[ext_type]:
  327. messages.append('%s needs to be removed' % ext)
  328. # Look for mismatched dependencies
  329. for (pkg, dep) in new_deps.items():
  330. if pkg not in old_deps:
  331. continue
  332. # Skip local and linked since we pick them up separately.
  333. if pkg in local or pkg in linked:
  334. continue
  335. if old_deps[pkg] != dep:
  336. msg = '%s changed from %s to %s'
  337. messages.append(msg % (pkg, old_deps[pkg], new_deps[pkg]))
  338. # Look for updated local extensions.
  339. for (name, source) in local.items():
  340. if fast:
  341. continue
  342. dname = pjoin(app_dir, 'extensions')
  343. if self._check_local(name, source, dname):
  344. messages.append('%s content changed' % name)
  345. # Look for updated linked packages.
  346. for (name, item) in linked.items():
  347. if fast:
  348. continue
  349. dname = pjoin(app_dir, 'staging', 'linked_packages')
  350. if self._check_local(name, item['source'], dname):
  351. messages.append('%s content changed' % name)
  352. return messages
  353. def uninstall_extension(self, name):
  354. """Uninstall an extension by name.
  355. """
  356. # Allow for uninstalled core extensions.
  357. data = self.info['core_data']
  358. if name in self.info['core_extensions']:
  359. self.logger.info('Uninstalling core extension %s' % name)
  360. config = self._read_build_config()
  361. uninstalled = config.get('uninstalled_core_extensions', [])
  362. if name not in uninstalled:
  363. uninstalled.append(name)
  364. config['uninstalled_core_extensions'] = uninstalled
  365. self._write_build_config(config)
  366. return True
  367. local = self.info['local_extensions']
  368. for (extname, data) in self.info['extensions'].items():
  369. path = data['path']
  370. if extname == name:
  371. msg = 'Uninstalling %s from %s' % (name, osp.dirname(path))
  372. self.logger.info(msg)
  373. os.remove(path)
  374. # Handle local extensions.
  375. if extname in local:
  376. config = self._read_build_config()
  377. data = config.setdefault('local_extensions', dict())
  378. del data[extname]
  379. self._write_build_config(config)
  380. return True
  381. self.logger.warn('No labextension named "%s" installed' % name)
  382. return False
  383. def link_package(self, path):
  384. """Link a package at the given path.
  385. """
  386. path = _normalize_path(path)
  387. if not osp.exists(path) or not osp.isdir(path):
  388. msg = 'Can install "%s" only link local directories'
  389. raise ValueError(msg % path)
  390. with TemporaryDirectory() as tempdir:
  391. info = self._extract_package(path, tempdir)
  392. messages = _validate_extension(info['data'])
  393. if not messages:
  394. return self.install_extension(path)
  395. # Warn that it is a linked package.
  396. self.logger.warn('Installing %s as a linked package:', path)
  397. [self.logger.warn(m) for m in messages]
  398. # Add to metadata.
  399. config = self._read_build_config()
  400. linked = config.setdefault('linked_packages', dict())
  401. linked[info['name']] = info['source']
  402. self._write_build_config(config)
  403. def unlink_package(self, path):
  404. """Link a package by name or at the given path.
  405. """
  406. path = _normalize_path(path)
  407. config = self._read_build_config()
  408. linked = config.setdefault('linked_packages', dict())
  409. found = None
  410. for (name, source) in linked.items():
  411. if name == path or source == path:
  412. found = name
  413. if found:
  414. del linked[found]
  415. else:
  416. local = config.setdefault('local_extensions', dict())
  417. for (name, source) in local.items():
  418. if name == path or source == path:
  419. found = name
  420. if found:
  421. del local[found]
  422. path = self.info['extensions'][found]['path']
  423. os.remove(path)
  424. if not found:
  425. raise ValueError('No linked package for %s' % path)
  426. self._write_build_config(config)
  427. def toggle_extension(self, extension, value):
  428. """Enable or disable a lab extension.
  429. """
  430. config = self._read_page_config()
  431. disabled = config.setdefault('disabledExtensions', [])
  432. if value and extension not in disabled:
  433. disabled.append(extension)
  434. if not value and extension in disabled:
  435. disabled.remove(extension)
  436. self._write_page_config(config)
  437. def _get_app_info(self):
  438. """Get information about the app.
  439. """
  440. info = dict()
  441. info['core_data'] = core_data = _get_core_data()
  442. info['extensions'] = extensions = self._get_extensions(core_data)
  443. page_config = self._read_page_config()
  444. info['disabled'] = page_config.get('disabledExtensions', [])
  445. info['local_extensions'] = self._get_local_extensions()
  446. info['linked_packages'] = self._get_linked_packages()
  447. info['app_extensions'] = app = []
  448. info['sys_extensions'] = sys = []
  449. for (name, data) in extensions.items():
  450. data['is_local'] = name in info['local_extensions']
  451. if data['location'] == 'app':
  452. app.append(name)
  453. else:
  454. sys.append(name)
  455. info['uninstalled_core'] = self._get_uninstalled_core_extensions()
  456. info['version'] = core_data['jupyterlab']['version']
  457. info['sys_dir'] = self.sys_dir
  458. info['app_dir'] = self.app_dir
  459. info['core_extensions'] = core_extensions = _get_core_extensions()
  460. disabled_core = []
  461. for key in core_extensions:
  462. if key in info['disabled']:
  463. disabled_core.append(key)
  464. info['disabled_core'] = disabled_core
  465. return info
  466. def _populate_staging(self, name=None, version=None, clean=False):
  467. """Set up the assets in the staging directory.
  468. """
  469. app_dir = self.app_dir
  470. staging = pjoin(app_dir, 'staging')
  471. if clean and osp.exists(staging):
  472. self.logger.info("Cleaning %s", staging)
  473. shutil.rmtree(staging)
  474. self._ensure_app_dirs()
  475. if not version:
  476. version = self.info['core_data']['jupyterlab']['version']
  477. # Look for mismatched version.
  478. pkg_path = pjoin(staging, 'package.json')
  479. overwrite_lock = False
  480. if osp.exists(pkg_path):
  481. with open(pkg_path) as fid:
  482. data = json.load(fid)
  483. if data['jupyterlab'].get('version', '') != version:
  484. shutil.rmtree(staging)
  485. os.makedirs(staging)
  486. else:
  487. overwrite_lock = False
  488. for fname in ['index.js', 'webpack.config.js',
  489. 'yarn.lock', '.yarnrc', 'yarn.js']:
  490. if fname == 'yarn.lock' and not overwrite_lock:
  491. continue
  492. shutil.copy(pjoin(HERE, 'staging', fname), pjoin(staging, fname))
  493. # Ensure a clean linked packages directory.
  494. linked_dir = pjoin(staging, 'linked_packages')
  495. if osp.exists(linked_dir):
  496. shutil.rmtree(linked_dir)
  497. os.makedirs(linked_dir)
  498. # Template the package.json file.
  499. # Update the local extensions.
  500. extensions = self.info['extensions']
  501. for (key, source) in self.info['local_extensions'].items():
  502. dname = pjoin(app_dir, 'extensions')
  503. self._update_local(key, source, dname, extensions[key],
  504. 'local_extensions')
  505. # Update the linked packages.
  506. linked = self.info['linked_packages']
  507. for (key, item) in linked.items():
  508. dname = pjoin(staging, 'linked_packages')
  509. self._update_local(key, item['source'], dname, item,
  510. 'linked_packages')
  511. # Then get the package template.
  512. data = self._get_package_template()
  513. if version:
  514. data['jupyterlab']['version'] = version
  515. if name:
  516. data['jupyterlab']['name'] = name
  517. pkg_path = pjoin(staging, 'package.json')
  518. with open(pkg_path, 'w') as fid:
  519. json.dump(data, fid, indent=4)
  520. def _get_package_template(self, silent=False):
  521. """Get the template the for staging package.json file.
  522. """
  523. logger = self.logger
  524. data = self.info['core_data']
  525. local = self.info['local_extensions']
  526. linked = self.info['linked_packages']
  527. extensions = self.info['extensions']
  528. jlab = data['jupyterlab']
  529. def format_path(path):
  530. path = osp.relpath(path, pjoin(self.app_dir, 'staging'))
  531. path = 'file:' + path.replace(os.sep, '/')
  532. if os.name == 'nt':
  533. path = path.lower()
  534. return path
  535. # Handle extensions
  536. compat_errors = self._get_extension_compat()
  537. for (key, value) in extensions.items():
  538. # Reject incompatible extensions with a message.
  539. errors = compat_errors[key]
  540. if errors:
  541. msg = _format_compatibility_errors(
  542. key, value['version'], errors
  543. )
  544. if not silent:
  545. logger.warn(msg + '\n')
  546. continue
  547. data['dependencies'][key] = format_path(value['path'])
  548. jlab_data = value['jupyterlab']
  549. for item in ['extension', 'mimeExtension']:
  550. ext = jlab_data.get(item, False)
  551. if not ext:
  552. continue
  553. if ext is True:
  554. ext = ''
  555. jlab[item + 's'][key] = ext
  556. jlab['linkedPackages'] = dict()
  557. # Handle local extensions.
  558. for (key, source) in local.items():
  559. jlab['linkedPackages'][key] = source
  560. # Handle linked packages.
  561. for (key, item) in linked.items():
  562. path = pjoin(self.app_dir, 'staging', 'linked_packages')
  563. path = pjoin(path, item['filename'])
  564. data['dependencies'][key] = format_path(path)
  565. jlab['linkedPackages'][key] = item['source']
  566. # Handle uninstalled core extensions.
  567. for item in self.info['uninstalled_core']:
  568. if item in jlab['extensions']:
  569. data['jupyterlab']['extensions'].pop(item)
  570. else:
  571. data['jupyterlab']['mimeExtensions'].pop(item)
  572. # Remove from dependencies as well.
  573. data['dependencies'].pop(item)
  574. return data
  575. def _check_local(self, name, source, dname):
  576. # Extract the package in a temporary directory.
  577. with TemporaryDirectory() as tempdir:
  578. info = self._extract_package(source, tempdir)
  579. # Test if the file content has changed.
  580. target = pjoin(dname, info['filename'])
  581. return not osp.exists(target)
  582. def _update_local(self, name, source, dname, data, dtype):
  583. """Update a local dependency. Return `True` if changed.
  584. """
  585. # Extract the package in a temporary directory.
  586. existing = data['filename']
  587. with TemporaryDirectory() as tempdir:
  588. info = self._extract_package(source, tempdir)
  589. # Bail if the file content has not changed.
  590. if info['filename'] == existing:
  591. return existing
  592. shutil.move(info['path'], pjoin(dname, info['filename']))
  593. # Remove the existing tarball and return the new file name.
  594. if existing:
  595. os.remove(pjoin(dname, existing))
  596. data['filename'] = info['filename']
  597. data['path'] = pjoin(data['tar_dir'], data['filename'])
  598. return info['filename']
  599. def _get_extensions(self, core_data):
  600. """Get the extensions for the application.
  601. """
  602. app_dir = self.app_dir
  603. extensions = dict()
  604. # Get system level packages.
  605. sys_path = pjoin(self.sys_dir, 'extensions')
  606. app_path = pjoin(self.app_dir, 'extensions')
  607. extensions = self._get_extensions_in_dir(self.sys_dir, core_data)
  608. # Look in app_dir if different.
  609. app_path = pjoin(app_dir, 'extensions')
  610. if app_path == sys_path or not osp.exists(app_path):
  611. return extensions
  612. extensions.update(self._get_extensions_in_dir(app_dir, core_data))
  613. return extensions
  614. def _get_extensions_in_dir(self, dname, core_data):
  615. """Get the extensions in a given directory.
  616. """
  617. extensions = dict()
  618. location = 'app' if dname == self.app_dir else 'sys'
  619. for target in glob.glob(pjoin(dname, 'extensions', '*.tgz')):
  620. data = _read_package(target)
  621. deps = data.get('dependencies', dict())
  622. name = data['name']
  623. jlab = data.get('jupyterlab', dict())
  624. path = osp.realpath(target)
  625. extensions[name] = dict(path=path,
  626. filename=osp.basename(path),
  627. version=data['version'],
  628. jupyterlab=jlab,
  629. dependencies=deps,
  630. tar_dir=osp.dirname(path),
  631. location=location)
  632. return extensions
  633. def _get_extension_compat(self):
  634. """Get the extension compatibility info.
  635. """
  636. compat = dict()
  637. core_data = self.info['core_data']
  638. for (name, data) in self.info['extensions'].items():
  639. deps = data['dependencies']
  640. compat[name] = _validate_compatibility(name, deps, core_data)
  641. return compat
  642. def _get_local_extensions(self):
  643. """Get the locally installed extensions.
  644. """
  645. return self._get_local_data('local_extensions')
  646. def _get_linked_packages(self):
  647. """Get the linked packages.
  648. """
  649. info = self._get_local_data('linked_packages')
  650. dname = pjoin(self.app_dir, 'staging', 'linked_packages')
  651. for (name, source) in info.items():
  652. info[name] = dict(source=source, filename='', tar_dir=dname)
  653. if not osp.exists(dname):
  654. return info
  655. for path in glob.glob(pjoin(dname, '*.tgz')):
  656. path = osp.realpath(path)
  657. data = _read_package(path)
  658. name = data['name']
  659. if name not in info:
  660. self.logger.warn('Removing orphaned linked package %s' % name)
  661. os.remove(path)
  662. continue
  663. item = info[name]
  664. item['filename'] = osp.basename(path)
  665. item['path'] = path
  666. item['version'] = data['version']
  667. item['data'] = data
  668. return info
  669. def _get_uninstalled_core_extensions(self):
  670. """Get the uninstalled core extensions.
  671. """
  672. config = self._read_build_config()
  673. return config.get('uninstalled_core_extensions', [])
  674. def _ensure_app_dirs(self):
  675. """Ensure that the application directories exist"""
  676. dirs = ['extensions', 'settings', 'staging', 'schemas', 'themes']
  677. for dname in dirs:
  678. path = pjoin(self.app_dir, dname)
  679. if not osp.exists(path):
  680. try:
  681. os.makedirs(path)
  682. except OSError as e:
  683. if e.errno != errno.EEXIST:
  684. raise
  685. def _list_extensions(self, info, ext_type):
  686. """List the extensions of a given type.
  687. """
  688. logger = self.logger
  689. names = info['%s_extensions' % ext_type]
  690. if not names:
  691. return
  692. dname = info['%s_dir' % ext_type]
  693. logger.info(' %s dir: %s' % (ext_type, dname))
  694. for name in sorted(names):
  695. logger.info(name)
  696. data = info['extensions'][name]
  697. version = data['version']
  698. errors = info['compat_errors'][name]
  699. extra = ''
  700. if _is_disabled(name, info['disabled']):
  701. extra += ' %s' % RED_DISABLED
  702. else:
  703. extra += ' %s' % GREEN_ENABLED
  704. if errors:
  705. extra += ' %s' % RED_X
  706. else:
  707. extra += ' %s' % GREEN_OK
  708. if data['is_local']:
  709. extra += '*'
  710. logger.info(' %s v%s%s' % (name, version, extra))
  711. if errors:
  712. msg = _format_compatibility_errors(
  713. name, version, errors
  714. )
  715. logger.warn(msg + '\n')
  716. def _read_build_config(self):
  717. """Get the build config data for the app dir.
  718. """
  719. target = pjoin(self.app_dir, 'settings', 'build_config.json')
  720. if not osp.exists(target):
  721. return {}
  722. else:
  723. with open(target) as fid:
  724. return json.load(fid)
  725. def _write_build_config(self, config):
  726. """Write the build config to the app dir.
  727. """
  728. self._ensure_app_dirs()
  729. target = pjoin(self.app_dir, 'settings', 'build_config.json')
  730. with open(target, 'w') as fid:
  731. json.dump(config, fid, indent=4)
  732. def _read_page_config(self):
  733. """Get the page config data for the app dir.
  734. """
  735. target = pjoin(self.app_dir, 'settings', 'page_config.json')
  736. if not osp.exists(target):
  737. return {}
  738. else:
  739. with open(target) as fid:
  740. return json.load(fid)
  741. def _write_page_config(self, config):
  742. """Write the build config to the app dir.
  743. """
  744. self._ensure_app_dirs()
  745. target = pjoin(self.app_dir, 'settings', 'page_config.json')
  746. with open(target, 'w') as fid:
  747. json.dump(config, fid, indent=4)
  748. def _get_local_data(self, source):
  749. """Get the local data for extensions or linked packages.
  750. """
  751. config = self._read_build_config()
  752. data = config.setdefault(source, dict())
  753. dead = []
  754. for (name, source) in data.items():
  755. if not osp.exists(source):
  756. dead.append(name)
  757. for name in dead:
  758. link_type = source.replace('_', ' ')
  759. msg = '**Note: Removing dead %s "%s"' % (link_type, name)
  760. self.logger.warn(msg)
  761. del data[name]
  762. if dead:
  763. self._write_build_config(config)
  764. return data
  765. def _install_extension(self, extension, tempdir):
  766. """Install an extension with validation and return the name and path.
  767. """
  768. info = self._extract_package(extension, tempdir)
  769. data = info['data']
  770. # Verify that the package is an extension.
  771. messages = _validate_extension(data)
  772. if messages:
  773. msg = '"%s" is not a valid extension:\n%s'
  774. raise ValueError(msg % (extension, '\n'.join(messages)))
  775. # Verify package compatibility.
  776. core_data = _get_core_data()
  777. deps = data.get('dependencies', dict())
  778. errors = _validate_compatibility(extension, deps, core_data)
  779. if errors:
  780. msg = _format_compatibility_errors(
  781. data['name'], data['version'], errors
  782. )
  783. raise ValueError(msg)
  784. # Move the file to the app directory.
  785. target = pjoin(self.app_dir, 'extensions', info['filename'])
  786. if osp.exists(target):
  787. os.remove(target)
  788. shutil.move(info['path'], target)
  789. info['path'] = target
  790. return info
  791. def _extract_package(self, source, tempdir):
  792. # npm pack the extension
  793. is_dir = osp.exists(source) and osp.isdir(source)
  794. if is_dir and not osp.exists(pjoin(source, 'node_modules')):
  795. self._run(['node', YARN_PATH, 'install'], cwd=source)
  796. info = dict(source=source, is_dir=is_dir)
  797. ret = self._run([which('npm'), 'pack', source], cwd=tempdir)
  798. if ret != 0:
  799. msg = '"%s" is not a valid npm package'
  800. raise ValueError(msg % source)
  801. path = glob.glob(pjoin(tempdir, '*.tgz'))[0]
  802. info['data'] = _read_package(path)
  803. if is_dir:
  804. info['sha'] = sha = _tarsum(path)
  805. target = path.replace('.tgz', '-%s.tgz' % sha)
  806. shutil.move(path, target)
  807. info['path'] = target
  808. else:
  809. info['path'] = path
  810. info['filename'] = osp.basename(info['path'])
  811. info['name'] = info['data']['name']
  812. info['version'] = info['data']['version']
  813. return info
  814. def _run(self, cmd, **kwargs):
  815. """Run the command using our logger and abort callback.
  816. Returns the exit code.
  817. """
  818. if self.kill_event.is_set():
  819. raise ValueError('Command was killed')
  820. kwargs['logger'] = self.logger
  821. kwargs['kill_event'] = self.kill_event
  822. proc = Process(cmd, **kwargs)
  823. return proc.wait()
  824. def _normalize_path(extension):
  825. """Normalize a given extension if it is a path.
  826. """
  827. extension = osp.expanduser(extension)
  828. if osp.exists(extension):
  829. extension = osp.abspath(extension)
  830. return extension
  831. def _read_package(target):
  832. """Read the package data in a given target tarball.
  833. """
  834. tar = tarfile.open(target, "r:gz")
  835. f = tar.extractfile('package/package.json')
  836. data = json.loads(f.read().decode('utf8'))
  837. data['jupyterlab_extracted_files'] = [
  838. f.path[len('package/'):] for f in tar.getmembers()
  839. ]
  840. tar.close()
  841. return data
  842. def _validate_extension(data):
  843. """Detect if a package is an extension using its metadata.
  844. Returns any problems it finds.
  845. """
  846. jlab = data.get('jupyterlab', None)
  847. if jlab is None:
  848. return ['No `jupyterlab` key']
  849. if not isinstance(jlab, dict):
  850. return ['The `jupyterlab` key must be a JSON object']
  851. extension = jlab.get('extension', False)
  852. mime_extension = jlab.get('mimeExtension', False)
  853. themeDir = jlab.get('themeDir', '')
  854. schemaDir = jlab.get('schemaDir', '')
  855. messages = []
  856. if not extension and not mime_extension:
  857. messages.append('No `extension` or `mimeExtension` key present')
  858. if extension == mime_extension:
  859. msg = '`mimeExtension` and `extension` must point to different modules'
  860. messages.append(msg)
  861. files = data['jupyterlab_extracted_files']
  862. main = data.get('main', 'index.js')
  863. if not main.endswith('.js'):
  864. main += '.js'
  865. if extension is True:
  866. extension = main
  867. elif extension and not extension.endswith('.js'):
  868. extension += '.js'
  869. if mime_extension is True:
  870. mime_extension = main
  871. elif mime_extension and not mime_extension.endswith('.js'):
  872. mime_extension += '.js'
  873. if extension and extension not in files:
  874. messages.append('Missing extension module "%s"' % extension)
  875. if mime_extension and mime_extension not in files:
  876. messages.append('Missing mimeExtension module "%s"' % mime_extension)
  877. if themeDir and not any(f.startswith(themeDir) for f in files):
  878. messages.append('themeDir is empty: "%s"' % themeDir)
  879. if schemaDir and not any(f.startswith(schemaDir) for f in files):
  880. messages.append('schemaDir is empty: "%s"' % schemaDir)
  881. return messages
  882. def _tarsum(input_file):
  883. """
  884. Compute the recursive sha sum of a tar file.
  885. """
  886. tar = tarfile.open(input_file, "r:gz")
  887. chunk_size = 100 * 1024
  888. h = hashlib.new("sha1")
  889. for member in tar:
  890. if not member.isfile():
  891. continue
  892. f = tar.extractfile(member)
  893. data = f.read(chunk_size)
  894. while data:
  895. h.update(data)
  896. data = f.read(chunk_size)
  897. return h.hexdigest()
  898. def _get_core_data():
  899. """Get the data for the app template.
  900. """
  901. with open(pjoin(HERE, 'staging', 'package.json')) as fid:
  902. return json.load(fid)
  903. def _validate_compatibility(extension, deps, core_data):
  904. """Validate the compatibility of an extension.
  905. """
  906. core_deps = core_data['dependencies']
  907. singletons = core_data['jupyterlab']['singletonPackages']
  908. errors = []
  909. for (key, value) in deps.items():
  910. if key in singletons:
  911. overlap = _test_overlap(core_deps[key], value)
  912. if overlap is False:
  913. errors.append((key, core_deps[key], value))
  914. return errors
  915. def _test_overlap(spec1, spec2):
  916. """Test whether two version specs overlap.
  917. Returns `None` if we cannot determine compatibility,
  918. otherwise whether there is an overlap
  919. """
  920. # Test for overlapping semver ranges.
  921. r1 = Range(spec1, True)
  922. r2 = Range(spec2, True)
  923. # If either range is empty, we cannot verify.
  924. if not r1.range or not r2.range:
  925. return
  926. x1 = r1.set[0][0].semver
  927. x2 = r1.set[0][-1].semver
  928. y1 = r2.set[0][0].semver
  929. y2 = r2.set[0][-1].semver
  930. o1 = r1.set[0][0].operator
  931. o2 = r2.set[0][0].operator
  932. # We do not handle (<) specifiers.
  933. if (o1.startswith('<') or o2.startswith('<')):
  934. return
  935. # Handle single value specifiers.
  936. lx = lte if x1 == x2 else lt
  937. ly = lte if y1 == y2 else lt
  938. gx = gte if x1 == x2 else gt
  939. gy = gte if x1 == x2 else gt
  940. # Handle unbounded (>) specifiers.
  941. def noop(x, y, z):
  942. return True
  943. if x1 == x2 and o1.startswith('>'):
  944. lx = noop
  945. if y1 == y2 and o2.startswith('>'):
  946. ly = noop
  947. # Check for overlap.
  948. return (
  949. gte(x1, y1, True) and ly(x1, y2, True) or
  950. gy(x2, y1, True) and ly(x2, y2, True) or
  951. gte(y1, x1, True) and lx(y1, x2, True) or
  952. gx(y2, x1, True) and lx(y2, x2, True)
  953. )
  954. def _is_disabled(name, disabled=[]):
  955. """Test whether the package is disabled.
  956. """
  957. for pattern in disabled:
  958. if name == pattern:
  959. return True
  960. if re.compile(pattern).match(name) is not None:
  961. return True
  962. return False
  963. def _format_compatibility_errors(name, version, errors):
  964. """Format a message for compatibility errors.
  965. """
  966. msgs = []
  967. l0 = 10
  968. l1 = 10
  969. for error in errors:
  970. pkg, jlab, ext = error
  971. jlab = str(Range(jlab, True))
  972. ext = str(Range(ext, True))
  973. msgs.append((pkg, jlab, ext))
  974. l0 = max(l0, len(pkg) + 1)
  975. l1 = max(l1, len(jlab) + 1)
  976. msg = '\n"%s@%s" is not compatible with the current JupyterLab'
  977. msg = msg % (name, version)
  978. msg += '\nConflicting Dependencies:\n'
  979. msg += 'JupyterLab'.ljust(l0)
  980. msg += 'Extension'.ljust(l1)
  981. msg += 'Package\n'
  982. for (pkg, jlab, ext) in msgs:
  983. msg += jlab.ljust(l0) + ext.ljust(l1) + pkg + '\n'
  984. return msg
  985. def _get_core_extensions():
  986. """Get the core extensions.
  987. """
  988. data = _get_core_data()['jupyterlab']
  989. return list(data['extensions']) + list(data['mimeExtensions'])