You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			510 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Python
		
	
			
		
		
	
	
			510 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Python
		
	
"""Frontend config storage helpers."""
 | 
						|
 | 
						|
# Copyright (c) Jupyter Development Team.
 | 
						|
# Distributed under the terms of the Modified BSD License.
 | 
						|
from __future__ import annotations
 | 
						|
 | 
						|
import json
 | 
						|
import os
 | 
						|
from glob import glob
 | 
						|
from typing import Any
 | 
						|
 | 
						|
import json5
 | 
						|
from jsonschema import Draft7Validator as Validator
 | 
						|
from jsonschema import ValidationError
 | 
						|
from jupyter_server import _tz as tz
 | 
						|
from jupyter_server.base.handlers import APIHandler
 | 
						|
from jupyter_server.services.config.manager import ConfigManager, recursive_update
 | 
						|
from tornado import web
 | 
						|
 | 
						|
from .translation_utils import (
 | 
						|
    DEFAULT_LOCALE,
 | 
						|
    L10N_SCHEMA_NAME,
 | 
						|
    PSEUDO_LANGUAGE,
 | 
						|
    SYS_LOCALE,
 | 
						|
    is_valid_locale,
 | 
						|
)
 | 
						|
 | 
						|
# The JupyterLab settings file extension.
 | 
						|
SETTINGS_EXTENSION = ".jupyterlab-settings"
 | 
						|
 | 
						|
 | 
						|
def _get_schema(
 | 
						|
    schemas_dir: str,
 | 
						|
    schema_name: str,
 | 
						|
    overrides: dict[str, Any],
 | 
						|
    labextensions_path: list[str] | None,
 | 
						|
) -> tuple[dict[str, Any], str]:
 | 
						|
    """Returns a dict containing a parsed and validated JSON schema."""
 | 
						|
    notfound_error = "Schema not found: %s"
 | 
						|
    parse_error = "Failed parsing schema (%s): %s"
 | 
						|
    validation_error = "Failed validating schema (%s): %s"
 | 
						|
 | 
						|
    path = None
 | 
						|
 | 
						|
    # Look for the setting in all of the labextension paths first
 | 
						|
    # Use the first one
 | 
						|
    if labextensions_path is not None:
 | 
						|
        ext_name, _, plugin_name = schema_name.partition(":")
 | 
						|
        for ext_path in labextensions_path:
 | 
						|
            target = os.path.join(ext_path, ext_name, "schemas", ext_name, plugin_name + ".json")
 | 
						|
            if os.path.exists(target):
 | 
						|
                schemas_dir = os.path.join(ext_path, ext_name, "schemas")
 | 
						|
                path = target
 | 
						|
                break
 | 
						|
 | 
						|
    # Fall back on the default location
 | 
						|
    if path is None:
 | 
						|
        path = _path(schemas_dir, schema_name)
 | 
						|
 | 
						|
    if not os.path.exists(path):
 | 
						|
        raise web.HTTPError(404, notfound_error % path)
 | 
						|
 | 
						|
    with open(path, encoding="utf-8") as fid:
 | 
						|
        # Attempt to load the schema file.
 | 
						|
        try:
 | 
						|
            schema = json.load(fid)
 | 
						|
        except Exception as e:
 | 
						|
            name = schema_name
 | 
						|
            raise web.HTTPError(500, parse_error % (name, str(e))) from None
 | 
						|
 | 
						|
    schema = _override(schema_name, schema, overrides)
 | 
						|
 | 
						|
    # Validate the schema.
 | 
						|
    try:
 | 
						|
        Validator.check_schema(schema)
 | 
						|
    except Exception as e:
 | 
						|
        name = schema_name
 | 
						|
        raise web.HTTPError(500, validation_error % (name, str(e))) from None
 | 
						|
 | 
						|
    version = _get_version(schemas_dir, schema_name)
 | 
						|
 | 
						|
    return schema, version
 | 
						|
 | 
						|
 | 
						|
