From 57b5ccd7d63e5b7067d37d0433111e9a5278fb6d Mon Sep 17 00:00:00 2001 From: Waylan Limberg Date: Tue, 1 Jun 2021 14:50:00 -0400 Subject: [PATCH] Add support for Configuration Inheritance * Support dict only markdown_extensions config * Support dict only plugin config * Remove explicit conversion to Unicode as PY2 is no longer supported. * Refactor yaml_load so that is can recursively call itself. Fixes #2218. --- docs/about/release-notes.md | 13 ++ docs/user-guide/configuration.md | 238 +++++++++++++++++--- mkdocs/config/base.py | 35 ++- mkdocs/config/config_options.py | 94 ++++---- mkdocs/tests/config/config_options_tests.py | 20 ++ mkdocs/tests/plugin_tests.py | 23 ++ mkdocs/tests/utils/utils_tests.py | 52 ++++- mkdocs/utils/__init__.py | 51 ++--- requirements/project-min.txt | 1 + requirements/project.txt | 1 + setup.py | 3 +- 11 files changed, 411 insertions(+), 120 deletions(-) diff --git a/docs/about/release-notes.md b/docs/about/release-notes.md index 3ffa14f0..9e4e65fa 100644 --- a/docs/about/release-notes.md +++ b/docs/about/release-notes.md @@ -67,6 +67,19 @@ otherkey: !ENV [VAR_NAME, FALLBACK_VAR, 'default value'] See [Environment Variables](../user-guide/configuration.md#environment-variables) in the Configuration documentation for details. +#### Support added for Configuration Inheritance (#2218) + +A configuration file may now inherit from a parent configuration file. In the +primary file set the `INHERIT` key to the relative path of the parent file. + +```yaml +INHERIT: path/to/base.yml +``` + +The two files will then be deep merged. See +[Configuration Inheritance](../user-guide/configuration.md#configuration-inheritance) +for details. + #### Update `gh-deploy` command (#2170) The vendored (and modified) copy of ghp_import has been replaced with a diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index 23b71df4..c70d78e9 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -12,43 +12,6 @@ project directory named `mkdocs.yml`. As a minimum, this configuration file must contain the `site_name` and `site_url` settings. All other settings are optional. -### Environment Variables - -In most cases, the value of a configuration option is set directly in the -configuration file. However, as an option, the value of a configuration option -may be set to the value of an environment variable using the `!ENV` tag. For -example, to set the value of the `site_name` option to the value of the -variable `SITE_NAME` the YAML file may contain the following: - -```yaml -site_name: !ENV SITE_NAME -``` - -If the environment variable is not defined, then the configuration setting -would be assigned a `null` (or `None` in Python) value. A default value can be -defined as the last value in a list. Like this: - -```yaml -site_name: !ENV [SITE_NAME, 'My default site name'] -``` - -Multiple fallback variables can be used as well. Note that the last value is -not an environment variable, but must be a value to use as a default if none -of the specified environment variables are defined. - -```yaml -site_name: !ENV [SITE_NAME, OTHER_NAME, 'My default site name'] -``` - -Simple types defined within an environment variable such as string, bool, -integer, float, datestamp and null are parsed as if they were defined directly -in the YAML file, which means that the value will be converted to the -appropriate type. However, complex types such as lists and key/value pairs -cannot be defined within a single environment variable. - -For more details, see the [pyyaml_env_tag](https://github.com/waylan/pyyaml-env-tag) -project. - ## Project information ### site_name @@ -497,6 +460,22 @@ markdown_extensions: - sane_lists ``` +In the above examples, each extension is a list item (starts with a `-`). As an +alternative, key/value pairs can be used instead. However, in that case an empty +value must be provided for extensions for which no options are defined. +Therefore, the last example above could also be defined as follows: + +```yaml +markdown_extensions: + smarty: {} + toc: + permalink: True + sane_lists: {} +``` + +This alternative syntax is required if you intend to override some options via +[inheritance]. + !!! note "See Also:" The Python-Markdown documentation provides a [list of extensions][exts] which are available out-of-the-box. For a list of configuration options @@ -523,6 +502,32 @@ plugins: - your_other_plugin ``` +To define options for a given plugin, use a nested set of key/value pairs: + +```yaml +plugins: + - search + - your_other_plugin: + option1: value + option2: other value +``` + +In the above examples, each plugin is a list item (starts with a `-`). As an +alternative, key/value pairs can be used instead. However, in that case an empty +value must be provided for plugins for which no options are defined. Therefore, +the last example above could also be defined as follows: + +```yaml +plugins: + search: {} + your_other_plugin: + option1: value + option2: other value +``` + +This alternative syntax is required if you intend to override some options via +[inheritance]. + To completely disable all plugins, including any defaults, set the `plugins` setting to an empty list: @@ -659,6 +664,161 @@ plugins: **default**: `full` +## Environment Variables + +In most cases, the value of a configuration option is set directly in the +configuration file. However, as an option, the value of a configuration option +may be set to the value of an environment variable using the `!ENV` tag. For +example, to set the value of the `site_name` option to the value of the +variable `SITE_NAME` the YAML file may contain the following: + +```yaml +site_name: !ENV SITE_NAME +``` + +If the environment variable is not defined, then the configuration setting +would be assigned a `null` (or `None` in Python) value. A default value can be +defined as the last value in a list. Like this: + +```yaml +site_name: !ENV [SITE_NAME, 'My default site name'] +``` + +Multiple fallback variables can be used as well. Note that the last value is +not an environment variable, but must be a value to use as a default if none +of the specified environment variables are defined. + +```yaml +site_name: !ENV [SITE_NAME, OTHER_NAME, 'My default site name'] +``` + +Simple types defined within an environment variable such as string, bool, +integer, float, datestamp and null are parsed as if they were defined directly +in the YAML file, which means that the value will be converted to the +appropriate type. However, complex types such as lists and key/value pairs +cannot be defined within a single environment variable. + +For more details, see the [pyyaml_env_tag](https://github.com/waylan/pyyaml-env-tag) +project. + +## Configuration Inheritance + +Generally, a single file would hold the entire configuration for a site. +However, some organizations may maintain multiple sites which all share a common +configuration across them. Rather than maintaining separate configurations for +each, the common configuration options can be defined in a parent configuration +while which each site's primary configuration file inherits. + +To define the parent for a configuration file, set the `INHERIT` (all caps) key +to the path of the parent file. The path must be relative to the location of the +primary file. + +For configuration options to be merged with a parent configuration, those +options must be defined as key/value pairs. Specifically, the +[markdown_extensions] and [plugins] options must use the alternative syntax +which does not use list items (lines which start with `-`). + +For example, suppose the common (parent) configuration is defined in `base.yml`: + +```yaml +theme: + name: mkdocs + locale: en + highlightjs: true + +markdown_extensions: + toc: + permalink: true + admonition: {} +``` + +Then, for the "foo" site, the primary configuration file would be defined at +`foo/mkdocs.yml`: + +```yml +INHERIT: ../base.yml +site_name: Foo Project +site_url: https://example.com/foo +``` + +When running `mkdocs build`, the file at `foo/mkdocs.yml` would be passed in as +the configuration file. MkDocs will then parse that file, retrieve and parse the +parent file `base.yml` and deep merge the two. This would result in MkDocs +receiving the following merged configuration: + +```yaml +site_name: Foo Project +site_url: https://example.com/foo + +theme: + name: mkdocs + locale: en + highlightjs: true + +markdown_extensions: + toc: + permalink: true + admonition: {} +``` + +Deep merging allows you to add and/or override various values in your primary +configuration file. For example, suppose for one site you wanted to add support +for definition lists, use a different symbol for permalinks, and define a +different separator. In that site's primary configuration file you could do: + +```yaml +INHERIT: ../base.yml +site_name: Bar Project +site_url: https://example.com/bar + +markdown_extensions: + def_list: {} + toc: + permalink:  + separator: "_" +``` + +In that case, the above configuration would be deep merged with `base.yml` and +result in the following configuration: + +```yaml +site_name: Bar Project +site_url: https://example.com/bar + +theme: + name: mkdocs + locale: en + highlightjs: true + +markdown_extensions: + def_list: {} + toc: + permalink:  + separator: "_" + admonition: {} +``` + +Notice that the `admonition` extension was retained from the parent +configuration, the `def_list` extension was added, the value of +`toc.permalink` was replaced, and the value of `toc.separator` was added. + +You can replace or merge the value of any key. However, any non-key is always +replaced. Therefore, you cannot append items to a list. You must redefine the +entire list. + +As the [nav] configuration is made up of nested lists, this means that you +cannot merge navigation items. Of course, you can replace the entire `nav` +configuration with a new one. However, it is generally expected that the entire +navigation would be defined in the primary configuration file for a project. + +!!! warning + + As a reminder, all path based configuration options must be relative to the + primary configuration file and MkDocs does not alter the paths when merging. + Therefore, defining paths in a parent file which is inherited by multiple + different sites may not work as expected. It is generally best to define + path based options in the primary configuration file only. + [Theme Developer Guide]: ../dev-guide/themes.md [variables that are available]: ../dev-guide/themes.md#template-variables [pymdk-extensions]: https://python-markdown.github.io/extensions/ @@ -679,3 +839,7 @@ plugins: [Node.js]: https://nodejs.org/ [Lunr.py]: http://lunr.readthedocs.io/ [Lunr.py's issues]: https://github.com/yeraydiazdiaz/lunr.py/issues +[markdown_extensions]: #markdown_extensions +[plugins]: #plugins +[nav]: #nav +[inheritance]: #configuration-inheritance diff --git a/mkdocs/config/base.py b/mkdocs/config/base.py index b13bf6c3..15e70dd0 100644 --- a/mkdocs/config/base.py +++ b/mkdocs/config/base.py @@ -3,6 +3,7 @@ import os import sys from yaml import YAMLError from collections import UserDict +from contextlib import contextmanager from mkdocs import exceptions from mkdocs import utils @@ -119,6 +120,7 @@ class Config(UserDict): return failed, warnings def load_dict(self, patch): + """ Load config options from a dictionary. """ if not isinstance(patch, dict): raise exceptions.ConfigurationError( @@ -130,6 +132,7 @@ class Config(UserDict): self.data.update(patch) def load_file(self, config_file): + """ Load config options from the open file descriptor of a YAML file. """ try: return self.load_dict(utils.yaml_load(config_file)) except YAMLError as e: @@ -139,7 +142,17 @@ class Config(UserDict): ) +@contextmanager def _open_config_file(config_file): + """ + A context manager which yields an open file descriptor ready to be read. + + Accepts a filename as a string, an open or closed file descriptor, or None. + When None, it defaults to `mkdocs.yml` in the CWD. If a closed file descriptor + is received, a new file descriptor is opened for the same file. + + The file descriptor is automaticaly closed when the context manager block is existed. + """ # Default to the standard config filename. if config_file is None: @@ -161,8 +174,11 @@ def _open_config_file(config_file): # Ensure file descriptor is at begining config_file.seek(0) - - return config_file + try: + yield config_file + finally: + if hasattr(config_file, 'close'): + config_file.close() def load_config(config_file=None, **kwargs): @@ -183,14 +199,15 @@ def load_config(config_file=None, **kwargs): if value is None: options.pop(key) - config_file = _open_config_file(config_file) - options['config_file_path'] = getattr(config_file, 'name', '') + with _open_config_file(config_file) as fd: + options['config_file_path'] = getattr(fd, 'name', '') + + # Initialise the config with the default schema. + from mkdocs.config.defaults import get_schema + cfg = Config(schema=get_schema(), config_file_path=options['config_file_path']) + # load the config file + cfg.load_file(fd) - # Initialise the config with the default schema . - from mkdocs.config.defaults import get_schema - cfg = Config(schema=get_schema(), config_file_path=options['config_file_path']) - # First load the config file - cfg.load_file(config_file) # Then load the options to overwrite anything in the config. cfg.load_dict(options) diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py index e46b3e97..41a07839 100644 --- a/mkdocs/config/config_options.py +++ b/mkdocs/config/config_options.py @@ -542,37 +542,47 @@ class MarkdownExtensions(OptionallyRequired): """ Markdown Extensions Config Option - A list of extensions. If a list item contains extension configs, - those are set on the private setting passed to `configkey`. The - `builtins` keyword accepts a list of extensions which cannot be - overriden by the user. However, builtins can be duplicated to define - config options for them if desired. - """ + A list or dict of extensions. Each list item may contain either a string or a one item dict. + A string must be a valid Markdown extension name with no config options defined. The key of + a dict item must be a valid Markdown extension name and the value must be a dict of config + options for that extension. Extension configs are set on the private setting passed to + `configkey`. The `builtins` keyword accepts a list of extensions which cannot be overriden by + the user. However, builtins can be duplicated to define config options for them if desired. """ def __init__(self, builtins=None, configkey='mdx_configs', **kwargs): super().__init__(**kwargs) self.builtins = builtins or [] self.configkey = configkey self.configdata = {} + def validate_ext_cfg(self, ext, cfg): + if not isinstance(ext, str): + raise ValidationError(f"'{ext}' is not a valid Markdown Extension name.") + if not cfg: + return + if not isinstance(cfg, dict): + raise ValidationError(f"Invalid config options for Markdown Extension '{ext}'.") + self.configdata[ext] = cfg + def run_validation(self, value): - if not isinstance(value, (list, tuple)): + if not isinstance(value, (list, tuple, dict)): raise ValidationError('Invalid Markdown Extensions configuration') extensions = [] - for item in value: - if isinstance(item, dict): - if len(item) > 1: - raise ValidationError('Invalid Markdown Extensions configuration') - ext, cfg = item.popitem() + if isinstance(value, dict): + for ext, cfg in value.items(): + self.validate_ext_cfg(ext, cfg) extensions.append(ext) - if cfg is None: - continue - if not isinstance(cfg, dict): - raise ValidationError(f"Invalid config options for Markdown Extension '{ext}'.") - self.configdata[ext] = cfg - elif isinstance(item, str): - extensions.append(item) - else: - raise ValidationError('Invalid Markdown Extensions configuration') + else: + for item in value: + if isinstance(item, dict): + if len(item) > 1: + raise ValidationError('Invalid Markdown Extensions configuration') + ext, cfg = item.popitem() + self.validate_ext_cfg(ext, cfg) + extensions.append(ext) + elif isinstance(item, str): + extensions.append(item) + else: + raise ValidationError('Invalid Markdown Extensions configuration') extensions = utils.reduce_list(self.builtins + extensions) @@ -592,7 +602,7 @@ class Plugins(OptionallyRequired): """ Plugins config option. - A list of plugins. If a plugin defines config options those are used when + A list or dict of plugins. If a plugin defines config options those are used when initializing the plugin class. """ @@ -605,32 +615,34 @@ class Plugins(OptionallyRequired): self.config_file_path = config.config_file_path def run_validation(self, value): - if not isinstance(value, (list, tuple)): - raise ValidationError('Invalid Plugins configuration. Expected a list of plugins') + if not isinstance(value, (list, tuple, dict)): + raise ValidationError('Invalid Plugins configuration. Expected a list or dict.') plgins = plugins.PluginCollection() - for item in value: - if isinstance(item, dict): - if len(item) > 1: - raise ValidationError('Invalid Plugins configuration') - name, cfg = item.popitem() - cfg = cfg or {} # Users may define a null (None) config - if not isinstance(cfg, dict): - raise ValidationError(f'Invalid config options for the "{name}" plugin.') - item = name - else: - cfg = {} - - if not isinstance(item, str): - raise ValidationError('Invalid Plugins configuration') - - plgins[item] = self.load_plugin(item, cfg) - + if isinstance(value, dict): + for name, cfg in value.items(): + plgins[name] = self.load_plugin(name, cfg) + else: + for item in value: + if isinstance(item, dict): + if len(item) > 1: + raise ValidationError('Invalid Plugins configuration') + name, cfg = item.popitem() + item = name + else: + cfg = {} + plgins[item] = self.load_plugin(item, cfg) return plgins def load_plugin(self, name, config): + if not isinstance(name, str): + raise ValidationError(f"'{name}' is not a valid plugin name.") if name not in self.installed_plugins: raise ValidationError(f'The "{name}" plugin is not installed') + config = config or {} # Users may define a null (None) config + if not isinstance(config, dict): + raise ValidationError(f"Invalid config options for the '{name}' plugin.") + Plugin = self.installed_plugins[name].load() if not issubclass(Plugin, plugins.BasePlugin): diff --git a/mkdocs/tests/config/config_options_tests.py b/mkdocs/tests/config/config_options_tests.py index 8095f9fb..6076a8a3 100644 --- a/mkdocs/tests/config/config_options_tests.py +++ b/mkdocs/tests/config/config_options_tests.py @@ -758,6 +758,26 @@ class MarkdownExtensionsTest(unittest.TestCase): } }, config) + @patch('markdown.Markdown') + def test_dict_of_dicts(self, mockMd): + option = config_options.MarkdownExtensions() + config = { + 'markdown_extensions': { + 'foo': {'foo_option': 'foo value'}, + 'bar': {'bar_option': 'bar value'}, + 'baz': {} + } + } + config['markdown_extensions'] = option.validate(config['markdown_extensions']) + option.post_validation(config, 'markdown_extensions') + self.assertEqual({ + 'markdown_extensions': ['foo', 'bar', 'baz'], + 'mdx_configs': { + 'foo': {'foo_option': 'foo value'}, + 'bar': {'bar_option': 'bar value'} + } + }, config) + @patch('markdown.Markdown') def test_builtins(self, mockMd): option = config_options.MarkdownExtensions(builtins=['meta', 'toc']) diff --git a/mkdocs/tests/plugin_tests.py b/mkdocs/tests/plugin_tests.py index 7d0a9e31..295a75d1 100644 --- a/mkdocs/tests/plugin_tests.py +++ b/mkdocs/tests/plugin_tests.py @@ -252,6 +252,29 @@ class TestPluginConfig(unittest.TestCase): } self.assertEqual(cfg['plugins']['sample'].config, expected) + def test_plugin_config_as_dict(self, mock_class): + + cfg = { + 'plugins': { + 'sample': { + 'foo': 'foo value', + 'bar': 42 + } + } + } + option = config.config_options.Plugins() + cfg['plugins'] = option.validate(cfg['plugins']) + + self.assertIsInstance(cfg['plugins'], plugins.PluginCollection) + self.assertIn('sample', cfg['plugins']) + self.assertIsInstance(cfg['plugins']['sample'], plugins.BasePlugin) + expected = { + 'foo': 'foo value', + 'bar': 42, + 'dir': None, + } + self.assertEqual(cfg['plugins']['sample'].config, expected) + def test_plugin_config_empty_list_with_empty_default(self, mock_class): cfg = {'plugins': []} option = config.config_options.Plugins(default=[]) diff --git a/mkdocs/tests/utils/utils_tests.py b/mkdocs/tests/utils/utils_tests.py index ef46d301..4054e746 100644 --- a/mkdocs/tests/utils/utils_tests.py +++ b/mkdocs/tests/utils/utils_tests.py @@ -13,7 +13,29 @@ import logging from mkdocs import utils, exceptions from mkdocs.structure.files import File from mkdocs.structure.pages import Page -from mkdocs.tests.base import dedent, load_config +from mkdocs.tests.base import dedent, load_config, tempdir + +BASEYML = """ +INHERIT: parent.yml +foo: bar +baz: + sub1: replaced + sub3: new +deep1: + deep2-1: + deep3-1: replaced +""" +PARENTYML = """ +foo: foo +baz: + sub1: 1 + sub2: 2 +deep1: + deep2-1: + deep3-1: foo + deep3-2: bar + deep2-2: baz +""" class UtilsTests(unittest.TestCase): @@ -355,7 +377,7 @@ class UtilsTests(unittest.TestCase): key2: - value ''' - ) + ).encode('utf-8') config = utils.yaml_load(yaml_src) self.assertTrue(isinstance(config['key'], str)) @@ -383,6 +405,32 @@ class UtilsTests(unittest.TestCase): self.assertEqual(config['key4'], 'Hello, World!') self.assertIs(config['key5'], False) + @tempdir(files={'base.yml': BASEYML, 'parent.yml': PARENTYML}) + def test_yaml_inheritance(self, tdir): + expected = { + 'foo': 'bar', + 'baz': { + 'sub1': 'replaced', + 'sub2': 2, + 'sub3': 'new' + }, + 'deep1': { + 'deep2-1': { + 'deep3-1': 'replaced', + 'deep3-2': 'bar' + }, + 'deep2-2': 'baz' + } + } + with open(os.path.join(tdir, 'base.yml')) as fd: + result = utils.yaml_load(fd) + self.assertEqual(result, expected) + + @tempdir(files={'base.yml': BASEYML}) + def test_yaml_inheritance_missing_parent(self, tdir): + with open(os.path.join(tdir, 'base.yml')) as fd: + self.assertRaises(exceptions.ConfigurationError, utils.yaml_load, fd) + def test_copy_files(self): src_paths = [ 'foo.txt', diff --git a/mkdocs/utils/__init__.py b/mkdocs/utils/__init__.py index d27f745d..9182174c 100644 --- a/mkdocs/utils/__init__.py +++ b/mkdocs/utils/__init__.py @@ -19,6 +19,7 @@ from collections import defaultdict from datetime import datetime, timezone from urllib.parse import urlparse from yaml_env_tag import construct_env_tag +from mergedeep import merge from mkdocs import exceptions @@ -33,20 +34,8 @@ markdown_extensions = [ ] -def yaml_load(source, loader=yaml.Loader): - """ - Wrap PyYaml's loader so we can extend it to suit our needs. - - Load all strings as unicode. - https://stackoverflow.com/a/2967461/3609487 - """ - - def construct_yaml_str(self, node): - """ - Override the default string handling function to always return - unicode objects. - """ - return self.construct_scalar(node) +def get_yaml_loader(loader=yaml.Loader): + """ Wrap PyYaml's loader so we can extend it to suit our needs. """ class Loader(loader): """ @@ -54,26 +43,28 @@ def yaml_load(source, loader=yaml.Loader): global loader unaltered. """ - # Attach our unicode constructor to our custom loader ensuring all strings - # will be unicode on translation. - Loader.add_constructor('tag:yaml.org,2002:str', construct_yaml_str) - # Attach Environment Variable constructor. # See https://github.com/waylan/pyyaml-env-tag Loader.add_constructor('!ENV', construct_env_tag) - try: - return yaml.load(source, Loader) - finally: - # TODO: Remove this when external calls are properly cleaning up file - # objects. Some mkdocs internal calls, sometimes in test lib, will - # load configs with a file object but never close it. On some - # systems, if a delete action is performed on that file without Python - # closing that object, there will be an access error. This will - # process the file and close it as there should be no more use for the - # file once we process the yaml content. - if hasattr(source, 'close'): - source.close() + return Loader + + +def yaml_load(source, loader=None): + """ Return dict of source YAML file using loader, recursively deep merging inherited parent. """ + Loader = loader or get_yaml_loader() + result = yaml.load(source, Loader=Loader) + if result is not None and 'INHERIT' in result: + relpath = result.pop('INHERIT') + abspath = os.path.normpath(os.path.join(os.path.dirname(source.name), relpath)) + if not os.path.exists(abspath): + raise exceptions.ConfigurationError( + f"Inherited config file '{relpath}' does not exist at '{abspath}'.") + log.debug(f"Loading inherited configuration file: {abspath}") + with open(abspath, 'rb') as fd: + parent = yaml_load(fd, Loader) + result = merge(parent, result) + return result def modified_time(file_path): diff --git a/requirements/project-min.txt b/requirements/project-min.txt index 72e1b2e8..2d9fce61 100644 --- a/requirements/project-min.txt +++ b/requirements/project-min.txt @@ -11,3 +11,4 @@ pyyaml_env_tag==0.1 mkdocs-redirects==1.0.1 importlib_metadata==3.10.0 packaging==20.5 +mergedeep==1.3.4 diff --git a/requirements/project.txt b/requirements/project.txt index 072be6ed..38b1b38e 100644 --- a/requirements/project.txt +++ b/requirements/project.txt @@ -11,3 +11,4 @@ pyyaml_env_tag>=0.1 mkdocs-redirects>=1.0.1 importlib_metadata>=3.10 packaging>=20.5 +mergedeep>=1.3.4 diff --git a/setup.py b/setup.py index c258fa2f..b9834e51 100755 --- a/setup.py +++ b/setup.py @@ -66,7 +66,8 @@ setup( 'ghp-import>=1.0', 'pyyaml_env_tag>=0.1', 'importlib_metadata>=3.10', - 'packaging>=20.5' + 'packaging>=20.5', + 'mergedeep>=1.3.4' ], extras_require={"i18n": ['babel>=2.9.0']}, python_requires='>=3.6',