diff --git a/jupyterlab_server/handlers.py b/jupyterlab_server/handlers.py index 9403b4c3..54add7c7 100755 --- a/jupyterlab_server/handlers.py +++ b/jupyterlab_server/handlers.py @@ -201,6 +201,19 @@ def add_handlers(handlers, extension_app): 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: @@ -279,18 +292,6 @@ def add_handlers(handlers, extension_app): } )) - # Handle translations. - 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, {'lab_config': extension_app})) - - # Handle requests for an individual language pack. - translations_lang_path = ujoin( - extension_app.translations_api_url, '(?P.*)') - handlers.append((translations_lang_path, TranslationsHandler, {'lab_config': 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)) diff --git a/jupyterlab_server/settings_handler.py b/jupyterlab_server/settings_handler.py index 198a9c24..a54aee3d 100755 --- a/jupyterlab_server/settings_handler.py +++ b/jupyterlab_server/settings_handler.py @@ -3,356 +3,40 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import json -import os -from glob import glob -import json5 -from jsonschema import Draft4Validator as Validator from jsonschema import ValidationError -from jupyter_server.extension.handler import ExtensionHandlerJinjaMixin, ExtensionHandlerMixin -from jupyter_server.services.config.manager import ConfigManager, recursive_update +from jupyter_server.extension.handler import ( + ExtensionHandlerJinjaMixin, + ExtensionHandlerMixin, +) from tornado import web -from .server import APIHandler, tz - -# The JupyterLab settings file extension. -SETTINGS_EXTENSION = '.jupyterlab-settings' - - -def _get_schema(schemas_dir, schema_name, overrides, labextensions_path): - """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))) - - 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))) - - version = _get_version(schemas_dir, schema_name) - - return schema, version - - -def _get_user_settings(settings_dir, schema_name, schema): - """ - 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))) - - # 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, schema_name): - """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, settings_dir, overrides, extension='.json', labextensions_path=None): - """ - Returns a tuple containing: - - the list of plugins, schemas, and their settings, - respecting any defaults that may have been overridden. - - the list of warnings that were generated when - validating the user overrides against the schemas. - """ - - settings = {} - federated_settings = {} - 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)] - 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. - schema, version = _get_schema(schemas_dir, schema_name, overrides, None) - 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 +from .settings_utils import SchemaHandler, get_settings, save_settings +from .translation_utils import DEFAULT_LOCALE, translator + + +class SettingsHandler(ExtensionHandlerMixin, ExtensionHandlerJinjaMixin, SchemaHandler): + def initialize( + self, + name, + app_settings_dir, + schemas_dir, + settings_dir, + labextensions_path, + **kwargs + ): + SchemaHandler.initialize( + self, app_settings_dir, schemas_dir, settings_dir, labextensions_path ) - - 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 - - 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, schema, overrides): - """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, schema_name, make_dirs=False, extension='.json'): - """ - 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. - """ - - parent_dir = root_dir - 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) - - 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))) - - return path - - -def _get_overrides(app_settings_dir): - """Get overrides settings from `app_settings_dir`.""" - overrides, error = {}, "" - overrides_path = os.path.join(app_settings_dir, 'overrides.json') - - if not os.path.exists(overrides_path): - overrides_path = os.path.join(app_settings_dir, 'overrides.json5') - - if os.path.exists(overrides_path): - with open(overrides_path, encoding='utf-8') as fid: - try: - overrides = json5.load(fid) - except Exception as e: - error = e - - # Allow `default_settings_overrides.json` files in /labconfig dirs - # to allow layering of defaults - cm = ConfigManager(config_dir_name="labconfig") - recursive_update(overrides, cm.get('default_setting_overrides')) - - return overrides, error - - -def get_settings(app_settings_dir, schemas_dir, settings_dir, schema_name="", overrides=None, labextensions_path=None): - """ - Get setttings. - - 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. - - Returns - ------- - tuple - The first item is a dictionary with a list of setting if no `schema_name` - was provided, 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) - 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) - result = { - "settings": settings_list, - } - - return result, warnings - - -class SettingsHandler(ExtensionHandlerMixin, ExtensionHandlerJinjaMixin, APIHandler): - - def initialize(self, name, app_settings_dir, schemas_dir, settings_dir, labextensions_path, **kwargs): - super().initialize(name) - self.overrides, error = _get_overrides(app_settings_dir) - 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.warn(overrides_warning % str(error)) + ExtensionHandlerMixin.initialize(self, name) @web.authenticated def get(self, schema_name=""): """Get setting(s)""" + # Need to be update here as translator locale is not change when a new locale is put + # from frontend + translator.set_locale(self.get_current_locale()) + result, warnings = get_settings( self.app_settings_dir, self.schemas_dir, @@ -360,6 +44,7 @@ def get(self, schema_name=""): labextensions_path=self.labextensions_path, schema_name=schema_name, overrides=self.overrides, + translator=translator.translate_schema ) # Print all warnings. @@ -385,24 +70,20 @@ def put(self, schema_name): raw_payload = self.request.body.strip().decode('utf-8') try: - raw_settings = json.loads(raw_payload)['raw'] - payload = json5.loads(raw_settings) + raw_settings = json.loads(raw_payload)["raw"] + save_settings( + schemas_dir, + settings_dir, + schema_name, + raw_settings, + overrides, + self.labextensions_path, + ) except json.decoder.JSONDecodeError as e: raise web.HTTPError(400, invalid_json_error % str(e)) except (KeyError, TypeError) as e: raise web.HTTPError(400, invalid_payload_format_error) - - # Validate the data against the schema. - schema, _ = _get_schema(schemas_dir, schema_name, overrides, labextensions_path=self.labextensions_path) - validator = Validator(schema) - try: - validator.validate(payload) except ValidationError as e: raise web.HTTPError(400, validation_error % str(e)) - # 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) - self.set_status(204) diff --git a/jupyterlab_server/settings_utils.py b/jupyterlab_server/settings_utils.py new file mode 100755 index 00000000..1dfd8daf --- /dev/null +++ b/jupyterlab_server/settings_utils.py @@ -0,0 +1,451 @@ +"""Frontend config storage helpers.""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. +import json +from jupyterlab_server.translation_utils import DEFAULT_LOCALE, L10N_SCHEMA_NAME, is_valid_locale +import os +from glob import glob + +import json5 +from jsonschema import Draft4Validator as Validator +from jsonschema import ValidationError +from jupyter_server.services.config.manager import ConfigManager, recursive_update +from tornado import web + +from .server import APIHandler, tz + +# The JupyterLab settings file extension. +SETTINGS_EXTENSION = '.jupyterlab-settings' + + +def _get_schema(schemas_dir, schema_name, overrides, labextensions_path): + """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))) + + 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))) + + version = _get_version(schemas_dir, schema_name) + + return schema, version + + +def _get_user_settings(settings_dir, schema_name, schema): + """ + 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))) + + # 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, schema_name): + """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, + settings_dir, + overrides, + extension=".json", + labextensions_path=None, + translator=None, +): + """ + Returns a tuple containing: + - the list of plugins, schemas, and their settings, + respecting any defaults that may have been overridden. + - the list of warnings that were generated when + validating the user overrides against the schemas. + """ + + settings = {} + federated_settings = {} + 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)] + 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. + 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 + + 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, schema, overrides): + """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, schema_name, make_dirs=False, extension='.json'): + """ + 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. + """ + + parent_dir = root_dir + 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) + + 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))) + + return path + + +def _get_overrides(app_settings_dir): + """Get overrides settings from `app_settings_dir`.""" + overrides, error = {}, "" + overrides_path = os.path.join(app_settings_dir, 'overrides.json') + + if not os.path.exists(overrides_path): + overrides_path = os.path.join(app_settings_dir, 'overrides.json5') + + if os.path.exists(overrides_path): + with open(overrides_path, encoding='utf-8') as fid: + try: + overrides = json5.load(fid) + except Exception as e: + error = e + + # Allow `default_settings_overrides.json` files in /labconfig dirs + # to allow layering of defaults + cm = ConfigManager(config_dir_name="labconfig") + recursive_update(overrides, cm.get('default_setting_overrides')) + + return overrides, error + + +def get_settings( + app_settings_dir, + schemas_dir, + settings_dir, + schema_name="", + overrides=None, + labextensions_path=None, + translator=None, +): + """ + 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, 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, + ) + result = { + "settings": settings_list, + } + + return result, warnings + + +def save_settings( + schemas_dir, + settings_dir, + schema_name, + raw_settings, + overrides, + labextensions_path=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, schemas_dir, settings_dir, labextensions_path, **kwargs + ): + super().initialize(**kwargs) + self.overrides, error = _get_overrides(app_settings_dir) + 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.warn(overrides_warning % str(error)) + + def get_current_locale(self): + """ + 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. + """ + 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, + ) + current_locale = settings.get("settings", {}).get("locale", DEFAULT_LOCALE) + if not is_valid_locale(current_locale): + current_locale = DEFAULT_LOCALE + + return current_locale diff --git a/jupyterlab_server/translation_utils.py b/jupyterlab_server/translation_utils.py index 486f8c91..c6be3ac1 100644 --- a/jupyterlab_server/translation_utils.py +++ b/jupyterlab_server/translation_utils.py @@ -7,9 +7,11 @@ import importlib import json import os -import subprocess +import re import sys import traceback +from functools import lru_cache +from typing import Dict, Pattern import babel import entrypoints @@ -23,6 +25,44 @@ DEFAULT_LOCALE = "en" LOCALE_DIR = "locale" LC_MESSAGES_DIR = "LC_MESSAGES" +DEFAULT_DOMAIN = "jupyterlab" +L10N_SCHEMA_NAME = "@jupyterlab/translation-extension:plugin" + +_default_schema_context = "schema" +_default_settings_context = "settings" +_lab_i18n_config = "jupyter.lab.internationalization" + +# mapping of schema translatable string selectors to translation context +DEFAULT_SCHEMA_SELECTORS = { + "properties/.*/title": _default_settings_context, + "properties/.*/description": _default_settings_context, + "definitions/.*/properties/.*/title": _default_settings_context, + "definitions/.*/properties/.*/description": _default_settings_context, + "title": _default_schema_context, + "description": _default_schema_context, + # JupyterLab-specific + "jupyter\.lab\.setting-icon-label": _default_settings_context, + "jupyter\.lab\.menus/.*/label": "menu", + "jupyter\.lab\.toolbars/.*/label": "toolbar", +} + + +@lru_cache() +def _get_default_schema_selectors() -> Dict[Pattern, str]: + return { + re.compile("^/" + pattern + "$"): context + for pattern, context in DEFAULT_SCHEMA_SELECTORS.items() + } + + +def _prepare_schema_patterns(schema: dict) -> Dict[Pattern, str]: + return { + **_get_default_schema_selectors(), + **{ + re.compile("^/" + selector + "$"): _default_schema_context + for selector in schema.get(_lab_i18n_config, {}).get("selectors", []) + }, + } # --- Private process helpers @@ -431,7 +471,7 @@ def ngettext(self, msgid: str, msgid_plural: str, n: int) -> str: """ return gettext.dngettext(self._domain, msgid, msgid_plural, n) - def pgettext(self, msgctxt: str, singular: str) -> str: + def pgettext(self, msgctxt: str, msgid: str) -> str: """ Translate a singular string with context. @@ -506,7 +546,7 @@ def _n(self, msgid: str, msgid_plural: str, n: int) -> str: str The translated string. """ - return self.ngettext(msgid, plural, n) + return self.ngettext(msgid, msgid_plural, n) def _p(self, msgctxt: str, msgid: str) -> str: """ @@ -526,7 +566,7 @@ def _p(self, msgctxt: str, msgid: str) -> str: """ return self.pgettext(msgctxt, msgid) - def _np(self, msgctxt: str, msgid: str, msgid_plular: str, n: str) -> str: + def _np(self, msgctxt: str, msgid: str, msgid_plural: str, n: str) -> str: """ Shorthand for npgettext. @@ -546,13 +586,14 @@ def _np(self, msgctxt: str, msgid: str, msgid_plular: str, n: str) -> str: str The translated string. """ - return self.npgettext(msgctxt, msgid, msgid_plular, n) + return self.npgettext(msgctxt, msgid, msgid_plural, n) class translator: """ Translations manager. """ + _TRANSLATORS = {} _LOCALE = DEFAULT_LOCALE @@ -569,6 +610,22 @@ def _update_env(locale: str): for key in ["LANGUAGE", "LANG"]: os.environ[key] = f"{locale}.UTF-8" + @staticmethod + def normalize_domain(domain: str) -> str: + """Normalize a domain name. + + Parameters + ---------- + domain: str + Domain to normalize + + Returns + ------- + str + Normalized domain + """ + return domain.replace("-", "_") + @classmethod def set_locale(cls, locale: str): """ @@ -579,6 +636,10 @@ def set_locale(cls, locale: str): locale: str The language name to use. """ + if locale == cls._LOCALE: + # Nothing to do bail early + return + if is_valid_locale(locale): cls._LOCALE = locale translator._update_env(locale) @@ -602,14 +663,81 @@ def load(cls, domain: str) -> TranslationBundle: Translator A translator instance bound to the domain. """ - if domain in cls._TRANSLATORS: - trans = cls._TRANSLATORS[domain] + norm_domain = translator.normalize_domain(domain) + if norm_domain in cls._TRANSLATORS: + trans = cls._TRANSLATORS[norm_domain] else: - trans = TranslationBundle(domain, cls._LOCALE) - cls._TRANSLATORS[domain] = trans + trans = TranslationBundle(norm_domain, cls._LOCALE) + cls._TRANSLATORS[norm_domain] = trans return trans + @staticmethod + def _translate_schema_strings( + translations, + schema: dict, + prefix: str = "", + to_translate: Dict[Pattern, str] = None, + ) -> None: + """Translate a schema in-place.""" + if to_translate is None: + to_translate = _prepare_schema_patterns(schema) + + for key, value in schema.items(): + path = prefix + "/" + key + + if isinstance(value, str): + matched = False + for pattern, context in to_translate.items(): + if pattern.fullmatch(path): + matched = True + break + if matched: + schema[key] = translations.pgettext(context, value) + elif isinstance(value, dict): + translator._translate_schema_strings( + translations, + value, + prefix=path, + to_translate=to_translate, + ) + elif isinstance(value, list): + for i, element in enumerate(value): + if not isinstance(element, dict): + continue + translator._translate_schema_strings( + translations, + element, + prefix=path + "[" + str(i) + "]", + to_translate=to_translate, + ) + + @staticmethod + def translate_schema(schema: Dict) -> Dict: + """Translate a schema. + + Parameters + ---------- + schema: dict + The schema to be translated + + Returns + ------- + Dict + The translated schema + """ + if translator._LOCALE == DEFAULT_LOCALE: + return schema + + translations = translator.load( + schema.get(_lab_i18n_config, {}).get("domain", DEFAULT_DOMAIN) + ) + + new_schema = schema.copy() + translator._translate_schema_strings(translations, schema.copy()) + + return new_schema + if __name__ == "__main__": _main() diff --git a/jupyterlab_server/translations_handler.py b/jupyterlab_server/translations_handler.py index 26503998..adc3bef9 100644 --- a/jupyterlab_server/translations_handler.py +++ b/jupyterlab_server/translations_handler.py @@ -11,49 +11,11 @@ import tornado from tornado import gen -from .server import APIHandler -from .settings_handler import get_settings +from .settings_utils import SchemaHandler from .translation_utils import get_language_pack, get_language_packs, is_valid_locale, translator -SCHEMA_NAME = '@jupyterlab/translation-extension:plugin' - - -def get_current_locale(config): - """ - Get the current locale for given `config`. - - Parameters - ---------- - config: LabConfig - The config. - - Returns - ------- - str - The current locale string. - - Notes - ----- - If the locale setting is not valid, it will default to "en". - """ - settings, _warnings = get_settings( - config.app_settings_dir, - config.schemas_dir, - config.user_settings_dir, - schema_name=SCHEMA_NAME, - ) - current_locale = settings.get("settings", {}).get("locale", "en") - if not is_valid_locale(current_locale): - current_locale = "en" - - return current_locale - - -class TranslationsHandler(APIHandler): - - def initialize(self, lab_config): - self.lab_config = lab_config +class TranslationsHandler(SchemaHandler): @gen.coroutine @tornado.web.authenticated @@ -71,7 +33,8 @@ def get(self, locale=""): try: if locale == "": data, message = get_language_packs( - display_locale=get_current_locale(self.lab_config)) + display_locale=self.get_current_locale() + ) else: data, message = get_language_pack(locale) if data == {} and message == "":