diff --git a/.gitignore b/.gitignore index ebe7773..916a529 100644 --- a/.gitignore +++ b/.gitignore @@ -159,3 +159,5 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ junit.xml + +tests/dummy/ diff --git a/jupyter_builder/base_extension_app.py b/jupyter_builder/base_extension_app.py new file mode 100644 index 0000000..65961b0 --- /dev/null +++ b/jupyter_builder/base_extension_app.py @@ -0,0 +1,64 @@ +"""Jupyter LabExtension Entry Points.""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +import os +from copy import copy + +from jupyter_core.application import JupyterApp, base_aliases, base_flags +from jupyter_core.paths import jupyter_path +from traitlets import List, Unicode, default + +from .debug_log_file_mixin import DebugLogFileMixin + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +# from .federated_labextensions import build_labextension, develop_labextension_py, watch_labextension + +flags = dict(base_flags) + +develop_flags = copy(flags) +develop_flags["overwrite"] = ( + {"DevelopLabExtensionApp": {"overwrite": True}}, + "Overwrite files", +) + +aliases = dict(base_aliases) +aliases["debug-log-path"] = "DebugLogFileMixin.debug_log_path" + +# VERSION = get_app_version() +VERSION = 1 + + +class BaseExtensionApp(JupyterApp, DebugLogFileMixin): + version = VERSION + flags = flags + aliases = aliases + name = "lab" + + labextensions_path = List( + Unicode(), + help="The standard paths to look in for prebuilt JupyterLab extensions", + ) + + # @default("labextensions_path") + # def _default_labextensions_path(self): + # lab = LabApp() + # lab.load_config_file() + # return lab.extra_labextensions_path + lab.labextensions_path + @default("labextensions_path") + def _default_labextensions_path(self) -> list[str]: + return jupyter_path("labextensions") + + def start(self): + with self.debug_logging(): + self.run_task() + + def run_task(self): + pass + + def _log_format_default(self): + """A default format for messages""" + return "%(message)s" diff --git a/jupyter_builder/commands.py b/jupyter_builder/commands.py new file mode 100644 index 0000000..d201b13 --- /dev/null +++ b/jupyter_builder/commands.py @@ -0,0 +1,115 @@ +"""JupyterLab command handler""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +import itertools + +from .jupyterlab_semver import Range, gt, gte, lt, lte + + +def _test_overlap(spec1, spec2, drop_prerelease1=False, drop_prerelease2=False): + """Test whether two version specs overlap. + + Returns `None` if we cannot determine compatibility, + otherwise whether there is an overlap + """ + cmp = _compare_ranges( + spec1, spec2, drop_prerelease1=drop_prerelease1, drop_prerelease2=drop_prerelease2 + ) + if cmp is None: + return + return cmp == 0 + + +def _compare_ranges(spec1, spec2, drop_prerelease1=False, drop_prerelease2=False): # noqa + """Test whether two version specs overlap. + + Returns `None` if we cannot determine compatibility, + otherwise return 0 if there is an overlap, 1 if + spec1 is lower/older than spec2, and -1 if spec1 + is higher/newer than spec2. + """ + # Test for overlapping semver ranges. + r1 = Range(spec1, True) + r2 = Range(spec2, True) + + # If either range is empty, we cannot verify. + if not r1.range or not r2.range: + return + + # Set return_value to a sentinel value + return_value = False + + # r1.set may be a list of ranges if the range involved an ||, so we need to test for overlaps between each pair. + for r1set, r2set in itertools.product(r1.set, r2.set): + x1 = r1set[0].semver + x2 = r1set[-1].semver + y1 = r2set[0].semver + y2 = r2set[-1].semver + + if x1.prerelease and drop_prerelease1: + x1 = x1.inc("patch") + + if y1.prerelease and drop_prerelease2: + y1 = y1.inc("patch") + + o1 = r1set[0].operator + o2 = r2set[0].operator + + # We do not handle (<) specifiers. + if o1.startswith("<") or o2.startswith("<"): + continue + + # Handle single value specifiers. + lx = lte if x1 == x2 else lt + ly = lte if y1 == y2 else lt + gx = gte if x1 == x2 else gt + gy = gte if x1 == x2 else gt + + # Handle unbounded (>) specifiers. + def noop(x, y, z): + return True + + if x1 == x2 and o1.startswith(">"): + lx = noop + if y1 == y2 and o2.startswith(">"): + ly = noop + + # Check for overlap. + if ( + gte(x1, y1, True) + and ly(x1, y2, True) + or gy(x2, y1, True) + and ly(x2, y2, True) + or gte(y1, x1, True) + and lx(y1, x2, True) + or gx(y2, x1, True) + and lx(y2, x2, True) + ): + # if we ever find an overlap, we can return immediately + return 0 + + if gte(y1, x2, True): + if return_value is False: + # We can possibly return 1 + return_value = 1 + elif return_value == -1: + # conflicting information, so we must return None + return_value = None + continue + + if gte(x1, y2, True): + if return_value is False: + return_value = -1 + elif return_value == 1: + # conflicting information, so we must return None + return_value = None + continue + + msg = "Unexpected case comparing version ranges" + raise AssertionError(msg) + + if return_value is False: + return_value = None + return return_value diff --git a/jupyter_builder/debug_log_file_mixin.py b/jupyter_builder/debug_log_file_mixin.py new file mode 100644 index 0000000..878d4eb --- /dev/null +++ b/jupyter_builder/debug_log_file_mixin.py @@ -0,0 +1,64 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +import contextlib +import logging +import os +import sys +import tempfile +import traceback +import warnings + +from traitlets import Unicode +from traitlets.config import Configurable + + +class DebugLogFileMixin(Configurable): + debug_log_path = Unicode("", config=True, help="Path to use for the debug log file") + + @contextlib.contextmanager + def debug_logging(self): + log_path = self.debug_log_path + if os.path.isdir(log_path): + log_path = os.path.join(log_path, "jupyterlab-debug.log") + if not log_path: + handle, log_path = tempfile.mkstemp(prefix="jupyterlab-debug-", suffix=".log") + os.close(handle) + log = self.log + + # Transfer current log level to the handlers: + for h in log.handlers: + h.setLevel(self.log_level) + log.setLevel("DEBUG") + + # Create our debug-level file handler: + _debug_handler = logging.FileHandler(log_path, "w", "utf8", delay=True) + _log_formatter = self._log_formatter_cls(fmt=self.log_format, datefmt=self.log_datefmt) + _debug_handler.setFormatter(_log_formatter) + _debug_handler.setLevel("DEBUG") + + log.addHandler(_debug_handler) + + try: + yield + except Exception as ex: + _, _, exc_traceback = sys.exc_info() + msg = traceback.format_exception(ex.__class__, ex, exc_traceback) + for line in msg: + self.log.debug(line) + if isinstance(ex, SystemExit): + warnings.warn(f"An error occurred. See the log file for details: {log_path}") + raise + warnings.warn("An error occurred.") + warnings.warn(msg[-1].strip()) + warnings.warn(f"See the log file for details: {log_path}") + self.exit(1) + else: + log.removeHandler(_debug_handler) + _debug_handler.flush() + _debug_handler.close() + try: + os.remove(log_path) + except FileNotFoundError: + pass + log.removeHandler(_debug_handler) diff --git a/jupyter_builder/extension_commands/__init__.py b/jupyter_builder/extension_commands/__init__.py new file mode 100644 index 0000000..c146300 --- /dev/null +++ b/jupyter_builder/extension_commands/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. diff --git a/jupyter_builder/extension_commands/build.py b/jupyter_builder/extension_commands/build.py new file mode 100644 index 0000000..37168bc --- /dev/null +++ b/jupyter_builder/extension_commands/build.py @@ -0,0 +1,55 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +import os + +from traitlets import Bool, Unicode + +from ..base_extension_app import BaseExtensionApp +from ..federated_extensions import build_labextension + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +class BuildLabExtensionApp(BaseExtensionApp): + description = "(developer) Build labextension" + + static_url = Unicode("", config=True, help="Sets the url for static assets when building") + + development = Bool(False, config=True, help="Build in development mode") + + source_map = Bool(False, config=True, help="Generate source maps") + + core_path = Unicode( + os.path.join(HERE, "staging"), + config=True, + help="Directory containing core application package.json file", + ) + + aliases = { + "static-url": "BuildLabExtensionApp.static_url", + "development": "BuildLabExtensionApp.development", + "source-map": "BuildLabExtensionApp.source_map", + "core-path": "BuildLabExtensionApp.core_path", + } + + def run_task(self): + self.extra_args = self.extra_args or [os.getcwd()] + build_labextension( + self.extra_args[0], + logger=self.log, + development=self.development, + static_url=self.static_url or None, + source_map=self.source_map, + core_path=self.core_path or None, + ) + + +def main(): + app = BuildLabExtensionApp() + app.initialize() + app.start() + + +if __name__ == "__main__": + main() diff --git a/jupyter_builder/extension_commands/develop.py b/jupyter_builder/extension_commands/develop.py new file mode 100644 index 0000000..cadd51a --- /dev/null +++ b/jupyter_builder/extension_commands/develop.py @@ -0,0 +1,58 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +import os +from copy import copy + +from jupyter_core.application import base_flags +from traitlets import Bool, Unicode + +from ..base_extension_app import BaseExtensionApp +from ..federated_extensions import develop_labextension_py + +flags = dict(base_flags) +develop_flags = copy(flags) +develop_flags["overwrite"] = ( + {"DevelopLabExtensionApp": {"overwrite": True}}, + "Overwrite files", +) + + +class DevelopLabExtensionApp(BaseExtensionApp): + description = "(developer) Develop labextension" + + flags = develop_flags + user = Bool(False, config=True, help="Whether to do a user install") + sys_prefix = Bool(True, config=True, help="Use the sys.prefix as the prefix") + overwrite = Bool(False, config=True, help="Whether to overwrite files") # flags + symlink = Bool(True, config=False, help="Whether to use a symlink") + + labextensions_dir = Unicode( + "", + config=True, + help="Full path to labextensions dir (probably use prefix or user)", + ) + + def run_task(self): + """Add config for this labextension""" + self.extra_args = self.extra_args or [os.getcwd()] + for arg in self.extra_args: + develop_labextension_py( + arg, + user=self.user, + sys_prefix=self.sys_prefix, + labextensions_dir=self.labextensions_dir, + logger=self.log, + overwrite=self.overwrite, + symlink=self.symlink, + ) + + +def main(): + app = DevelopLabExtensionApp() + app.initialize() + app.start() + + +if __name__ == "__main__": + main() diff --git a/jupyter_builder/extension_commands/watch.py b/jupyter_builder/extension_commands/watch.py new file mode 100644 index 0000000..9405ab8 --- /dev/null +++ b/jupyter_builder/extension_commands/watch.py @@ -0,0 +1,53 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +import os + +from traitlets import Bool, Unicode + +from ..base_extension_app import BaseExtensionApp +from ..federated_extensions import watch_labextension + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +class WatchLabExtensionApp(BaseExtensionApp): + description = "(developer) Watch labextension" + + development = Bool(True, config=True, help="Build in development mode") + + source_map = Bool(False, config=True, help="Generate source maps") + + core_path = Unicode( + os.path.join(HERE, "staging"), + config=True, + help="Directory containing core application package.json file", + ) + + aliases = { + "core-path": "WatchLabExtensionApp.core_path", + "development": "WatchLabExtensionApp.development", + "source-map": "WatchLabExtensionApp.source_map", + } + + def run_task(self): + self.extra_args = self.extra_args or [os.getcwd()] + labextensions_path = self.labextensions_path + watch_labextension( + self.extra_args[0], + labextensions_path, + logger=self.log, + development=self.development, + source_map=self.source_map, + core_path=self.core_path or None, + ) + + +def main(): + app = WatchLabExtensionApp() + app.initialize() + app.start() + + +if __name__ == "__main__": + main() diff --git a/jupyter_builder/federated_extensions.py b/jupyter_builder/federated_extensions.py new file mode 100644 index 0000000..0bfa0a6 --- /dev/null +++ b/jupyter_builder/federated_extensions.py @@ -0,0 +1,486 @@ +"""Utilities for installing Javascript extensions for the notebook""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +import importlib +import json +import os +import os.path as osp +import platform +import shutil +import subprocess +import sys +from pathlib import Path + +try: + from importlib.metadata import PackageNotFoundError, version +except ImportError: + from importlib_metadata import PackageNotFoundError, version + +from os.path import basename, normpath +from os.path import join as pjoin + +from jupyter_core.paths import ENV_JUPYTER_PATH, SYSTEM_JUPYTER_PATH, jupyter_data_dir +from jupyter_core.utils import ensure_dir_exists +from jupyter_server.extension.serverextension import ArgumentConflict + +# from jupyterlab_server.config import get_federated_extensions +from .federated_extensions_requirements import get_federated_extensions + +try: + from tomllib import load # Python 3.11+ +except ImportError: + from tomli import load + +# from .commands import _test_overlap TO BE DONE ----------------------------- + +DEPRECATED_ARGUMENT = object() + +HERE = osp.abspath(osp.dirname(__file__)) + + +# ------------------------------------------------------------------------------ +# Public API +# ------------------------------------------------------------------------------ + + +def develop_labextension( # noqa + 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)): + msg = "path must be a string pointing to a single extension to install; call this function multiple times to install multiple extensions" + raise TypeError(msg) + + 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(f"Symlinking: {full_dest} -> {path}") + try: + os.symlink(path, full_dest) + except OSError as e: + if platform.platform().startswith("Windows"): + msg = ( + "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" + ) + raise OSError(msg) 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, _, 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(f"Installing {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""" + core_path = osp.join(HERE, "staging") if core_path is None else str(Path(core_path).resolve()) + + ext_path = str(Path(path).resolve()) + + 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) # noqa S603 + + +def watch_labextension( + path, labextensions_path, logger=None, development=False, source_map=False, core_path=None +): + """Watch a labextension in a given path""" + core_path = osp.join(HERE, "staging") if core_path is None else str(Path(core_path).resolve()) + ext_path = str(Path(path).resolve()) + + 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) # noqa S603 + + +# ------------------------------------------------------------------------------ +# 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) + dep_version1 = core_data["devDependencies"]["@jupyterlab/builder"] + dep_version2 = ext_data.get("devDependencies", {}).get("@jupyterlab/builder") + dep_version2 = dep_version2 or ext_data.get("dependencies", {}).get("@jupyterlab/builder") + if dep_version2 is None: + raise ValueError( + "Extensions require a devDependency on @jupyterlab/builder@%s" % dep_version1 + ) + + # if we have installed from disk (version is a path), assume we know what + # we are doing and do not check versions. + if "/" in dep_version2: + with open(osp.join(ext_path, dep_version2, "package.json")) as fid: + dep_version2 = json.load(fid).get("version") + if not osp.exists(osp.join(ext_path, "node_modules")): + subprocess.check_call(["jlpm"], cwd=ext_path) # noqa S603 S607 + + # 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: + msg = "Could not find @jupyterlab/builder" + raise ValueError(msg) + target = osp.dirname(target) + + overlap = _test_overlap( + dep_version1, dep_version2, drop_prerelease1=True, drop_prerelease2=True + ) + if not overlap: + with open( + osp.join(target, "node_modules", "@jupyterlab", "builder", "package.json") + ) as fid: + dep_version2 = json.load(fid).get("version") + overlap = _test_overlap( + dep_version1, dep_version2, drop_prerelease1=True, drop_prerelease2=True + ) + + if not overlap: + msg = f"Extensions require a devDependency on @jupyterlab/builder@{dep_version1}, you have a dependency on {dep_version2}" + raise ValueError(msg) + + 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: # noqa + # 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.warning("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(f"Copying: {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 = [f"{n}={v!r}" for n, v in conflicting if v] + if len(conflicting_set) > 1: + msg = "cannot specify more than one of user, sys_prefix, prefix, or labextensions_dir, but got: {}".format( + ", ".join(conflicting_set) + ) + raise ArgumentConflict(msg) + if user: + labext = pjoin(jupyter_data_dir(), "labextensions") + elif sys_prefix: + labext = pjoin(ENV_JUPYTER_PATH[0], "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): # noqa + """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): + msg = f"The path `{mod_path}` does not exist." + # breakpoint() + raise FileNotFoundError(msg) + + 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 to get the package name + package = None + + # Try getting the package name from pyproject.toml + if os.path.exists(os.path.join(mod_path, "pyproject.toml")): + with open(os.path.join(mod_path, "pyproject.toml"), "rb") as fid: + data = load(fid) + package = data.get("project", {}).get("name") + + # Try getting the package name from setup.py + if not package: + try: + package = ( + subprocess.check_output( + [sys.executable, "setup.py", "--name"], # noqa S603 + cwd=mod_path, + ) + .decode("utf8") + .strip() + ) + except subprocess.CalledProcessError: + msg = ( + f"The Python package `{module}` is not a valid package, " + "it is missing the `setup.py` file." + ) + raise FileNotFoundError(msg) from None + + # Make sure the package is installed + try: + version(package) + except PackageNotFoundError: + subprocess.check_call([sys.executable, "-m", "pip", "install", "-e", mod_path]) # noqa S603 + sys.path.insert(0, mod_path) + + from setuptools import find_namespace_packages, find_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) + + msg = f"There is no labextension at {module}. Errors encountered: {errors}" + raise ModuleNotFoundError(msg) diff --git a/jupyter_builder/federated_extensions_requirements.py b/jupyter_builder/federated_extensions_requirements.py new file mode 100644 index 0000000..af98936 --- /dev/null +++ b/jupyter_builder/federated_extensions_requirements.py @@ -0,0 +1,74 @@ +"""JupyterLab Server config""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. +from __future__ import annotations + +import json +import os.path as osp +from glob import iglob +from itertools import chain + +# from logging import Logger +from os.path import join as pjoin +from typing import Any + +# import json5 # type:ignore[import-untyped] +# from jupyter_core.paths import SYSTEM_CONFIG_PATH, jupyter_config_dir, jupyter_path +# from jupyter_server.services.config.manager import ConfigManager, recursive_update +# from jupyter_server.utils import url_path_join as ujoin +# from traitlets import Bool, HasTraits, List, Unicode, default + +# ----------------------------------------------------------------------------- +# Module globals +# ----------------------------------------------------------------------------- + +DEFAULT_TEMPLATE_PATH = osp.join(osp.dirname(__file__), "templates") + + +def get_package_url(data: dict[str, Any]) -> str: + """Get the url from the extension data""" + # homepage, repository are optional + if "homepage" in data: + url = data["homepage"] + elif "repository" in data and isinstance(data["repository"], dict): + url = data["repository"].get("url", "") + else: + url = "" + return url + + +def get_federated_extensions(labextensions_path: list[str]) -> dict[str, Any]: + """Get the metadata about federated extensions""" + federated_extensions = {} + for ext_dir in labextensions_path: + # extensions are either top-level directories, or two-deep in @org directories + for ext_path in chain( + iglob(pjoin(ext_dir, "[!@]*", "package.json")), + iglob(pjoin(ext_dir, "@*", "*", "package.json")), + ): + with open(ext_path, encoding="utf-8") as fid: + pkgdata = json.load(fid) + if pkgdata["name"] not in federated_extensions: + data = dict( + name=pkgdata["name"], + version=pkgdata["version"], + description=pkgdata.get("description", ""), + url=get_package_url(pkgdata), + ext_dir=ext_dir, + ext_path=osp.dirname(ext_path), + is_local=False, + dependencies=pkgdata.get("dependencies", dict()), + jupyterlab=pkgdata.get("jupyterlab", dict()), + ) + + # Add repository info if available + if "repository" in pkgdata and "url" in pkgdata.get("repository", {}): + data["repository"] = dict(url=pkgdata.get("repository").get("url")) + + install_path = osp.join(osp.dirname(ext_path), "install.json") + if osp.exists(install_path): + with open(install_path, encoding="utf-8") as fid: + data["install"] = json.load(fid) + federated_extensions[data["name"]] = data + return federated_extensions diff --git a/jupyter_builder/jupyterlab_semver.py b/jupyter_builder/jupyterlab_semver.py new file mode 100644 index 0000000..24cc930 --- /dev/null +++ b/jupyter_builder/jupyterlab_semver.py @@ -0,0 +1,252 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +# This file comes from https://github.com/podhmo/python-semver/blob/b42e9896e391e086b773fc621b23fa299d16b874/semver/__init__.py +# +# It is licensed under the following license: +# +# MIT License + +# Copyright (c) 2016 podhmo + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +def semver(version, loose): + if isinstance(version, SemVer): + if version.loose == loose: + return version + else: + version = version.version + elif not isinstance(version, string_type): # xxx: + raise ValueError(f"Invalid Version: {version}") + + """ + if (!(this instanceof SemVer)) + return new SemVer(version, loose); + """ + return SemVer(version, loose) + + +make_semver = semver + + +class SemVer: + def __init__(self, version, loose): + logger.debug("SemVer %s, %s", version, loose) + self.loose = loose + self.raw = version + + m = regexp[LOOSE if loose else FULL].search(version.strip()) + if not m: + if not loose: + raise ValueError(f"Invalid Version: {version}") + m = regexp[RECOVERYVERSIONNAME].search(version.strip()) + self.major = int(m.group(1)) if m.group(1) else 0 + self.minor = int(m.group(2)) if m.group(2) else 0 + self.patch = 0 + if not m.group(3): + self.prerelease = [] + else: + self.prerelease = [ + (int(id) if NUMERIC.search(id) else id) for id in m.group(3).split(".") + ] + else: + # these are actually numbers + self.major = int(m.group(1)) + self.minor = int(m.group(2)) + self.patch = int(m.group(3)) + # numberify any prerelease numeric ids + if not m.group(4): + self.prerelease = [] + else: + self.prerelease = [ + (int(id) if NUMERIC.search(id) else id) for id in m.group(4).split(".") + ] + if m.group(5): + self.build = m.group(5).split(".") + else: + self.build = [] + + self.format() # xxx: + + def format(self): + self.version = f"{self.major}.{self.minor}.{self.patch}" + if len(self.prerelease) > 0: + self.version += "-{}".format(".".join(str(v) for v in self.prerelease)) + return self.version + + def __repr__(self): + return f"" + + def __str__(self): + return self.version + + def compare(self, other): + logger.debug("SemVer.compare %s %s %s", self.version, self.loose, other) + if not isinstance(other, SemVer): + other = make_semver(other, self.loose) + result = self.compare_main(other) or self.compare_pre(other) + logger.debug("compare result %s", result) + return result + + def compare_main(self, other): + if not isinstance(other, SemVer): + other = make_semver(other, self.loose) + + return ( + compare_identifiers(str(self.major), str(other.major)) + or compare_identifiers(str(self.minor), str(other.minor)) + or compare_identifiers(str(self.patch), str(other.patch)) + ) + + def compare_pre(self, other): + if not isinstance(other, SemVer): + other = make_semver(other, self.loose) + + # NOT having a prerelease is > having one + is_self_more_than_zero = len(self.prerelease) > 0 + is_other_more_than_zero = len(other.prerelease) > 0 + + if not is_self_more_than_zero and is_other_more_than_zero: + return 1 + elif is_self_more_than_zero and not is_other_more_than_zero: + return -1 + elif not is_self_more_than_zero and not is_other_more_than_zero: + return 0 + + i = 0 + while True: + a = list_get(self.prerelease, i) + b = list_get(other.prerelease, i) + logger.debug("prerelease compare %s: %s %s", i, a, b) + i += 1 + if a is None and b is None: + return 0 + elif b is None: + return 1 + elif a is None: + return -1 + elif a == b: + continue + else: + return compare_identifiers(str(a), str(b)) + + def inc(self, release, identifier=None): + logger.debug("inc release %s %s", self.prerelease, release) + if release == "premajor": + self.prerelease = [] + self.patch = 0 + self.minor = 0 + self.major += 1 + self.inc("pre", identifier=identifier) + elif release == "preminor": + self.prerelease = [] + self.patch = 0 + self.minor += 1 + self.inc("pre", identifier=identifier) + elif release == "prepatch": + # If this is already a prerelease, it will bump to the next version + # drop any prereleases that might already exist, since they are not + # relevant at this point. + self.prerelease = [] + self.inc("patch", identifier=identifier) + self.inc("pre", identifier=identifier) + elif release == "prerelease": + # If the input is a non-prerelease version, this acts the same as + # prepatch. + if len(self.prerelease) == 0: + self.inc("patch", identifier=identifier) + self.inc("pre", identifier=identifier) + elif release == "major": + # If this is a pre-major version, bump up to the same major version. + # Otherwise increment major. + # 1.0.0-5 bumps to 1.0.0 + # 1.1.0 bumps to 2.0.0 + if self.minor != 0 or self.patch != 0 or len(self.prerelease) == 0: + self.major += 1 + self.minor = 0 + self.patch = 0 + self.prerelease = [] + elif release == "minor": + # If this is a pre-minor version, bump up to the same minor version. + # Otherwise increment minor. + # 1.2.0-5 bumps to 1.2.0 + # 1.2.1 bumps to 1.3.0 + if self.patch != 0 or len(self.prerelease) == 0: + self.minor += 1 + self.patch = 0 + self.prerelease = [] + elif release == "patch": + # If this is not a pre-release version, it will increment the patch. + # If it is a pre-release it will bump up to the same patch version. + # 1.2.0-5 patches to 1.2.0 + # 1.2.0 patches to 1.2.1 + if len(self.prerelease) == 0: + self.patch += 1 + self.prerelease = [] + elif release == "pre": + # This probably shouldn't be used publicly. + # 1.0.0 "pre" would become 1.0.0-0 which is the wrong direction. + logger.debug("inc prerelease %s", self.prerelease) + if len(self.prerelease) == 0: + self.prerelease = [0] + else: + i = len(self.prerelease) - 1 + while i >= 0: + if isinstance(self.prerelease[i], int): + self.prerelease[i] += 1 + i -= 2 + i -= 1 + # ## this is needless code in python ## + # if i == -1: # didn't increment anything + # self.prerelease.append(0) + if identifier is not None: + # 1.2.0-beta.1 bumps to 1.2.0-beta.2, + # 1.2.0-beta.fooblz or 1.2.0-beta bumps to 1.2.0-beta.0 + if self.prerelease[0] == identifier: + if not isinstance(self.prerelease[1], int): + self.prerelease = [identifier, 0] + else: + self.prerelease = [identifier, 0] + else: + raise ValueError(f"invalid increment argument: {release}") + self.format() + self.raw = self.version + return self + + +def compare(a, b, loose): + return make_semver(a, loose).compare(b) + + +def gt(a, b, loose): + return compare(a, b, loose) > 0 + + +def lt(a, b, loose): + return compare(a, b, loose) < 0 + + +def gte(a, b, loose): + return compare(a, b, loose) >= 0 + + +def lte(a, b, loose): + return compare(a, b, loose) <= 0 diff --git a/jupyter_builder/jupyterlab_server.py b/jupyter_builder/jupyterlab_server.py new file mode 100644 index 0000000..7b3489d --- /dev/null +++ b/jupyter_builder/jupyterlab_server.py @@ -0,0 +1,66 @@ +"""JupyterLab Server config""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. +from __future__ import annotations + +import json +import os.path as osp +from glob import iglob +from itertools import chain +from os.path import join as pjoin +from typing import Any + +# ----------------------------------------------------------------------------- +# Module globals +# ----------------------------------------------------------------------------- + +DEFAULT_TEMPLATE_PATH = osp.join(osp.dirname(__file__), "templates") + + +def get_package_url(data: dict[str, Any]) -> str: + """Get the url from the extension data""" + # homepage, repository are optional + if "homepage" in data: + url = data["homepage"] + elif "repository" in data and isinstance(data["repository"], dict): + url = data["repository"].get("url", "") + else: + url = "" + return url + + +def get_federated_extensions(labextensions_path: list[str]) -> dict[str, Any]: + """Get the metadata about federated extensions""" + federated_extensions = {} + for ext_dir in labextensions_path: + # extensions are either top-level directories, or two-deep in @org directories + for ext_path in chain( + iglob(pjoin(ext_dir, "[!@]*", "package.json")), + iglob(pjoin(ext_dir, "@*", "*", "package.json")), + ): + with open(ext_path, encoding="utf-8") as fid: + pkgdata = json.load(fid) + if pkgdata["name"] not in federated_extensions: + data = dict( + name=pkgdata["name"], + version=pkgdata["version"], + description=pkgdata.get("description", ""), + url=get_package_url(pkgdata), + ext_dir=ext_dir, + ext_path=osp.dirname(ext_path), + is_local=False, + dependencies=pkgdata.get("dependencies", dict()), + jupyterlab=pkgdata.get("jupyterlab", dict()), + ) + + # Add repository info if available + if "repository" in pkgdata and "url" in pkgdata.get("repository", {}): + data["repository"] = dict(url=pkgdata.get("repository").get("url")) + + install_path = osp.join(osp.dirname(ext_path), "install.json") + if osp.exists(install_path): + with open(install_path, encoding="utf-8") as fid: + data["install"] = json.load(fid) + federated_extensions[data["name"]] = data + return federated_extensions diff --git a/jupyter_builder/main.py b/jupyter_builder/main.py new file mode 100644 index 0000000..bb82d56 --- /dev/null +++ b/jupyter_builder/main.py @@ -0,0 +1,55 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +# # Copyright (c) Jupyter Development Team. +# # Distributed under the terms of the Modified BSD License. + +import sys + +from jupyter_core.application import JupyterApp + +from jupyter_builder.extension_commands.develop import DevelopLabExtensionApp + +from jupyter_builder.extension_commands.build import BuildLabExtensionApp +# from .commands.watch import WatchLabExtensionApp + + +class LabExtensionApp(JupyterApp): + """Base jupyter labextension command entry point""" + + name = "jupyter labextension" + # version = VERSION + description = "Work with JupyterLab extensions" + # examples = _EXAMPLES + + subcommands = { + # "install": (InstallLabExtensionApp, "Install labextension(s)"), + # "update": (UpdateLabExtensionApp, "Update labextension(s)"), + # "uninstall": (UninstallLabExtensionApp, "Uninstall labextension(s)"), + # "list": (ListLabExtensionsApp, "List labextensions"), + # "link": (LinkLabExtensionApp, "Link labextension(s)"), + # "unlink": (UnlinkLabExtensionApp, "Unlink labextension(s)"), + # "enable": (EnableLabExtensionsApp, "Enable labextension(s)"), + # "disable": (DisableLabExtensionsApp, "Disable labextension(s)"), + # "lock": (LockLabExtensionsApp, "Lock labextension(s)"), + # "unlock": (UnlockLabExtensionsApp, "Unlock labextension(s)"), + # "check": (CheckLabExtensionsApp, "Check labextension(s)"), + "develop": (DevelopLabExtensionApp, "(developer) Develop labextension(s)"), + "build": (BuildLabExtensionApp, "(developer) Build labextension"), + # "watch": (WatchLabExtensionApp, "(developer) Watch labextension"), + } + + def start(self): + """Perform the App's functions as configured""" + super().start() + + # The above should have called a subcommand and raised NoStart; if we + # get here, it didn't, so we should self.log.info a message. + subcmds = ", ".join(sorted(self.subcommands)) + self.exit(f"Please supply at least one subcommand: {subcmds}") + + +main = LabExtensionApp.launch_instance + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index fc67ac6..8ad9136 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,9 @@ dev = [ "ruff==0.4.7", ] -# [project.scripts] +[project.scripts] +jupyter-builder = "jupyter_builder.main:main" + [tool.check-wheel-contents] diff --git a/tests/test_tpl.py b/tests/test_tpl.py index 6e8a2c0..831848f 100644 --- a/tests/test_tpl.py +++ b/tests/test_tpl.py @@ -1,6 +1,5 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. - def test_tpl(): assert True