From 6fa038f12ad7946e16c5e18665e8a88177080264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Collonval?= Date: Wed, 22 Dec 2021 13:19:49 +0100 Subject: [PATCH] Move workspace management in jupyterlab_server (#227) --- jupyterlab_server/__init__.py | 10 +- jupyterlab_server/handlers.py | 4 +- jupyterlab_server/workspaces_app.py | 171 +++++++++++++++++ jupyterlab_server/workspaces_handler.py | 243 +++++++++++++----------- 4 files changed, 316 insertions(+), 112 deletions(-) create mode 100644 jupyterlab_server/workspaces_app.py diff --git a/jupyterlab_server/__init__.py b/jupyterlab_server/__init__.py index 9e415dee..48b40a36 100644 --- a/jupyterlab_server/__init__.py +++ b/jupyterlab_server/__init__.py @@ -4,7 +4,8 @@ from .app import LabServerApp from .licenses_app import LicensesApp from .handlers import add_handlers, LabHandler, LabConfig -from .workspaces_handler import slugify, WORKSPACE_EXTENSION +from .translation_utils import translator +from .workspaces_app import WorkspaceExportApp, WorkspaceImportApp, WorkspaceListApp from ._version import __version__ __all__ = [ @@ -13,9 +14,12 @@ 'LabConfig', 'LabHandler', 'LabServerApp', - 'slugify', + 'LicensesApp', 'SETTINGS_EXTENSION', - 'WORKSPACE_EXTENSION' + 'translator', + 'WorkspaceExportApp', + 'WorkspaceImportApp', + 'WorkspaceListApp' ] def _jupyter_server_extension_points(): diff --git a/jupyterlab_server/handlers.py b/jupyterlab_server/handlers.py index a6dfcc4f..4aaad046 100755 --- a/jupyterlab_server/handlers.py +++ b/jupyterlab_server/handlers.py @@ -18,7 +18,7 @@ from .settings_handler import SettingsHandler from .themes_handler import ThemesHandler from .translations_handler import TranslationsHandler -from .workspaces_handler import WorkspacesHandler +from .workspaces_handler import WorkspacesHandler, WorkspacesManager from .licenses_handler import LicensesHandler, LicensesManager # ----------------------------------------------------------------------------- @@ -239,7 +239,7 @@ def add_handlers(handlers, extension_app): if extension_app.workspaces_dir: workspaces_config = { - 'path': extension_app.workspaces_dir + 'manager': WorkspacesManager(extension_app.workspaces_dir) } # Handle requests for the list of workspaces. Make slash optional. diff --git a/jupyterlab_server/workspaces_app.py b/jupyterlab_server/workspaces_app.py new file mode 100644 index 00000000..d1c56bab --- /dev/null +++ b/jupyterlab_server/workspaces_app.py @@ -0,0 +1,171 @@ +"""""" +import json +import sys +from pathlib import Path + +from jupyter_core.application import JupyterApp +from traitlets import Bool, Unicode + +from ._version import __version__ +from .config import LabConfig +from .workspaces_handler import WorkspacesManager + + +class WorkspaceListApp(JupyterApp, LabConfig): + version = __version__ + description = """ + Print all the workspaces available + + If '--json' flag is passed in, a single 'json' object is printed. + If '--jsonlines' flag is passed in, 'json' object of each workspace separated by a new line is printed. + If nothing is passed in, workspace ids list is printed. + """ + flags = dict( + jsonlines=( + {"WorkspaceListApp": {"jsonlines": True}}, + ("Produce machine-readable JSON Lines output."), + ), + json=( + {"WorkspaceListApp": {"json": True}}, + ("Produce machine-readable JSON object output."), + ), + ) + + jsonlines = Bool( + False, + config=True, + help=( + "If True, the output will be a newline-delimited JSON (see https://jsonlines.org/) of objects, " + "one per JupyterLab workspace, each with the details of the relevant workspace" + ), + ) + json = Bool( + False, + config=True, + help=( + "If True, each line of output will be a JSON object with the " + "details of the workspace." + ), + ) + + def initialize(self, *args, **kwargs): + super().initialize(*args, **kwargs) + self.manager = WorkspacesManager(self.workspaces_dir) + + def start(self): + workspaces = self.manager.list_workspaces() + if self.jsonlines: + for workspace in workspaces: + print(json.dumps(workspace)) + elif self.json: + print(json.dumps(workspaces)) + else: + for workspace in workspaces: + print(workspace["metadata"]["id"]) + + +class WorkspaceExportApp(JupyterApp, LabConfig): + version = __version__ + description = """ + Export a JupyterLab workspace + + If no arguments are passed in, this command will export the default + workspace. + If a workspace name is passed in, this command will export that workspace. + If no workspace is found, this command will export an empty workspace. + """ + + def initialize(self, *args, **kwargs): + super().initialize(*args, **kwargs) + self.manager = WorkspacesManager(self.workspaces_dir) + + def start(self): + if len(self.extra_args) > 1: + print("Too many arguments were provided for workspace export.") + self.exit(1) + + raw = self.extra_args[0] + try: + workspace = self.manager.load(raw) + print(json.dumps(workspace)) + except Exception as e: + print(json.dumps(dict(data=dict(), metadata=dict(id=raw)))) + + +class WorkspaceImportApp(JupyterApp, LabConfig): + version = __version__ + description = """ + Import a JupyterLab workspace + + This command will import a workspace from a JSON file. The format of the + file must be the same as what the export functionality emits. + """ + workspace_name = Unicode( + None, + config=True, + allow_none=True, + help=""" + Workspace name. If given, the workspace ID in the imported + file will be replaced with a new ID pointing to this + workspace name. + """, + ) + + aliases = {"name": "WorkspaceImportApp.workspace_name"} + + def initialize(self, *args, **kwargs): + super().initialize(*args, **kwargs) + self.manager = WorkspacesManager(self.workspaces_dir) + + def start(self): + + if len(self.extra_args) != 1: + print("One argument is required for workspace import.") + self.exit(1) + + with self._smart_open() as fid: + try: # to load, parse, and validate the workspace file. + workspace = self._validate(fid) + except Exception as e: + print("%s is not a valid workspace:\n%s" % (fid.name, e)) + self.exit(1) + + try: + workspace_path = self.manager.save( + workspace["metadata"]["id"], json.dumps(workspace) + ) + except Exception as e: + print(f"Workspace could not be exported:\n{e!s}") + self.exit(1) + + print(f"Saved workspace: {workspace_path!s}") + + def _smart_open(self): + file_name = self.extra_args[0] + + if file_name == "-": + return sys.stdin + else: + file_path = Path(file_name).resolve() + + if not file_path.exists(): + print(f"{file_name!s} does not exist.") + self.exit(1) + + return file_path.open(encoding="utf-8") + + def _validate(self, data): + workspace = json.load(data) + + if "data" not in workspace: + raise Exception("The `data` field is missing.") + + # If workspace_name is set in config, inject the + # name into the workspace metadata. + if self.workspace_name is not None and self.workspace_name != "": + workspace["metadata"] = {"id": self.workspace_name} + else: + if "id" not in workspace["metadata"]: + raise Exception("The `id` field is missing in `metadata`.") + + return workspace diff --git a/jupyterlab_server/workspaces_handler.py b/jupyterlab_server/workspaces_handler.py index 4f312f5c..1d106b6e 100644 --- a/jupyterlab_server/workspaces_handler.py +++ b/jupyterlab_server/workspaces_handler.py @@ -4,64 +4,66 @@ # Distributed under the terms of the Modified BSD License. import hashlib import json -import os import re import unicodedata import urllib +from pathlib import Path -from jupyter_server.extension.handler import ExtensionHandlerJinjaMixin, ExtensionHandlerMixin +from jupyter_server.extension.handler import ( + ExtensionHandlerJinjaMixin, + ExtensionHandlerMixin, +) from tornado import web +from traitlets.config import LoggingConfigurable from .server import APIHandler, tz from .server import url_path_join as ujoin # The JupyterLab workspace file extension. -WORKSPACE_EXTENSION = '.jupyterlab-workspace' +WORKSPACE_EXTENSION = ".jupyterlab-workspace" -def _list_workspaces(directory, prefix): + +def _list_workspaces(directory: Path, prefix: str) -> list: """ Return the list of workspaces in a given directory beginning with the given prefix. """ - workspaces = { 'ids': [], 'values': [] } - if not os.path.exists(directory): + workspaces = [] + if not directory.exists(): return workspaces - items = [item - for item in os.listdir(directory) - if item.startswith(prefix) and - item.endswith(WORKSPACE_EXTENSION)] + items = [ + item + for item in directory.iterdir() + if item.name.startswith(prefix) and item.name.endswith(WORKSPACE_EXTENSION) + ] items.sort() for slug in items: - workspace_path = os.path.join(directory, slug) - if os.path.exists(workspace_path): - try: - workspace = _load_with_file_times(workspace_path) - workspaces.get('ids').append(workspace['metadata']['id']) - workspaces.get('values').append(workspace) - except Exception as e: - raise web.HTTPError(500, str(e)) + workspace_path: Path = directory / slug + if workspace_path.exists(): + workspace = _load_with_file_times(workspace_path) + workspaces.append(workspace) return workspaces -def _load_with_file_times(workspace_path): +def _load_with_file_times(workspace_path: Path) -> dict: """ Load workspace JSON from disk, overwriting the `created` and `last_modified` metadata with current file stat information """ - stat = os.stat(workspace_path) - with open(workspace_path, encoding='utf-8') as fid: + stat = workspace_path.stat() + with workspace_path.open(encoding="utf-8") as fid: workspace = json.load(fid) workspace["metadata"].update( last_modified=tz.utcfromtimestamp(stat.st_mtime).isoformat(), - created=tz.utcfromtimestamp(stat.st_ctime).isoformat() + created=tz.utcfromtimestamp(stat.st_ctime).isoformat(), ) return workspace -def slugify(raw, base='', sign=True, max_length=128 - len(WORKSPACE_EXTENSION)): +def slugify(raw, base="", sign=True, max_length=128 - len(WORKSPACE_EXTENSION)): """ Use the common superset of raw and base values to build a slug shorter than max_length. By default, base value is an empty string. @@ -72,12 +74,12 @@ def slugify(raw, base='', sign=True, max_length=128 - len(WORKSPACE_EXTENSION)): Modified from Django utils: https://github.com/django/django/blob/master/django/utils/text.py """ - raw = raw if raw.startswith('/') else '/' + raw - signature = '' + raw = raw if raw.startswith("/") else "/" + raw + signature = "" if sign: data = raw[1:] # Remove initial slash that always exists for digest. - signature = '-' + hashlib.sha256(data.encode('utf-8')).hexdigest()[:4] - base = (base if base.startswith('/') else '/' + base).lower() + signature = "-" + hashlib.sha256(data.encode("utf-8")).hexdigest()[:4] + base = (base if base.startswith("/") else "/" + base).lower() raw = raw.lower() common = 0 limit = min(len(base), len(raw)) @@ -85,89 +87,57 @@ def slugify(raw, base='', sign=True, max_length=128 - len(WORKSPACE_EXTENSION)): common += 1 value = ujoin(base[common:], raw) value = urllib.parse.unquote(value) - value = (unicodedata - .normalize('NFKC', value) - .encode('ascii', 'ignore') - .decode('ascii')) - value = re.sub(r'[^\w\s-]', '', value).strip() - value = re.sub(r'[-\s]+', '-', value) - return value[:max_length - len(signature)] + signature - + value = ( + unicodedata.normalize("NFKC", value).encode("ascii", "ignore").decode("ascii") + ) + value = re.sub(r"[^\w\s-]", "", value).strip() + value = re.sub(r"[-\s]+", "-", value) + return value[: max_length - len(signature)] + signature -class WorkspacesHandler(ExtensionHandlerMixin, ExtensionHandlerJinjaMixin, APIHandler): - def initialize(self, name, path, workspaces_url=None, **kwargs): - super().initialize(name) - self.workspaces_dir = path +class WorkspacesManager(LoggingConfigurable): + """A manager for workspaces.""" - def ensure_directory(self): - """Return the workspaces directory if set or raise error if not set""" - if not self.workspaces_dir: - raise web.HTTPError(500, 'Workspaces directory is not set') + def __init__(self, path): + """Initialize a workspaces manager with content in ``path``.""" + super() + if not path: + raise ValueError("Workspaces directory is not set") + self.workspaces_dir = Path(path) - return self.workspaces_dir - - @web.authenticated def delete(self, space_name): - """Remove a workspace""" - directory = self.ensure_directory() - - if not space_name: - raise web.HTTPError(400, 'Workspace name is required for DELETE') - + """Remove a workspace ``space_name``.""" slug = slugify(space_name) - workspace_path = os.path.join(directory, slug + WORKSPACE_EXTENSION) + workspace_path = self.workspaces_dir / (slug + WORKSPACE_EXTENSION) - if not os.path.exists(workspace_path): - raise web.HTTPError(404, 'Workspace %r (%r) not found' % - (space_name, slug)) - - try: # to delete the workspace file. - os.remove(workspace_path) - return self.set_status(204) - except Exception as e: - raise web.HTTPError(500, str(e)) + if not workspace_path.exists(): + raise FileNotFoundError(f"Workspace {space_name!r} ({slug!r}) not found") - @web.authenticated - def get(self, space_name=''): - """Get workspace(s) data""" - directory = self.ensure_directory() + # to delete the workspace file. + workspace_path.unlink() - if not space_name: - prefix = slugify('', sign=False) - workspaces = _list_workspaces(directory, prefix) - return self.finish(json.dumps(dict(workspaces=workspaces))) + def list_workspaces(self) -> list: + """List all available workspaces.""" + prefix = slugify("", sign=False) + return _list_workspaces(self.workspaces_dir, prefix) + def load(self, space_name: str) -> dict: + """Load the workspace ``space_name``.""" slug = slugify(space_name) - workspace_path = os.path.join(directory, slug + WORKSPACE_EXTENSION) + workspace_path = self.workspaces_dir / (slug + WORKSPACE_EXTENSION) - if os.path.exists(workspace_path): - try: # to load and parse the workspace file. - workspace = _load_with_file_times(workspace_path) - except Exception as e: - raise web.HTTPError(500, str(e)) + if workspace_path.exists(): + # to load and parse the workspace file. + return _load_with_file_times(workspace_path) else: - id = (space_name if space_name.startswith('/') - else '/' + space_name) - workspace = dict(data=dict(), metadata=dict(id=id)) - - return self.finish(json.dumps(workspace)) + id = space_name if space_name.startswith("/") else "/" + space_name + return dict(data=dict(), metadata=dict(id=id)) - @web.authenticated - def put(self, space_name=''): - """Update workspace data""" - if not space_name: - raise web.HTTPError(400, 'Workspace name is required for PUT.') + def save(self, space_name, raw) -> Path: + """Save the ``raw`` data as workspace ``space_name``.""" + if not self.workspaces_dir.exists(): + self.workspaces_dir.mkdir(parents=True) - directory = self.ensure_directory() - - if not os.path.exists(directory): - try: - os.makedirs(directory) - except Exception as e: - raise web.HTTPError(500, str(e)) - - raw = self.request.body.strip().decode('utf-8') workspace = dict() # Make sure the data is valid JSON. @@ -175,24 +145,83 @@ def put(self, space_name=''): decoder = json.JSONDecoder() workspace = decoder.decode(raw) except Exception as e: - raise web.HTTPError(400, str(e)) + raise ValueError(str(e)) from e # Make sure metadata ID matches the workspace name. - # Transparently support an optional inital root `/`. - metadata_id = workspace['metadata']['id'] - metadata_id = (metadata_id if metadata_id.startswith('/') - else '/' + metadata_id) + # Transparently support an optional initial root `/`. + metadata_id = workspace["metadata"]["id"] + metadata_id = metadata_id if metadata_id.startswith("/") else "/" + metadata_id metadata_id = urllib.parse.unquote(metadata_id) - if metadata_id != '/' + space_name: - message = ('Workspace metadata ID mismatch: expected %r got %r' - % (space_name, metadata_id)) - raise web.HTTPError(400, message) + if metadata_id != "/" + space_name: + message = "Workspace metadata ID mismatch: expected %r got %r" % ( + space_name, + metadata_id, + ) + raise ValueError(message) slug = slugify(space_name) - workspace_path = os.path.join(directory, slug + WORKSPACE_EXTENSION) + workspace_path = self.workspaces_dir / (slug + WORKSPACE_EXTENSION) # Write the workspace data to a file. - with open(workspace_path, 'w', encoding='utf-8') as fid: - fid.write(raw) + workspace_path.write_text(raw, encoding="utf-8") + + return workspace_path + + +class WorkspacesHandler(ExtensionHandlerMixin, ExtensionHandlerJinjaMixin, APIHandler): + def initialize(self, name, manager: WorkspacesManager, **kwargs): + super().initialize(name) + self.manager = manager + + @web.authenticated + def delete(self, space_name): + """Remove a workspace""" + if not space_name: + raise web.HTTPError(400, "Workspace name is required for DELETE") + + try: + self.manager.delete(space_name) + return self.set_status(204) + except FileNotFoundError as e: + raise web.HTTPError(404, str(e)) from e + except Exception as e: + raise web.HTTPError(500, str(e)) from e + + @web.authenticated + def get(self, space_name=""): + """Get workspace(s) data""" + + try: + if not space_name: + workspaces = self.manager.list_workspaces() + ids = [] + values = [] + for workspace in workspaces: + ids.append(workspace["metadata"]["id"]) + values.append(workspace) + return self.finish( + json.dumps({"workspaces": {"ids": ids, "values": values}}) + ) + + workspace = self.manager.load(space_name) + return self.finish(json.dumps(workspace)) + except Exception as e: + raise web.HTTPError(500, str(e)) from e + + @web.authenticated + def put(self, space_name=""): + """Update workspace data""" + if not space_name: + raise web.HTTPError(400, "Workspace name is required for PUT.") + + raw = self.request.body.strip().decode("utf-8") + + # Make sure the data is valid JSON. + try: + self.manager.save(space_name, raw) + except ValueError as e: + raise web.HTTPError(400, str(e)) from e + except Exception as e: + raise web.HTTPError(500, str(e)) from e self.set_status(204)