def _get_user_settings(settings_dir: str, schema_name: str, schema: Any) -> dict[str, Any]:
 | 
						|
    """
 | 
						|
    Returns a dictionary containing the raw user settings, the parsed user
 | 
						|
    settings, a validation warning for a schema, and file times.
 | 
						|
    """
 | 
						|
    path = _path(settings_dir, schema_name, False, SETTINGS_EXTENSION)
 | 
						|
    raw = "{}"
 | 
						|
    settings = {}
 | 
						|
    warning = None
 | 
						|
    validation_warning = "Failed validating settings (%s): %s"
 | 
						|
    parse_error = "Failed loading settings (%s): %s"
 | 
						|
    last_modified = None
 | 
						|
    created = None
 | 
						|
 | 
						|
    if os.path.exists(path):
 | 
						|
        stat = os.stat(path)
 | 
						|
        last_modified = tz.utcfromtimestamp(stat.st_mtime).isoformat()
 | 
						|
        created = tz.utcfromtimestamp(stat.st_ctime).isoformat()
 | 
						|
        with open(path, encoding="utf-8") as fid:
 | 
						|
            try:  # to load and parse the settings file.
 | 
						|
                raw = fid.read() or raw
 | 
						|
                settings = json5.loads(raw)
 | 
						|
            except Exception as e:
 | 
						|
                raise web.HTTPError(500, parse_error % (schema_name, str(e))) from None
 | 
						|
 | 
						|
    # Validate the parsed data against the schema.
 | 
						|
    if len(settings):
 | 
						|
        validator = Validator(schema)
 | 
						|
        try:
 | 
						|
            validator.validate(settings)
 | 
						|
        except ValidationError as e:
 | 
						|
            warning = validation_warning % (schema_name, str(e))
 | 
						|
            raw = "{}"
 | 
						|
            settings = {}
 | 
						|
 | 
						|
    return dict(
 | 
						|
        raw=raw, settings=settings, warning=warning, last_modified=last_modified, created=created
 | 
						|
    )
 | 
						|
 | 
						|
 | 
						|
def _get_version(schemas_dir: str, schema_name: str) -> str:
 | 
						|
    """Returns the package version for a given schema or 'N/A' if not found."""
 | 
						|
 | 
						|
    path = _path(schemas_dir, schema_name)
 | 
						|
    package_path = os.path.join(os.path.split(path)[0], "package.json.orig")
 | 
						|
 | 
						|
    try:  # to load and parse the package.json.orig file.
 | 
						|
        with open(package_path, encoding="utf-8") as fid:
 | 
						|
            package = json.load(fid)
 | 
						|
            return package["version"]
 | 
						|
    except Exception:
 | 
						|
        return "N/A"
 | 
						|
 | 
						|
 | 
						|
