diff --git a/jupyterlab/commands.py b/jupyterlab/commands.py index 64c781f5383c..277ed27fabec 100644 --- a/jupyterlab/commands.py +++ b/jupyterlab/commands.py @@ -39,6 +39,9 @@ DEV_DIR = osp.abspath(os.path.join(HERE, '..', 'dev_mode')) +# If we are pinning the package, rename it `pin@` +PIN_PREFIX = 'pin@' + class ProgressProcess(Process): def __init__(self, cmd, logger=None, cwd=None, kill_event=None, @@ -304,7 +307,8 @@ def watch(app_dir=None, logger=None, core_config=None): return handler.watch() -def install_extension(extension, app_dir=None, logger=None, core_config=None): + +def install_extension(extension, app_dir=None, logger=None, core_config=None, pin=None): """Install an extension package into JupyterLab. The extension is first validated. @@ -314,7 +318,7 @@ def install_extension(extension, app_dir=None, logger=None, core_config=None): logger = _ensure_logger(logger) _node_check(logger) handler = _AppHandler(app_dir, logger, core_config=core_config) - return handler.install_extension(extension) + return handler.install_extension(extension, pin=pin) def uninstall_extension(name=None, app_dir=None, logger=None, all_=False, core_config=None): @@ -491,7 +495,7 @@ def __init__(self, app_dir, logger=None, kill_event=None, core_config=None): # TODO: Make this configurable self.registry = 'https://registry.npmjs.org' - def install_extension(self, extension, existing=None): + def install_extension(self, extension, existing=None, pin=None): """Install an extension package into JupyterLab. The extension is first validated. @@ -518,7 +522,7 @@ def install_extension(self, extension, existing=None): # Install the package using a temporary directory. with TemporaryDirectory() as tempdir: - info = self._install_extension(extension, tempdir) + info = self._install_extension(extension, tempdir, pin=pin) name = info['name'] @@ -787,6 +791,10 @@ def _update_extension(self, name): Returns `True` if a rebuild is recommended, `False` otherwise. """ + data = self.info['extensions'][name] + if data["alias_package_source"]: + self.logger.warn("Skipping updating pinned extension '%s'." % name) + return False try: latest = self._latest_compatible_package_version(name) except URLError: @@ -794,7 +802,7 @@ def _update_extension(self, name): if latest is None: self.logger.warn('No compatible version found for %s!' % (name,)) return False - if latest == self.info['extensions'][name]['version']: + if latest == data['version']: self.logger.info('Extension %r already up to date' % name) return False self.logger.info('Updating %s to version %s' % (name, latest)) @@ -1212,6 +1220,12 @@ def _get_extensions_in_dir(self, dname, core_data): name = data['name'] jlab = data.get('jupyterlab', dict()) path = osp.abspath(target) + + filename = osp.basename(target) + if filename.startswith(PIN_PREFIX): + alias = filename[len(PIN_PREFIX):-len(".tgz")] + else: + alias = None # homepage, repository are optional if 'homepage' in data: url = data['homepage'] @@ -1219,10 +1233,12 @@ def _get_extensions_in_dir(self, dname, core_data): url = data['repository'].get('url', '') else: url = '' - extensions[name] = dict(path=path, + extensions[alias or name] = dict(path=path, filename=osp.basename(path), url=url, version=data['version'], + # Only save the package name if the extension name is an alias + alias_package_source=name if alias else None, jupyterlab=jlab, dependencies=deps, tar_dir=osp.dirname(path), @@ -1316,7 +1332,12 @@ def _list_extensions(self, info, ext_type): extra += ' %s' % GREEN_OK if data['is_local']: extra += '*' - logger.info(' %s v%s%s' % (name, version, extra)) + # If we have the package name in the data, this means this extension's name is the alias name + alias_package_source = data['alias_package_source'] + if alias_package_source: + logger.info(' %s %s v%s%s' % (name, alias_package_source, version, extra)) + else: + logger.info(' %s v%s%s' % (name, version, extra)) if errors: error_accumulator[name] = (version, errors) @@ -1381,10 +1402,10 @@ def _get_local_data(self, source): return data - def _install_extension(self, extension, tempdir): + def _install_extension(self, extension, tempdir, pin=None): """Install an extension with validation and return the name and path. """ - info = self._extract_package(extension, tempdir) + info = self._extract_package(extension, tempdir, pin=pin) data = info['data'] # Verify that the package is an extension. @@ -1438,7 +1459,7 @@ def _install_extension(self, extension, tempdir): info['path'] = target return info - def _extract_package(self, source, tempdir): + def _extract_package(self, source, tempdir, pin=None): """Call `npm pack` for an extension. The pack command will download the package tar if `source` is @@ -1465,6 +1486,11 @@ def _extract_package(self, source, tempdir): info['path'] = target else: info['path'] = path + if pin: + old_path = info['path'] + new_path = pjoin(osp.dirname(old_path), '{}{}.tgz'.format(PIN_PREFIX, pin)) + shutil.move(old_path, new_path) + info['path'] = new_path info['filename'] = osp.basename(info['path']) info['name'] = info['data']['name'] diff --git a/jupyterlab/labextensions.py b/jupyterlab/labextensions.py index 6d1984737eb4..90b4db5c5d77 100644 --- a/jupyterlab/labextensions.py +++ b/jupyterlab/labextensions.py @@ -57,6 +57,8 @@ aliases['minimize'] = 'BaseExtensionApp.minimize' aliases['debug-log-path'] = 'DebugLogFileMixin.debug_log_path' +install_aliases = copy(aliases) +install_aliases['pin-version-as'] = 'InstallLabExtensionApp.pin' VERSION = get_app_version() @@ -111,15 +113,38 @@ def _log_format_default(self): class InstallLabExtensionApp(BaseExtensionApp): - description = "Install labextension(s)" + description = """Install labextension(s) + + Usage + + jupyter labextension install [--pin-version-as ] + + This installs JupyterLab extensions similar to yarn add or npm install. + + Pass a list of comma seperate names to the --pin-version-as flag + to use as alises for the packages providers. This is useful to + install multiple versions of the same extension. + These can be uninstalled with the alias you provided + to the flag, similar to the "alias" feature of yarn add. + """ + aliases = install_aliases + + pin = Unicode('', config=True, + help="Pin this version with a certain alias") def run_task(self): + pinned_versions = self.pin.split(',') self.extra_args = self.extra_args or [os.getcwd()] return any([ install_extension( - arg, self.app_dir, logger=self.log, - core_config=self.core_config) - for arg in self.extra_args + arg, + self.app_dir, + logger=self.log, + core_config=self.core_config, + # Pass in pinned alias if we have it + pin=pinned_versions[i] if i < len(pinned_versions) else None + ) + for i, arg in enumerate(self.extra_args) ]) diff --git a/jupyterlab/tests/test_jupyterlab.py b/jupyterlab/tests/test_jupyterlab.py index 73c95f6173a7..947cdcda5941 100644 --- a/jupyterlab/tests/test_jupyterlab.py +++ b/jupyterlab/tests/test_jupyterlab.py @@ -9,6 +9,10 @@ import os import shutil import sys +import subprocess +import shutil +import pathlib +import platform from os.path import join as pjoin from tempfile import TemporaryDirectory from unittest import TestCase @@ -121,12 +125,15 @@ def ignore(dname, files): self.assertEqual(paths.ENV_CONFIG_PATH, [self.config_dir]) self.assertEqual(paths.ENV_JUPYTER_PATH, [self.data_dir]) self.assertEqual( - commands.get_app_dir(), + os.path.realpath(commands.get_app_dir()), os.path.realpath(pjoin(self.data_dir, 'lab')) ) self.app_dir = commands.get_app_dir() + # Set pinned extension names + self.pinned_packages = ['jupyterlab-test-extension@1.0', 'jupyterlab-test-extension@2.0'] + def test_install_extension(self): assert install_extension(self.mock_extension) is True path = pjoin(self.app_dir, 'extensions', '*.tgz') @@ -228,6 +235,58 @@ def test_uninstall_core_extension(self): assert '@jupyterlab/console-extension' in extensions assert check_extension('@jupyterlab/console-extension') + def test_install_and_uninstall_pinned(self): + """ + You should be able to install different versions of the same extension with different + pinned names and uninstall them with those names. + """ + NAMES = ['test-1', 'test-2'] + assert install_extension(self.pinned_packages[0], pin=NAMES[0]) + assert install_extension(self.pinned_packages[1], pin=NAMES[1]) + + extensions = get_app_info(self.app_dir)['extensions'] + assert NAMES[0] in extensions + assert NAMES[1] in extensions + assert check_extension(NAMES[0]) + assert check_extension(NAMES[1]) + + # Uninstall + assert uninstall_extension(NAMES[0]) + assert uninstall_extension(NAMES[1]) + + extensions = get_app_info(self.app_dir)['extensions'] + assert NAMES[0] not in extensions + assert NAMES[1] not in extensions + assert not check_extension(NAMES[0]) + assert not check_extension(NAMES[1]) + + @pytest.mark.skipif(platform.system() == 'Windows', reason='running npm pack fails on windows CI') + def test_install_and_uninstall_pinned_folder(self): + """ + Same as above test, but installs from a local folder instead of from npm. + """ + # Download each version of the package from NPM: + base_dir = pathlib.Path(self.tempdir()) + + # The archive file names are printed to stdout when run `npm pack` + packages = [ + subprocess.run( + ['npm', 'pack', name], + stdout=subprocess.PIPE, + universal_newlines=True, + check=True, + cwd=base_dir + ).stdout.strip() + for name in self.pinned_packages + ] + + shutil.unpack_archive(str(base_dir / packages[0]), str(base_dir / '1')) + shutil.unpack_archive(str(base_dir / packages[1]), str(base_dir / '2')) + # Change pinned packages to be these directories now, so we install from these folders + self.pinned_packages = [str(base_dir / '1' / 'package'), str(base_dir / '2' / 'package')] + self.test_install_and_uninstall_pinned() + + def test_link_extension(self): path = self.mock_extension name = self.pkg_names['extension'] diff --git a/scripts/travis_script.sh b/scripts/travis_script.sh index ac8ef1bef533..1e1085dbca52 100644 --- a/scripts/travis_script.sh +++ b/scripts/travis_script.sh @@ -142,7 +142,6 @@ if [[ $GROUP == usage ]]; then jupyter lab workspaces export newspace > newspace.json rm workspace.json newspace.json - # Make sure we can call help on all the cli apps. jupyter lab -h jupyter lab build -h