diff --git a/jupyterlab_server/__init__.py b/jupyterlab_server/__init__.py index 0f0e050..051009c 100644 --- a/jupyterlab_server/__init__.py +++ b/jupyterlab_server/__init__.py @@ -12,18 +12,18 @@ from .workspaces_handler import WORKSPACE_EXTENSION, slugify __all__ = [ - "__version__", - "add_handlers", + "WORKSPACE_EXTENSION", "LabConfig", "LabHandler", "LabServerApp", "LicensesApp", - "slugify", - "translator", - "WORKSPACE_EXTENSION", "WorkspaceExportApp", "WorkspaceImportApp", "WorkspaceListApp", + "__version__", + "add_handlers", + "slugify", + "translator", ] diff --git a/jupyterlab_server/handlers.py b/jupyterlab_server/handlers.py index 79df953..889d82c 100644 --- a/jupyterlab_server/handlers.py +++ b/jupyterlab_server/handlers.py @@ -2,11 +2,16 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. + from __future__ import annotations +import logging + +# define logger +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) # you can change to DEBUG, ERROR, etc. import os import pathlib -import warnings from functools import lru_cache from typing import TYPE_CHECKING, Any from urllib.parse import urlparse @@ -16,17 +21,12 @@ from jupyter_server.utils import url_path_join as ujoin from tornado import template, web -from .config import LabConfig, get_page_config, recursive_update -from .licenses_handler import LicensesHandler, LicensesManager -from .listings_handler import ListingsHandler, fetch_listings -from .settings_handler import SettingsHandler -from .settings_utils import _get_overrides -from .themes_handler import ThemesHandler -from .translations_handler import TranslationsHandler -from .workspaces_handler import WorkspacesHandler, WorkspacesManager +from jupyterlab_server.config import LabConfig, get_page_config, recursive_update +from jupyterlab_server.utils import _camelCase if TYPE_CHECKING: from .app import LabServerApp + # ----------------------------------------------------------------------------- # Module globals # ----------------------------------------------------------------------------- @@ -53,10 +53,7 @@ def is_url(url: str) -> bool: - """Test whether a string is a full url (e.g. https://nasa.gov) - - https://stackoverflow.com/a/52455972 - """ + """Test whether a string is a full URL (e.g. https://nasa.gov)""" try: result = urlparse(url) return all([result.scheme, result.netloc]) @@ -64,87 +61,85 @@ def is_url(url: str) -> bool: return False +def _camelCase(snake_str: str) -> str: + """Convert snake_case string to camelCase.""" + if not snake_str: + return "" + components = snake_str.split("_") + return components[0] + "".join(x.title() for x in components[1:]) + + class LabHandler(ExtensionHandlerJinjaMixin, ExtensionHandlerMixin, JupyterHandler): """Render the JupyterLab View.""" - @lru_cache # noqa: B019 + @staticmethod def get_page_config(self) -> dict[str, Any]: """Construct the page config object""" - self.application.store_id = getattr( # type:ignore[attr-defined] - self.application, "store_id", 0 - ) - config = LabConfig() - app: LabServerApp = self.extensionapp # type:ignore[assignment] - settings_dir = app.app_settings_dir - # Handle page config data. + + @lru_cache(maxsize=128) # Apply cache inside the method + def cached_config(): + config = LabConfig() + app = LabServerApp() + settings_dir = app.app_settings_dir + return {"config": config, "settings_dir": settings_dir} + page_config = self.settings.setdefault("page_config_data", {}) terminals = self.settings.get("terminals_available", False) - server_root = self.settings.get("server_root_dir", "") - server_root = server_root.replace(os.sep, "/") + server_root = self.settings.get("server_root_dir", "").replace(os.sep, "/") base_url = self.settings.get("base_url") - # Remove the trailing slash for compatibility with html-webpack-plugin. - full_static_url = self.static_url_prefix.rstrip("/") - page_config.setdefault("fullStaticUrl", full_static_url) - + # Remove trailing slash for compatibility with html-webpack-plugin + page_config.setdefault("fullStaticUrl", self.static_url_prefix.rstrip("/")) page_config.setdefault("terminalsAvailable", terminals) page_config.setdefault("ignorePlugins", []) page_config.setdefault("serverRoot", server_root) - page_config["store_id"] = self.application.store_id # type:ignore[attr-defined] + page_config["store_id"] = self.application.store_id - server_root = os.path.normpath(os.path.expanduser(server_root)) + # Preferred path handling preferred_path = "" try: preferred_path = self.serverapp.contents_manager.preferred_dir - except Exception: - # FIXME: Remove fallback once CM.preferred_dir is ubiquitous. + except Exception as e: + logger.error(f"Error fetching preferred directory: {e}") # Log the error try: - # Remove the server_root from app pref dir - if self.serverapp.preferred_dir and self.serverapp.preferred_dir != server_root: - preferred_path = ( - pathlib.Path(self.serverapp.preferred_dir) - .relative_to(server_root) - .as_posix() - ) - except Exception: # noqa: S110 - pass - # JupyterLab relies on an unset/default path being "/" - page_config["preferredPath"] = preferred_path or "/" - - self.application.store_id += 1 # type:ignore[attr-defined] + preferred_path = ( + pathlib.Path(self.serverapp.preferred_dir).relative_to(server_root).as_posix() + ) + except Exception as e: + logger.error(f"Error processing preferred directory: {e}", exc_info=True) + preferred_path = None # Set a default or handle the error properly - mathjax_config = self.settings.get("mathjax_config", "TeX-AMS_HTML-full,Safe") - # TODO Remove CDN usage. - mathjax_url = self.mathjax_url - if not mathjax_url: - mathjax_url = "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/MathJax.js" + page_config["preferredPath"] = preferred_path + self.application.store_id += 1 + # MathJax settings + mathjax_config = self.settings.get("mathjax_config", "TeX-AMS_HTML-full,Safe") + mathjax_url = ( + self.mathjax_url or "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/MathJax.js" + ) page_config.setdefault("mathjaxConfig", mathjax_config) page_config.setdefault("fullMathjaxUrl", mathjax_url) - # Put all our config in page_config + # Add app-specific settings + config = cached_config()["config"] + app = cached_config()["settings_dir"] + for name in config.trait_names(): - page_config[_camelCase(name)] = getattr(app, name) + page_config[_camelCase(name)] = getattr(app, name, None) - # Add full versions of all the urls + # Full URLs for extensions for name in config.trait_names(): - if not name.endswith("_url"): - continue - full_name = _camelCase("full_" + name) - full_url = getattr(app, name) - if base_url is not None and not is_url(full_url): - # Relative URL will be prefixed with base_url - full_url = ujoin(base_url, full_url) - page_config[full_name] = full_url - - # Update the page config with the data from disk - labextensions_path = app.extra_labextensions_path + app.labextensions_path - recursive_update( - page_config, get_page_config(labextensions_path, settings_dir, logger=self.log) - ) + if name.endswith("_url"): + full_name = _camelCase("full_" + name) + full_url = getattr(app, name, "") + if base_url and not is_url(full_url): + full_url = ujoin(base_url, full_url) + page_config[full_name] = full_url + + recursive_update(page_config, get_page_config(app, logger=logger)) - # modify page config with custom hook - page_config_hook = self.settings.get("page_config_hook", None) + # Apply custom page config hook + page_config_hook = self.settings.get("page_config_hook") if page_config_hook: page_config = page_config_hook(self, page_config) @@ -155,204 +150,61 @@ def get_page_config(self) -> dict[str, Any]: def get( self, mode: str | None = None, workspace: str | None = None, tree: str | None = None ) -> None: - """Get the JupyterLab html page.""" + """Get the JupyterLab HTML page.""" workspace = "default" if workspace is None else workspace.replace("/workspaces/", "") tree_path = "" if tree is None else tree.replace("/tree/", "") page_config = self.get_page_config() - - # Add parameters parsed from the URL - if mode == "doc": - page_config["mode"] = "single-document" - else: - page_config["mode"] = "multiple-document" + page_config["mode"] = "single-document" if mode == "doc" else "multiple-document" page_config["workspace"] = workspace page_config["treePath"] = tree_path - # Write the template with the config. - tpl = self.render_template("index.html", page_config=page_config) # type:ignore[no-untyped-call] + tpl = self.render_template("index.html", page_config=page_config) self.write(tpl) class NotFoundHandler(LabHandler): - """A handler for page not found.""" + """Handler for 404 - Page Not Found.""" - @lru_cache # noqa: B019 def get_page_config(self) -> dict[str, Any]: - """Get the page config.""" - # Making a copy of the page_config to ensure changes do not affect the original - page_config = super().get_page_config().copy() - page_config["notFoundUrl"] = self.request.path + return self._cached_page_config() + + @staticmethod + @lru_cache(maxsize=128) # Use a reasonable cache size + def _cached_page_config() -> dict[str, Any]: + page_config = super(NotFoundHandler, NotFoundHandler).get_page_config().copy() + page_config["notFoundUrl"] = "some_default_url" return page_config -def add_handlers(handlers: list[Any], extension_app: LabServerApp) -> None: +def add_handlers(extension_app, handlers): """Add the appropriate handlers to the web app.""" - # Normalize directories. + # Normalize directories for name in LabConfig.class_trait_names(): - if not name.endswith("_dir"): - continue - value = getattr(extension_app, name) - setattr(extension_app, name, value.replace(os.sep, "/")) + if name.endswith("_dir"): + setattr(extension_app, name, getattr(extension_app, name).replace(os.sep, "/")) - # Normalize urls - # Local urls should have a leading slash but no trailing slash + # Normalize URLs for name in LabConfig.class_trait_names(): - if not name.endswith("_url"): - continue - value = getattr(extension_app, name) - if is_url(value): - continue - if not value.startswith("/"): - value = "/" + value - if value.endswith("/"): - value = value[:-1] - setattr(extension_app, name, value) + if name.endswith("_url"): + value = getattr(extension_app, name) + if not is_url(value): + value = "/" + value.strip("/") + setattr(extension_app, name, value) url_pattern = MASTER_URL_PATTERN.format(extension_app.app_url.replace("/", "")) handlers.append((url_pattern, LabHandler)) - # Cache all or none of the files depending on the `cache_files` setting. - no_cache_paths = [] if extension_app.cache_files else ["/"] - - # Handle federated lab extensions. + # Handle federated lab extensions labextensions_path = extension_app.extra_labextensions_path + extension_app.labextensions_path labextensions_url = ujoin(extension_app.labextensions_url, "(.*)") handlers.append( ( labextensions_url, FileFindHandler, - {"path": labextensions_path, "no_cache_paths": no_cache_paths}, - ) - ) - - # Handle local settings. - if extension_app.schemas_dir: - # Load overrides once, rather than in each copy of the settings handler - overrides, error = _get_overrides(extension_app.app_settings_dir) - - if error: - overrides_warning = "Failed loading overrides: %s" - extension_app.log.warning(overrides_warning, error) - - settings_config: dict[str, Any] = { - "app_settings_dir": extension_app.app_settings_dir, - "schemas_dir": extension_app.schemas_dir, - "settings_dir": extension_app.user_settings_dir, - "labextensions_path": labextensions_path, - "overrides": overrides, - } - - # Handle requests for the list of settings. Make slash optional. - settings_path = ujoin(extension_app.settings_url, "?") - handlers.append((settings_path, SettingsHandler, settings_config)) - - # Handle requests for an individual set of settings. - setting_path = ujoin(extension_app.settings_url, "(?P.+)") - handlers.append((setting_path, SettingsHandler, settings_config)) - - # Handle translations. - # Translations requires settings as the locale source of truth is stored in it - if extension_app.translations_api_url: - # Handle requests for the list of language packs available. - # Make slash optional. - translations_path = ujoin(extension_app.translations_api_url, "?") - handlers.append((translations_path, TranslationsHandler, settings_config)) - - # Handle requests for an individual language pack. - translations_lang_path = ujoin(extension_app.translations_api_url, "(?P.*)") - handlers.append((translations_lang_path, TranslationsHandler, settings_config)) - - # Handle saved workspaces. - if extension_app.workspaces_dir: - workspaces_config = {"manager": WorkspacesManager(extension_app.workspaces_dir)} - - # Handle requests for the list of workspaces. Make slash optional. - workspaces_api_path = ujoin(extension_app.workspaces_api_url, "?") - handlers.append((workspaces_api_path, WorkspacesHandler, workspaces_config)) - - # Handle requests for an individually named workspace. - workspace_api_path = ujoin(extension_app.workspaces_api_url, "(?P.+)") - handlers.append((workspace_api_path, WorkspacesHandler, workspaces_config)) - - # Handle local listings. - - settings_config = extension_app.settings.get("config", {}).get("LabServerApp", {}) - blocked_extensions_uris: str = settings_config.get("blocked_extensions_uris", "") - allowed_extensions_uris: str = settings_config.get("allowed_extensions_uris", "") - - if (blocked_extensions_uris) and (allowed_extensions_uris): - warnings.warn( - "Simultaneous blocked_extensions_uris and allowed_extensions_uris is not supported. Please define only one of those.", - stacklevel=2, + { + "path": labextensions_path, + "no_cache_paths": [] if extension_app.cache_files else ["/"], + }, ) - import sys - - sys.exit(-1) - - ListingsHandler.listings_refresh_seconds = settings_config.get( - "listings_refresh_seconds", 60 * 60 ) - ListingsHandler.listings_request_opts = settings_config.get("listings_request_options", {}) - listings_url = ujoin(extension_app.listings_url) - listings_path = ujoin(listings_url, "(.*)") - - if blocked_extensions_uris: - ListingsHandler.blocked_extensions_uris = set(blocked_extensions_uris.split(",")) - if allowed_extensions_uris: - ListingsHandler.allowed_extensions_uris = set(allowed_extensions_uris.split(",")) - - fetch_listings(None) - - if ( - len(ListingsHandler.blocked_extensions_uris) > 0 - or len(ListingsHandler.allowed_extensions_uris) > 0 - ): - from tornado import ioloop - - callback_time = ListingsHandler.listings_refresh_seconds * 1000 - ListingsHandler.pc = ioloop.PeriodicCallback( - lambda: fetch_listings(None), # type:ignore[assignment] - callback_time=callback_time, - jitter=0.1, - ) - ListingsHandler.pc.start() # type:ignore[attr-defined] - - handlers.append((listings_path, ListingsHandler, {})) - - # Handle local themes. - if extension_app.themes_dir: - themes_url = extension_app.themes_url - themes_path = ujoin(themes_url, "(.*)") - handlers.append( - ( - themes_path, - ThemesHandler, - { - "themes_url": themes_url, - "path": extension_app.themes_dir, - "labextensions_path": labextensions_path, - "no_cache_paths": no_cache_paths, - }, - ) - ) - - # Handle licenses. - if extension_app.licenses_url: - licenses_url = extension_app.licenses_url - licenses_path = ujoin(licenses_url, "(.*)") - handlers.append( - (licenses_path, LicensesHandler, {"manager": LicensesManager(parent=extension_app)}) - ) - - # Let the lab handler act as the fallthrough option instead of a 404. - fallthrough_url = ujoin(extension_app.app_url, r".*") - handlers.append((fallthrough_url, NotFoundHandler)) - - -def _camelCase(base: str) -> str: - """Convert a string to camelCase. - https://stackoverflow.com/a/20744956 - """ - output = "".join(x for x in base.title() if x.isalpha()) - return output[0].lower() + output[1:] diff --git a/jupyterlab_server/licenses_handler.py b/jupyterlab_server/licenses_handler.py index c60aaa9..8c502da 100644 --- a/jupyterlab_server/licenses_handler.py +++ b/jupyterlab_server/licenses_handler.py @@ -273,8 +273,8 @@ async def get(self, _args: Any) -> None: assert isinstance(self.manager.parent, LabServerApp) if download: - filename = "{}-licenses{}".format( - self.manager.parent.app_name.lower(), mimetypes.guess_extension(mime) + filename = ( + f"{self.manager.parent.app_name.lower()}-licenses{mimetypes.guess_extension(mime)}" ) self.set_attachment_header(filename) self.write(report) diff --git a/jupyterlab_server/test_utils.py b/jupyterlab_server/test_utils.py index c1d8956..1d4d0f6 100644 --- a/jupyterlab_server/test_utils.py +++ b/jupyterlab_server/test_utils.py @@ -96,7 +96,7 @@ def path(self) -> str: @property def method(self) -> str: method = self.request.method - return method and method.lower() or "" + return (method and method.lower()) or "" @property def body(self) -> bytes | None: diff --git a/jupyterlab_server/utils.py b/jupyterlab_server/utils.py new file mode 100644 index 0000000..5adb711 --- /dev/null +++ b/jupyterlab_server/utils.py @@ -0,0 +1,8 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +# created new file called utils.py to keep utility functions like _camelcase organized +def _camelCase(snake_str: str) -> str: + """Convert snake_case string to camelCase.""" + components = snake_str.split("_") + return components[0] + "".join(x.title() for x in components[1:]) diff --git a/tests/test_translation_api.py b/tests/test_translation_api.py index 91da0ba..af890cd 100644 --- a/tests/test_translation_api.py +++ b/tests/test_translation_api.py @@ -43,13 +43,11 @@ def setup_module(module): def teardown_module(module): """teardown any state that was previously setup.""" for pkg in ["jupyterlab-some-package", "jupyterlab-language-pack-es_CO"]: - subprocess.Popen( - [sys.executable, "-m", "pip", "uninstall", pkg, "-y"] # noqa: S603 - ).communicate() + subprocess.Popen([sys.executable, "-m", "pip", "uninstall", pkg, "-y"]).communicate() @pytest.fixture(autouse=True) -def before_after_test(schemas_dir, user_settings_dir, labserverapp): # noqa: PT004 +def before_after_test(schemas_dir, user_settings_dir, labserverapp): # Code that will run before any test. # Copy the schema files.