def _list_settings(
 | 
						|
    schemas_dir: str,
 | 
						|
    settings_dir: str,
 | 
						|
    overrides: dict[str, Any],
 | 
						|
    extension: str = ".json",
 | 
						|
    labextensions_path: list[str] | None = None,
 | 
						|
    translator: Any = None,
 | 
						|
    ids_only: bool = False,
 | 
						|
) -> tuple[list[Any], list[Any]]:
 | 
						|
    """
 | 
						|
    Returns a tuple containing:
 | 
						|
     - the list of plugins, schemas, and their settings,
 | 
						|
       respecting any defaults that may have been overridden if `ids_only=False`,
 | 
						|
       otherwise a list of dict containing only the ids of plugins.
 | 
						|
     - the list of warnings that were generated when
 | 
						|
       validating the user overrides against the schemas.
 | 
						|
    """
 | 
						|
 | 
						|
    settings: dict[str, Any] = {}
 | 
						|
    federated_settings: dict[str, Any] = {}
 | 
						|
    warnings = []
 | 
						|
 | 
						|
    if not os.path.exists(schemas_dir):
 | 
						|
        warnings = ["Settings directory does not exist at %s" % schemas_dir]
 | 
						|
        return ([], warnings)
 | 
						|
 | 
						|
    schema_pattern = schemas_dir + "/**/*" + extension
 | 
						|
    schema_paths = [path for path in glob(schema_pattern, recursive=True)]  # noqa: C416
 | 
						|
    schema_paths.sort()
 | 
						|
 | 
						|
    for schema_path in schema_paths:
 | 
						|
        # Generate the schema_name used to request individual settings.
 | 
						|
        rel_path = os.path.relpath(schema_path, schemas_dir)
 | 
						|
        rel_schema_dir, schema_base = os.path.split(rel_path)
 | 
						|
        _id = schema_name = ":".join(
 | 
						|
            [rel_schema_dir, schema_base[: -len(extension)]]  # Remove file extension.
 | 
						|
        ).replace("\\", "/")  # Normalize slashes.
 | 
						|
 | 
						|
        if ids_only:
 | 
						|
            settings[_id] = dict(id=_id)
 | 
						|
        else:
 | 
						|
            schema, version = _get_schema(schemas_dir, schema_name, overrides, None)
 | 
						|
            if translator is not None:
 | 
						|
                schema = translator(schema)
 | 
						|
            user_settings = _get_user_settings(settings_dir, schema_name, schema)
 | 
						|
 | 
						|
            if user_settings["warning"]:
 | 
						|
                warnings.append(user_settings.pop("warning"))
 | 
						|
 | 
						|
            # Add the plugin to the list of settings.
 | 
						|
            settings[_id] = dict(id=_id, schema=schema, version=version, **user_settings)
 | 
						|
 | 
						|
    if labextensions_path is not None:
 | 
						|
        schema_paths = []
 | 
						|
        for ext_dir in labextensions_path:
 | 
						|
            schema_pattern = ext_dir + "/**/schemas/**/*" + extension
 | 
						|
            schema_paths.extend(path for path in glob(schema_pattern, recursive=True))
 | 
						|
 | 
						|
        schema_paths.sort()
 | 
						|
 | 
						|
        for schema_path_ in schema_paths:
 | 
						|
            schema_path = schema_path_.replace(os.sep, "/")
 | 
						|
 | 
						|
            base_dir, rel_path = schema_path.split("schemas/")
 | 
						|
 | 
						|
            # Generate the schema_name used to request individual settings.
 | 
						|
            rel_schema_dir, schema_base = os.path.split(rel_path)
 | 
						|
            _id = schema_name = ":".join(
 | 
						|
                [rel_schema_dir, schema_base[: -len(extension)]]  # Remove file extension.
 | 
						|
            ).replace("\\", "/")  # Normalize slashes.
 | 
						|
 | 
						|
            # bail if we've already handled the highest federated setting
 | 
						|
            if _id in federated_settings:
 | 
						|
                continue
 | 
						|
 | 
						|
            if ids_only:
 | 
						|
                federated_settings[_id] = dict(id=_id)
 | 
						|
            else:
 | 
						|
                schema, version = _get_schema(
 | 
						|
                    schemas_dir, schema_name, overrides, labextensions_path=labextensions_path
 | 
						|
                )
 | 
						|
                user_settings = _get_user_settings(settings_dir, schema_name, schema)
 | 
						|
 | 
						|
                if user_settings["warning"]:
 | 
						|
                    warnings.append(user_settings.pop("warning"))
 | 
						|
 | 
						|
                # Add the plugin to the list of settings.
 | 
						|
                federated_settings[_id] = dict(
 | 
						|
                    id=_id, schema=schema, version=version, **user_settings
 | 
						|
                )
 | 
						|
 | 
						|
    settings.update(federated_settings)
 | 
						|
    settings_list = [settings[key] for key in sorted(settings.keys(), reverse=True)]
 | 
						|
 | 
						|
    return (settings_list, warnings)
 | 
						|
 | 
						|
 | 
						|
def _override(
 | 
						|
    schema_name: str, schema: dict[str, Any], overrides: dict[str, Any]
 | 
						|
) -> dict[str, Any]:
 | 
						|
    """Override default values in the schema if necessary."""
 | 
						|
    if schema_name in overrides:
 | 
						|
        defaults = overrides[schema_name]
 | 
						|
        for key in defaults:
 | 
						|
            if key in schema["properties"]:
 | 
						|
                new_defaults = schema["properties"][key]["default"]
 | 
						|
                # If values for defaults are dicts do a recursive update
 | 
						|
                if isinstance(new_defaults, dict):
 | 
						|
                    recursive_update(new_defaults, defaults[key])
 | 
						|
                else:
 | 
						|
                    new_defaults = defaults[key]
 | 
						|
 | 
						|
                schema["properties"][key]["default"] = new_defaults
 | 
						|
            else:
 | 
						|
                schema["properties"][key] = dict(default=defaults[key])
 | 
						|
 | 
						|
    return schema
 | 
						|
 | 
						|
 | 
						|
