settings_utils.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. """Frontend config storage helpers."""
  2. # Copyright (c) Jupyter Development Team.
  3. # Distributed under the terms of the Modified BSD License.
  4. from __future__ import annotations
  5. import json
  6. import os
  7. from glob import glob
  8. from typing import Any
  9. import json5
  10. from jsonschema import Draft7Validator as Validator
  11. from jsonschema import ValidationError
  12. from jupyter_server import _tz as tz
  13. from jupyter_server.base.handlers import APIHandler
  14. from jupyter_server.services.config.manager import ConfigManager, recursive_update
  15. from tornado import web
  16. from .translation_utils import (
  17. DEFAULT_LOCALE,
  18. L10N_SCHEMA_NAME,
  19. PSEUDO_LANGUAGE,
  20. SYS_LOCALE,
  21. is_valid_locale,
  22. )
  23. # The JupyterLab settings file extension.
  24. SETTINGS_EXTENSION = ".jupyterlab-settings"
  25. def _get_schema(
  26. schemas_dir: str,
  27. schema_name: str,
  28. overrides: dict[str, Any],
  29. labextensions_path: list[str] | None,
  30. ) -> tuple[dict[str, Any], str]:
  31. """Returns a dict containing a parsed and validated JSON schema."""
  32. notfound_error = "Schema not found: %s"
  33. parse_error = "Failed parsing schema (%s): %s"
  34. validation_error = "Failed validating schema (%s): %s"
  35. path = None
  36. # Look for the setting in all of the labextension paths first
  37. # Use the first one
  38. if labextensions_path is not None:
  39. ext_name, _, plugin_name = schema_name.partition(":")
  40. for ext_path in labextensions_path:
  41. target = os.path.join(ext_path, ext_name, "schemas", ext_name, plugin_name + ".json")
  42. if os.path.exists(target):
  43. schemas_dir = os.path.join(ext_path, ext_name, "schemas")
  44. path = target
  45. break
  46. # Fall back on the default location
  47. if path is None:
  48. path = _path(schemas_dir, schema_name)
  49. if not os.path.exists(path):
  50. raise web.HTTPError(404, notfound_error % path)
  51. with open(path, encoding="utf-8") as fid:
  52. # Attempt to load the schema file.
  53. try:
  54. schema = json.load(fid)
  55. except Exception as e:
  56. name = schema_name
  57. raise web.HTTPError(500, parse_error % (name, str(e))) from None
  58. schema = _override(schema_name, schema, overrides)
  59. # Validate the schema.
  60. try:
  61. Validator.check_schema(schema)
  62. except Exception as e:
  63. name = schema_name
  64. raise web.HTTPError(500, validation_error % (name, str(e))) from None
  65. version = _get_version(schemas_dir, schema_name)
  66. return schema, version
  67. def _get_user_settings(settings_dir: str, schema_name: str, schema: Any) -> dict[str, Any]:
  68. """
  69. Returns a dictionary containing the raw user settings, the parsed user
  70. settings, a validation warning for a schema, and file times.
  71. """
  72. path = _path(settings_dir, schema_name, False, SETTINGS_EXTENSION)
  73. raw = "{}"
  74. settings = {}
  75. warning = None
  76. validation_warning = "Failed validating settings (%s): %s"
  77. parse_error = "Failed loading settings (%s): %s"
  78. last_modified = None
  79. created = None
  80. if os.path.exists(path):
  81. stat = os.stat(path)
  82. last_modified = tz.utcfromtimestamp(stat.st_mtime).isoformat()
  83. created = tz.utcfromtimestamp(stat.st_ctime).isoformat()
  84. with open(path, encoding="utf-8") as fid:
  85. try: # to load and parse the settings file.
  86. raw = fid.read() or raw
  87. settings = json5.loads(raw)
  88. except Exception as e:
  89. raise web.HTTPError(500, parse_error % (schema_name, str(e))) from None
  90. # Validate the parsed data against the schema.
  91. if len(settings):
  92. validator = Validator(schema)
  93. try:
  94. validator.validate(settings)
  95. except ValidationError as e:
  96. warning = validation_warning % (schema_name, str(e))
  97. raw = "{}"
  98. settings = {}
  99. return dict(
  100. raw=raw, settings=settings, warning=warning, last_modified=last_modified, created=created
  101. )
  102. def _get_version(schemas_dir: str, schema_name: str) -> str:
  103. """Returns the package version for a given schema or 'N/A' if not found."""
  104. path = _path(schemas_dir, schema_name)
  105. package_path = os.path.join(os.path.split(path)[0], "package.json.orig")
  106. try: # to load and parse the package.json.orig file.
  107. with open(package_path, encoding="utf-8") as fid:
  108. package = json.load(fid)
  109. return package["version"]
  110. except Exception:
  111. return "N/A"
  112. def _list_settings(
  113. schemas_dir: str,
  114. settings_dir: str,
  115. overrides: dict[str, Any],
  116. extension: str = ".json",
  117. labextensions_path: list[str] | None = None,
  118. translator: Any = None,
  119. ids_only: bool = False,
  120. ) -> tuple[list[Any], list[Any]]:
  121. """
  122. Returns a tuple containing:
  123. - the list of plugins, schemas, and their settings,
  124. respecting any defaults that may have been overridden if `ids_only=False`,
  125. otherwise a list of dict containing only the ids of plugins.
  126. - the list of warnings that were generated when
  127. validating the user overrides against the schemas.
  128. """
  129. settings: dict[str, Any] = {}
  130. federated_settings: dict[str, Any] = {}
  131. warnings = []
  132. if not os.path.exists(schemas_dir):
  133. warnings = ["Settings directory does not exist at %s" % schemas_dir]
  134. return ([], warnings)
  135. schema_pattern = schemas_dir + "/**/*" + extension
  136. schema_paths = [path for path in glob(schema_pattern, recursive=True)] # noqa: C416
  137. schema_paths.sort()
  138. for schema_path in schema_paths:
  139. # Generate the schema_name used to request individual settings.
  140. rel_path = os.path.relpath(schema_path, schemas_dir)
  141. rel_schema_dir, schema_base = os.path.split(rel_path)
  142. _id = schema_name = ":".join(
  143. [rel_schema_dir, schema_base[: -len(extension)]] # Remove file extension.
  144. ).replace("\\", "/") # Normalize slashes.
  145. if ids_only:
  146. settings[_id] = dict(id=_id)
  147. else:
  148. schema, version = _get_schema(schemas_dir, schema_name, overrides, None)
  149. if translator is not None:
  150. schema = translator(schema)
  151. user_settings = _get_user_settings(settings_dir, schema_name, schema)
  152. if user_settings["warning"]:
  153. warnings.append(user_settings.pop("warning"))
  154. # Add the plugin to the list of settings.
  155. settings[_id] = dict(id=_id, schema=schema, version=version, **user_settings)
  156. if labextensions_path is not None:
  157. schema_paths = []
  158. for ext_dir in labextensions_path:
  159. schema_pattern = ext_dir + "/**/schemas/**/*" + extension
  160. schema_paths.extend(path for path in glob(schema_pattern, recursive=True))
  161. schema_paths.sort()
  162. for schema_path_ in schema_paths:
  163. schema_path = schema_path_.replace(os.sep, "/")
  164. base_dir, rel_path = schema_path.split("schemas/")
  165. # Generate the schema_name used to request individual settings.
  166. rel_schema_dir, schema_base = os.path.split(rel_path)
  167. _id = schema_name = ":".join(
  168. [rel_schema_dir, schema_base[: -len(extension)]] # Remove file extension.
  169. ).replace("\\", "/") # Normalize slashes.
  170. # bail if we've already handled the highest federated setting
  171. if _id in federated_settings:
  172. continue
  173. if ids_only:
  174. federated_settings[_id] = dict(id=_id)
  175. else:
  176. schema, version = _get_schema(
  177. schemas_dir, schema_name, overrides, labextensions_path=labextensions_path
  178. )
  179. user_settings = _get_user_settings(settings_dir, schema_name, schema)
  180. if user_settings["warning"]:
  181. warnings.append(user_settings.pop("warning"))
  182. # Add the plugin to the list of settings.
  183. federated_settings[_id] = dict(
  184. id=_id, schema=schema, version=version, **user_settings
  185. )
  186. settings.update(federated_settings)
  187. settings_list = [settings[key] for key in sorted(settings.keys(), reverse=True)]
  188. return (settings_list, warnings)
  189. def _override(
  190. schema_name: str, schema: dict[str, Any], overrides: dict[str, Any]
  191. ) -> dict[str, Any]:
  192. """Override default values in the schema if necessary."""
  193. if schema_name in overrides:
  194. defaults = overrides[schema_name]
  195. for key in defaults:
  196. if key in schema["properties"]:
  197. new_defaults = schema["properties"][key]["default"]
  198. # If values for defaults are dicts do a recursive update
  199. if isinstance(new_defaults, dict):
  200. recursive_update(new_defaults, defaults[key])
  201. else:
  202. new_defaults = defaults[key]
  203. schema["properties"][key]["default"] = new_defaults
  204. else:
  205. schema["properties"][key] = dict(default=defaults[key])
  206. return schema
  207. def _path(
  208. root_dir: str, schema_name: str, make_dirs: bool = False, extension: str = ".json"
  209. ) -> str:
  210. """
  211. Returns the local file system path for a schema name in the given root
  212. directory. This function can be used to filed user overrides in addition to
  213. schema files. If the `make_dirs` flag is set to `True` it will create the
  214. parent directory for the calculated path if it does not exist.
  215. """
  216. notfound_error = "Settings not found (%s)"
  217. write_error = "Failed writing settings (%s): %s"
  218. try: # to parse path, e.g. @jupyterlab/apputils-extension:themes.
  219. package_dir, plugin = schema_name.split(":")
  220. parent_dir = os.path.join(root_dir, package_dir)
  221. path = os.path.join(parent_dir, plugin + extension)
  222. except Exception:
  223. raise web.HTTPError(404, notfound_error % schema_name) from None
  224. if make_dirs and not os.path.exists(parent_dir):
  225. try:
  226. os.makedirs(parent_dir)
  227. except Exception as e:
  228. raise web.HTTPError(500, write_error % (schema_name, str(e))) from None
  229. return path
  230. def _get_overrides(app_settings_dir: str) -> tuple[dict[str, Any], str]:
  231. """Get overrides settings from `app_settings_dir`.
  232. The ordering of paths is:
  233. - {app_settings_dir}/overrides.d/*.{json,json5} (many, namespaced by package)
  234. - {app_settings_dir}/overrides.{json,json5} (singleton, owned by the user)
  235. """
  236. overrides: dict[str, Any]
  237. error: str
  238. overrides, error = {}, ""
  239. overrides_d = os.path.join(app_settings_dir, "overrides.d")
  240. # find (and sort) the conf.d overrides files
  241. all_override_paths = sorted(
  242. [
  243. *(glob(os.path.join(overrides_d, "*.json"))),
  244. *(glob(os.path.join(overrides_d, "*.json5"))),
  245. ]
  246. )
  247. all_override_paths += [
  248. os.path.join(app_settings_dir, "overrides.json"),
  249. os.path.join(app_settings_dir, "overrides.json5"),
  250. ]
  251. for overrides_path in all_override_paths:
  252. if not os.path.exists(overrides_path):
  253. continue
  254. with open(overrides_path, encoding="utf-8") as fid:
  255. try:
  256. if overrides_path.endswith(".json5"):
  257. path_overrides = json5.load(fid)
  258. else:
  259. path_overrides = json.load(fid)
  260. for plugin_id, config in path_overrides.items():
  261. recursive_update(overrides.setdefault(plugin_id, {}), config)
  262. except Exception as e:
  263. error = e # type:ignore[assignment]
  264. # Allow `default_settings_overrides.json` files in <jupyter_config>/labconfig dirs
  265. # to allow layering of defaults
  266. cm = ConfigManager(config_dir_name="labconfig")
  267. for plugin_id, config in cm.get("default_setting_overrides").items(): # type:ignore[no-untyped-call]
  268. recursive_update(overrides.setdefault(plugin_id, {}), config)
  269. return overrides, error
  270. def get_settings(
  271. app_settings_dir: str,
  272. schemas_dir: str,
  273. settings_dir: str,
  274. schema_name: str = "",
  275. overrides: dict[str, Any] | None = None,
  276. labextensions_path: list[str] | None = None,
  277. translator: Any = None,
  278. ids_only: bool = False,
  279. ) -> tuple[dict[str, Any], list[Any]]:
  280. """
  281. Get settings.
  282. Parameters
  283. ----------
  284. app_settings_dir:
  285. Path to applications settings.
  286. schemas_dir: str
  287. Path to schemas.
  288. settings_dir:
  289. Path to settings.
  290. schema_name str, optional
  291. Schema name. Default is "".
  292. overrides: dict, optional
  293. Settings overrides. If not provided, the overrides will be loaded
  294. from the `app_settings_dir`. Default is None.
  295. labextensions_path: list, optional
  296. List of paths to federated labextensions containing their own schema files.
  297. translator: Callable[[Dict], Dict] or None, optional
  298. Translate a schema. It requires the schema dictionary and returns its translation
  299. Returns
  300. -------
  301. tuple
  302. The first item is a dictionary with a list of setting if no `schema_name`
  303. was provided (only the ids if `ids_only=True`), otherwise it is a dictionary
  304. with id, raw, scheme, settings and version keys.
  305. The second item is a list of warnings. Warnings will either be a list of
  306. i) strings with the warning messages or ii) `None`.
  307. """
  308. result = {}
  309. warnings = []
  310. if overrides is None:
  311. overrides, _error = _get_overrides(app_settings_dir)
  312. if schema_name:
  313. schema, version = _get_schema(schemas_dir, schema_name, overrides, labextensions_path)
  314. if translator is not None:
  315. schema = translator(schema)
  316. user_settings = _get_user_settings(settings_dir, schema_name, schema)
  317. warnings = [user_settings.pop("warning")]
  318. result = {"id": schema_name, "schema": schema, "version": version, **user_settings}
  319. else:
  320. settings_list, warnings = _list_settings(
  321. schemas_dir,
  322. settings_dir,
  323. overrides,
  324. labextensions_path=labextensions_path,
  325. translator=translator,
  326. ids_only=ids_only,
  327. )
  328. result = {
  329. "settings": settings_list,
  330. }
  331. return result, warnings
  332. def save_settings(
  333. schemas_dir: str,
  334. settings_dir: str,
  335. schema_name: str,
  336. raw_settings: str,
  337. overrides: dict[str, Any],
  338. labextensions_path: list[str] | None = None,
  339. ) -> None:
  340. """
  341. Save ``raw_settings`` settings for ``schema_name``.
  342. Parameters
  343. ----------
  344. schemas_dir: str
  345. Path to schemas.
  346. settings_dir: str
  347. Path to settings.
  348. schema_name str
  349. Schema name.
  350. raw_settings: str
  351. Raw serialized settings dictionary
  352. overrides: dict
  353. Settings overrides.
  354. labextensions_path: list, optional
  355. List of paths to federated labextensions containing their own schema files.
  356. """
  357. payload = json5.loads(raw_settings)
  358. # Validate the data against the schema.
  359. schema, _ = _get_schema(
  360. schemas_dir, schema_name, overrides, labextensions_path=labextensions_path
  361. )
  362. validator = Validator(schema)
  363. validator.validate(payload)
  364. # Write the raw data (comments included) to a file.
  365. path = _path(settings_dir, schema_name, True, SETTINGS_EXTENSION)
  366. with open(path, "w", encoding="utf-8") as fid:
  367. fid.write(raw_settings)
  368. class SchemaHandler(APIHandler):
  369. """Base handler for handler requiring access to settings."""
  370. def initialize(
  371. self,
  372. app_settings_dir: str,
  373. schemas_dir: str,
  374. settings_dir: str,
  375. labextensions_path: list[str] | None,
  376. overrides: dict[str, Any] | None = None,
  377. **kwargs: Any,
  378. ) -> None:
  379. """Initialize the handler."""
  380. super().initialize(**kwargs)
  381. error = None
  382. if not overrides:
  383. overrides, error = _get_overrides(app_settings_dir)
  384. self.overrides = overrides
  385. self.app_settings_dir = app_settings_dir
  386. self.schemas_dir = schemas_dir
  387. self.settings_dir = settings_dir
  388. self.labextensions_path = labextensions_path
  389. if error:
  390. overrides_warning = "Failed loading overrides: %s"
  391. self.log.warning(overrides_warning, error)
  392. def get_current_locale(self) -> str:
  393. """
  394. Get the current locale as specified in the translation-extension settings.
  395. Returns
  396. -------
  397. str
  398. The current locale string.
  399. Notes
  400. -----
  401. If the locale setting is not available or not valid, it will default to jupyterlab_server.translation_utils.DEFAULT_LOCALE.
  402. """
  403. try:
  404. settings, _ = get_settings(
  405. self.app_settings_dir,
  406. self.schemas_dir,
  407. self.settings_dir,
  408. schema_name=L10N_SCHEMA_NAME,
  409. overrides=self.overrides,
  410. labextensions_path=self.labextensions_path,
  411. )
  412. except web.HTTPError as e:
  413. schema_warning = "Missing or misshapen translation settings schema:\n%s"
  414. self.log.warning(schema_warning, e)
  415. settings = {}
  416. current_locale = settings.get("settings", {}).get("locale") or SYS_LOCALE
  417. if current_locale == "default":
  418. current_locale = SYS_LOCALE
  419. if not is_valid_locale(current_locale) and current_locale != PSEUDO_LANGUAGE:
  420. current_locale = DEFAULT_LOCALE
  421. return current_locale