# coding: utf-8 """Utilities for installing Javascript extensions for the notebook""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. from __future__ import print_function import importlib import json import os import os.path as osp import platform import shutil import subprocess import sys from os.path import basename, join as pjoin, normpath from jupyter_core.paths import ( jupyter_data_dir, SYSTEM_JUPYTER_PATH, ENV_JUPYTER_PATH, ) from jupyter_core.utils import ensure_dir_exists from jupyterlab_server.config import get_federated_extensions from jupyter_server.extension.serverextension import ArgumentConflict from .commands import _test_overlap DEPRECATED_ARGUMENT = object() HERE = osp.abspath(osp.dirname(__file__)) #------------------------------------------------------------------------------ # Public API #------------------------------------------------------------------------------ def develop_labextension(path, symlink=True, overwrite=False, user=False, labextensions_dir=None, destination=None, logger=None, sys_prefix=False ): """Install a prebuilt extension for JupyterLab Stages files and/or directories into the labextensions directory. By default, this compares modification time, and only stages files that need updating. If `overwrite` is specified, matching files are purged before proceeding. Parameters ---------- path : path to file, directory, zip or tarball archive, or URL to install By default, the file will be installed with its base name, so '/path/to/foo' will install to 'labextensions/foo'. See the destination argument below to change this. Archives (zip or tarballs) will be extracted into the labextensions directory. user : bool [default: False] Whether to install to the user's labextensions directory. Otherwise do a system-wide install (e.g. /usr/local/share/jupyter/labextensions). overwrite : bool [default: False] If True, always install the files, regardless of what may already be installed. symlink : bool [default: True] If True, create a symlink in labextensions, rather than copying files. Windows support for symlinks requires a permission bit which only admin users have by default, so don't rely on it. labextensions_dir : str [optional] Specify absolute path of labextensions directory explicitly. destination : str [optional] name the labextension is installed to. For example, if destination is 'foo', then the source file will be installed to 'labextensions/foo', regardless of the source name. logger : Jupyter logger [optional] Logger instance to use """ # the actual path to which we eventually installed full_dest = None labext = _get_labextension_dir(user=user, sys_prefix=sys_prefix, labextensions_dir=labextensions_dir) # make sure labextensions dir exists ensure_dir_exists(labext) if isinstance(path, (list, tuple)): raise TypeError("path must be a string pointing to a single extension to install; call this function multiple times to install multiple extensions") if not destination: destination = basename(normpath(path)) full_dest = normpath(pjoin(labext, destination)) if overwrite and os.path.lexists(full_dest): if logger: logger.info("Removing: %s" % full_dest) if os.path.isdir(full_dest) and not os.path.islink(full_dest): shutil.rmtree(full_dest) else: os.remove(full_dest) # Make sure the parent directory exists os.makedirs(os.path.dirname(full_dest), exist_ok=True) if symlink: path = os.path.abspath(path) if not os.path.exists(full_dest): if logger: logger.info("Symlinking: %s -> %s" % (full_dest, path)) try: os.symlink(path, full_dest) except OSError as e: if platform.platform().startswith("Windows"): raise OSError( "Symlinks can be activated on Windows 10 for Python version 3.8 or higher" " by activating the 'Developer Mode'. That may not be allowed by your administrators.\n" "See https://docs.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development" ) from e raise elif not os.path.islink(full_dest): raise ValueError("%s exists and is not a symlink" % full_dest) elif os.path.isdir(path): path = pjoin(os.path.abspath(path), '') # end in path separator for parent, dirs, files in os.walk(path): dest_dir = pjoin(full_dest, parent[len(path):]) if not os.path.exists(dest_dir): if logger: logger.info("Making directory: %s" % dest_dir) os.makedirs(dest_dir) for file_name in files: src = pjoin(parent, file_name) dest_file = pjoin(dest_dir, file_name) _maybe_copy(src, dest_file, logger=logger) else: src = path _maybe_copy(src, full_dest, logger=logger) return full_dest def develop_labextension_py(module, user=False, sys_prefix=False, overwrite=True, symlink=True, labextensions_dir=None, logger=None): """Develop a labextension bundled in a Python package. Returns a list of installed/updated directories. See develop_labextension for parameter information.""" m, labexts = _get_labextension_metadata(module) base_path = os.path.split(m.__file__)[0] full_dests = [] for labext in labexts: src = os.path.join(base_path, labext['src']) dest = labext['dest'] if logger: logger.info("Installing %s -> %s" % (src, dest)) if not os.path.exists(src): build_labextension(base_path, logger=logger) full_dest = develop_labextension( src, overwrite=overwrite, symlink=symlink, user=user, sys_prefix=sys_prefix, labextensions_dir=labextensions_dir, destination=dest, logger=logger ) full_dests.append(full_dest) return full_dests def build_labextension(path, logger=None, development=False, static_url=None, source_map = False, core_path=None): """Build a labextension in the given path""" if core_path is None: core_path = osp.join(HERE, 'staging') ext_path = osp.abspath(path) if logger: logger.info('Building extension in %s' % path) builder = _ensure_builder(ext_path, core_path) arguments = ['node', builder, '--core-path', core_path, ext_path] if static_url is not None: arguments.extend(['--static-url', static_url]) if development: arguments.append('--development') if source_map: arguments.append('--source-map') subprocess.check_call(arguments, cwd=ext_path) def watch_labextension(path, labextensions_path, logger=None, development=False, source_map=False, core_path=None): """Watch a labextension in a given path""" if core_path is None: core_path = osp.join(HERE, 'staging') ext_path = osp.abspath(path) if logger: logger.info('Building extension in %s' % path) # Check to see if we need to create a symlink federated_extensions = get_federated_extensions(labextensions_path) with open(pjoin(ext_path, 'package.json')) as fid: ext_data = json.load(fid) if ext_data['name'] not in federated_extensions: develop_labextension_py(ext_path, sys_prefix=True) else: full_dest = pjoin(federated_extensions[ext_data['name']]['ext_dir'], ext_data['name']) output_dir = pjoin(ext_path, ext_data['jupyterlab'].get('outputDir', 'static')) if not osp.islink(full_dest): shutil.rmtree(full_dest) os.symlink(output_dir, full_dest) builder = _ensure_builder(ext_path, core_path) arguments = ['node', builder, '--core-path', core_path, '--watch', ext_path] if development: arguments.append('--development') if source_map: arguments.append('--source-map') subprocess.check_call(arguments, cwd=ext_path) #------------------------------------------------------------------------------ # Private API #------------------------------------------------------------------------------ def _ensure_builder(ext_path, core_path): """Ensure that we can build the extension and return the builder script path """ # Test for compatible dependency on @jupyterlab/builder with open(osp.join(core_path, 'package.json')) as fid: core_data = json.load(fid) with open(osp.join(ext_path, 'package.json')) as fid: ext_data = json.load(fid) depVersion1 = core_data['devDependencies']['@jupyterlab/builder'] depVersion2 = ext_data.get('devDependencies', dict()).get('@jupyterlab/builder') depVersion2 = depVersion2 or ext_data.get('dependencies', dict()).get('@jupyterlab/builder') if depVersion2 is None: raise ValueError('Extensions require a devDependency on @jupyterlab/builder@%s' % depVersion1) # if we have installed from disk (version is a path), assume we know what # we are doing and do not check versions. if '/' in depVersion2: with open(osp.join(ext_path, depVersion2, 'package.json')) as fid: depVersion2 = json.load(fid).get('version') overlap = _test_overlap(depVersion1, depVersion2, drop_prerelease1=True, drop_prerelease2=True) if not overlap: raise ValueError('Extensions require a devDependency on @jupyterlab/builder@%s, you have a dependency on %s' % (depVersion1, depVersion2)) if not osp.exists(osp.join(ext_path, 'node_modules')): subprocess.check_call(['jlpm'], cwd=ext_path) # Find @jupyterlab/builder using node module resolution # We cannot use a script because the script path is a shell script on Windows target = ext_path while not osp.exists(osp.join(target, 'node_modules', '@jupyterlab', 'builder')): if osp.dirname(target) == target: raise ValueError('Could not find @jupyterlab/builder') target = osp.dirname(target) return osp.join(target, 'node_modules', '@jupyterlab', 'builder', 'lib', 'build-labextension.js') def _should_copy(src, dest, logger=None): """Should a file be copied, if it doesn't exist, or is newer? Returns whether the file needs to be updated. Parameters ---------- src : string A path that should exist from which to copy a file src : string A path that might exist to which to copy a file logger : Jupyter logger [optional] Logger instance to use """ if not os.path.exists(dest): return True if os.stat(src).st_mtime - os.stat(dest).st_mtime > 1e-6: # we add a fudge factor to work around a bug in python 2.x # that was fixed in python 3.x: https://bugs.python.org/issue12904 if logger: logger.warn("Out of date: %s" % dest) return True if logger: logger.info("Up to date: %s" % dest) return False def _maybe_copy(src, dest, logger=None): """Copy a file if it needs updating. Parameters ---------- src : string A path that should exist from which to copy a file src : string A path that might exist to which to copy a file logger : Jupyter logger [optional] Logger instance to use """ if _should_copy(src, dest, logger=logger): if logger: logger.info("Copying: %s -> %s" % (src, dest)) shutil.copy2(src, dest) def _get_labextension_dir(user=False, sys_prefix=False, prefix=None, labextensions_dir=None): """Return the labextension directory specified Parameters ---------- user : bool [default: False] Get the user's .jupyter/labextensions directory sys_prefix : bool [default: False] Get sys.prefix, i.e. ~/.envs/my-env/share/jupyter/labextensions prefix : str [optional] Get custom prefix labextensions_dir : str [optional] Get what you put in """ conflicting = [ ('user', user), ('prefix', prefix), ('labextensions_dir', labextensions_dir), ('sys_prefix', sys_prefix), ] conflicting_set = ['{}={!r}'.format(n, v) for n, v in conflicting if v] if len(conflicting_set) > 1: raise ArgumentConflict( "cannot specify more than one of user, sys_prefix, prefix, or labextensions_dir, but got: {}" .format(', '.join(conflicting_set))) if user: labext = pjoin(jupyter_data_dir(), u'labextensions') elif sys_prefix: labext = pjoin(ENV_JUPYTER_PATH[0], u'labextensions') elif prefix: labext = pjoin(prefix, 'share', 'jupyter', 'labextensions') elif labextensions_dir: labext = labextensions_dir else: labext = pjoin(SYSTEM_JUPYTER_PATH[0], 'labextensions') return labext def _get_labextension_metadata(module): """Get the list of labextension paths associated with a Python module. Returns a tuple of (the module path, [{ 'src': 'mockextension', 'dest': '_mockdestination' }]) Parameters ---------- module : str Importable Python module exposing the magic-named `_jupyter_labextension_paths` function """ mod_path = osp.abspath(module) if not osp.exists(mod_path): raise FileNotFoundError('The path `{}` does not exist.'.format(mod_path)) errors = [] # Check if the path is a valid labextension try: m = importlib.import_module(module) if hasattr(m, '_jupyter_labextension_paths') : return m, m._jupyter_labextension_paths() except Exception as exc: errors.append(exc) # Try getting the package name from setup.py try: package = subprocess.check_output([sys.executable, 'setup.py', '--name'], cwd=mod_path).decode('utf8').strip() except subprocess.CalledProcessError: raise FileNotFoundError('The Python package `{}` is not a valid package, ' 'it is missing the `setup.py` file.'.format(module)) # Make sure the package is installed import pkg_resources try: pkg_resources.get_distribution(package) except pkg_resources.DistributionNotFound: subprocess.check_call([sys.executable, '-m', 'pip', 'install', '-e', mod_path]) sys.path.insert(0, mod_path) from setuptools import find_packages, find_namespace_packages package_candidates = [ package.replace('-', '_'), # Module with the same name as package ] package_candidates.extend(find_packages(mod_path)) # Packages in the module path package_candidates.extend(find_namespace_packages(mod_path)) # Namespace packages in the module path for package in package_candidates: try: m = importlib.import_module(package) if hasattr(m, '_jupyter_labextension_paths'): return m, m._jupyter_labextension_paths() except Exception as exc: errors.append(exc) raise ModuleNotFoundError('There is no labextension at {}. Errors encountered: {}'.format(module, errors))