def _path(
 | 
						|
    root_dir: str, schema_name: str, make_dirs: bool = False, extension: str = ".json"
 | 
						|
) -> str:
 | 
						|
    """
 | 
						|
    Returns the local file system path for a schema name in the given root
 | 
						|
    directory. This function can be used to filed user overrides in addition to
 | 
						|
    schema files. If the `make_dirs` flag is set to `True` it will create the
 | 
						|
    parent directory for the calculated path if it does not exist.
 | 
						|
    """
 | 
						|
 | 
						|
    notfound_error = "Settings not found (%s)"
 | 
						|
    write_error = "Failed writing settings (%s): %s"
 | 
						|
 | 
						|
    try:  # to parse path, e.g. @jupyterlab/apputils-extension:themes.
 | 
						|
        package_dir, plugin = schema_name.split(":")
 | 
						|
        parent_dir = os.path.join(root_dir, package_dir)
 | 
						|
        path = os.path.join(parent_dir, plugin + extension)
 | 
						|
    except Exception:
 | 
						|
        raise web.HTTPError(404, notfound_error % schema_name) from None
 | 
						|
 | 
						|
    if make_dirs and not os.path.exists(parent_dir):
 | 
						|
        try:
 | 
						|
            os.makedirs(parent_dir)
 | 
						|
        except Exception as e:
 | 
						|
            raise web.HTTPError(500, write_error % (schema_name, str(e))) from None
 | 
						|
 | 
						|
    return path
 | 
						|
 | 
						|
 | 
						|
def _get_overrides(app_settings_dir: str) -> tuple[dict[str, Any], str]:
 | 
						|
    """Get overrides settings from `app_settings_dir`.
 | 
						|
 | 
						|
    The ordering of paths is:
 | 
						|
    - {app_settings_dir}/overrides.d/*.{json,json5} (many, namespaced by package)
 | 
						|
    - {app_settings_dir}/overrides.{json,json5} (singleton, owned by the user)
 | 
						|
    """
 | 
						|
    overrides: dict[str, Any]
 | 
						|
    error: str
 | 
						|
    overrides, error = {}, ""
 | 
						|
 | 
						|
    overrides_d = os.path.join(app_settings_dir, "overrides.d")
 | 
						|
 | 
						|
    # find (and sort) the conf.d overrides files
 | 
						|
    all_override_paths = sorted(
 | 
						|
        [
 | 
						|
            *(glob(os.path.join(overrides_d, "*.json"))),
 | 
						|
            *(glob(os.path.join(overrides_d, "*.json5"))),
 | 
						|
        ]
 | 
						|
    )
 | 
						|
 | 
						|
    all_override_paths += [
 | 
						|
        os.path.join(app_settings_dir, "overrides.json"),
 | 
						|
        os.path.join(app_settings_dir, "overrides.json5"),
 | 
						|
    ]
 | 
						|
 | 
						|
    for overrides_path in all_override_paths:
 | 
						|
        if not os.path.exists(overrides_path):
 | 
						|
            continue
 | 
						|
 | 
						|
        with open(overrides_path, encoding="utf-8") as fid:
 | 
						|
            try:
 | 
						|
                if overrides_path.endswith(".json5"):
 | 
						|
                    path_overrides = json5.load(fid)
 | 
						|
                else:
 | 
						|
                    path_overrides = json.load(fid)
 | 
						|
                for plugin_id, config in path_overrides.items():
 | 
						|
                    recursive_update(overrides.setdefault(plugin_id, {}), config)
 | 
						|
            except Exception as e:
 | 
						|
                error = e  # type:ignore[assignment]
 | 
						|
 | 
						|
    # Allow `default_settings_overrides.json` files in <jupyter_config>/labconfig dirs
 | 
						|
    # to allow layering of defaults
 | 
						|
    cm = ConfigManager(config_dir_name="labconfig")
 | 
						|
 | 
						|
    for plugin_id, config in cm.get("default_setting_overrides").items():  # type:ignore[no-untyped-call]
 | 
						|
        recursive_update(overrides.setdefault(plugin_id, {}), config)
 | 
						|
 | 
						|
    return overrides, error
 | 
						|
 | 
						|
 | 
						|
