| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509 |
- """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
|