def get_settings(
 | 
						|
    app_settings_dir: str,
 | 
						|
    schemas_dir: str,
 | 
						|
    settings_dir: str,
 | 
						|
    schema_name: str = "",
 | 
						|
    overrides: dict[str, Any] | None = None,
 | 
						|
    labextensions_path: list[str] | None = None,
 | 
						|
    translator: Any = None,
 | 
						|
    ids_only: bool = False,
 | 
						|
) -> tuple[dict[str, Any], list[Any]]:
 | 
						|
    """
 | 
						|
    Get settings.
 | 
						|
 | 
						|
    Parameters
 | 
						|
    ----------
 | 
						|
    app_settings_dir:
 | 
						|
        Path to applications settings.
 | 
						|
    schemas_dir: str
 | 
						|
        Path to schemas.
 | 
						|
    settings_dir:
 | 
						|
        Path to settings.
 | 
						|
    schema_name str, optional
 | 
						|
        Schema name. Default is "".
 | 
						|
    overrides: dict, optional
 | 
						|
        Settings overrides. If not provided, the overrides will be loaded
 | 
						|
        from the `app_settings_dir`. Default is None.
 | 
						|
    labextensions_path: list, optional
 | 
						|
        List of paths to federated labextensions containing their own schema files.
 | 
						|
    translator: Callable[[Dict], Dict] or None, optional
 | 
						|
        Translate a schema. It requires the schema dictionary and returns its translation
 | 
						|
 | 
						|
    Returns
 | 
						|
    -------
 | 
						|
    tuple
 | 
						|
        The first item is a dictionary with a list of setting if no `schema_name`
 | 
						|
        was provided (only the ids if `ids_only=True`), otherwise it is a dictionary
 | 
						|
        with id, raw, scheme, settings and version keys.
 | 
						|
        The second item is a list of warnings. Warnings will either be a list of
 | 
						|
        i) strings with the warning messages or ii) `None`.
 | 
						|
    """
 | 
						|
    result = {}
 | 
						|
    warnings = []
 | 
						|
 | 
						|
    if overrides is None:
 | 
						|
        overrides, _error = _get_overrides(app_settings_dir)
 | 
						|
 | 
						|
    if schema_name:
 | 
						|
        schema, version = _get_schema(schemas_dir, schema_name, overrides, labextensions_path)
 | 
						|
        if translator is not None:
 | 
						|
            schema = translator(schema)
 | 
						|
        user_settings = _get_user_settings(settings_dir, schema_name, schema)
 | 
						|
        warnings = [user_settings.pop("warning")]
 | 
						|
        result = {"id": schema_name, "schema": schema, "version": version, **user_settings}
 | 
						|
    else:
 | 
						|
        settings_list, warnings = _list_settings(
 | 
						|
            schemas_dir,
 | 
						|
            settings_dir,
 | 
						|
            overrides,
 | 
						|
            labextensions_path=labextensions_path,
 | 
						|
            translator=translator,
 | 
						|
            ids_only=ids_only,
 | 
						|
        )
 | 
						|
        result = {
 | 
						|
            "settings": settings_list,
 | 
						|
        }
 | 
						|
 | 
						|
    return result, warnings
 | 
						|
 | 
						|
 | 
						|
def save_settings(
 | 
						|
    schemas_dir: str,
 | 
						|
    settings_dir: str,
 | 
						|
    schema_name: str,
 | 
						|
    raw_settings: str,
 | 
						|
    overrides: dict[str, Any],
 | 
						|
    labextensions_path: list[str] | None = None,
 | 
						|
) -> None:
 | 
						|
    """
 | 
						|
    Save ``raw_settings`` settings for ``schema_name``.
 | 
						|
 | 
						|
    Parameters
 | 
						|
    ----------
 | 
						|
    schemas_dir: str
 | 
						|
        Path to schemas.
 | 
						|
    settings_dir: str
 | 
						|
        Path to settings.
 | 
						|
    schema_name str
 | 
						|
        Schema name.
 | 
						|
    raw_settings: str
 | 
						|
        Raw serialized settings dictionary
 | 
						|
    overrides: dict
 | 
						|
        Settings overrides.
 | 
						|
    labextensions_path: list, optional
 | 
						|
        List of paths to federated labextensions containing their own schema files.
 | 
						|
    """
 | 
						|
    payload = json5.loads(raw_settings)
 | 
						|
 | 
						|
    # Validate the data against the schema.
 | 
						|
    schema, _ = _get_schema(
 | 
						|
        schemas_dir, schema_name, overrides, labextensions_path=labextensions_path
 | 
						|
    )
 | 
						|
    validator = Validator(schema)
 | 
						|
    validator.validate(payload)
 | 
						|
 | 
						|
    # Write the raw data (comments included) to a file.
 | 
						|
    path = _path(settings_dir, schema_name, True, SETTINGS_EXTENSION)
 | 
						|
    with open(path, "w", encoding="utf-8") as fid:
 | 
						|
        fid.write(raw_settings)
 | 
						|
 | 
						|
 | 
						|
class SchemaHandler(APIHandler):
 | 
						|
    """Base handler for handler requiring access to settings."""
 | 
						|
 | 
						|
    def initialize(
 | 
						|
        self,
 | 
						|
        app_settings_dir: str,
 | 
						|
        schemas_dir: str,
 | 
						|
        settings_dir: str,
 | 
						|
        labextensions_path: list[str] | None,
 | 
						|
        overrides: dict[str, Any] | None = None,
 | 
						|
        **kwargs: Any,
 | 
						|
    ) -> None:
 | 
						|
        """Initialize the handler."""
 | 
						|
        super().initialize(**kwargs)
 | 
						|
        error = None
 | 
						|
        if not overrides:
 | 
						|
            overrides, error = _get_overrides(app_settings_dir)
 | 
						|
        self.overrides = overrides
 | 
						|
        self.app_settings_dir = app_settings_dir
 | 
						|
        self.schemas_dir = schemas_dir
 | 
						|
        self.settings_dir = settings_dir
 | 
						|
        self.labextensions_path = labextensions_path
 | 
						|
 | 
						|
        if error:
 | 
						|
            overrides_warning = "Failed loading overrides: %s"
 | 
						|
            self.log.warning(overrides_warning, error)
 | 
						|
 | 
						|
    def get_current_locale(self) -> str:
 | 
						|
        """
 | 
						|
        Get the current locale as specified in the translation-extension settings.
 | 
						|
 | 
						|
        Returns
 | 
						|
        -------
 | 
						|
        str
 | 
						|
            The current locale string.
 | 
						|
 | 
						|
        Notes
 | 
						|
        -----
 | 
						|
        If the locale setting is not available or not valid, it will default to jupyterlab_server.translation_utils.DEFAULT_LOCALE.
 | 
						|
        """
 | 
						|
        try:
 | 
						|
            settings, _ = get_settings(
 | 
						|
                self.app_settings_dir,
 | 
						|
                self.schemas_dir,
 | 
						|
                self.settings_dir,
 | 
						|
                schema_name=L10N_SCHEMA_NAME,
 | 
						|
                overrides=self.overrides,
 | 
						|
                labextensions_path=self.labextensions_path,
 | 
						|
            )
 | 
						|
        except web.HTTPError as e:
 | 
						|
            schema_warning = "Missing or misshapen translation settings schema:\n%s"
 | 
						|
            self.log.warning(schema_warning, e)
 | 
						|
 | 
						|
            settings = {}
 | 
						|
 | 
						|
        current_locale = settings.get("settings", {}).get("locale") or SYS_LOCALE
 | 
						|
        if current_locale == "default":
 | 
						|
            current_locale = SYS_LOCALE
 | 
						|
        if not is_valid_locale(current_locale) and current_locale != PSEUDO_LANGUAGE:
 | 
						|
            current_locale = DEFAULT_LOCALE
 | 
						|
 | 
						|
        return current_locale
 |