From 6fca6b59a29c07b7106d3c2ec58b7a3c136b09d8 Mon Sep 17 00:00:00 2001 From: Oleh Prypin Date: Tue, 11 Oct 2022 21:54:40 -0400 Subject: [PATCH] Support theme-namespaced plugin loading (#2998) This is mainly aimed at 'material' theme which also ships plugins with it. It will be able to ship plugins under the name e.g. 'material/search' and that will ensure the following effects: * If the current theme is 'material', the plugin 'material/search' will always be preferred over 'search'. * If the current theme *isn't* 'material', the only way to use this plugin is by specifying `plugins: [material/search]`. One can also specify `plugins: ['/search']` instead of `plugins: ['search']` to definitely avoid the theme-namespaced plugin. Previously: * #2591 @squidfunk --- mkdocs/config/config_options.py | 31 +++++++-- mkdocs/config/defaults.py | 2 +- mkdocs/tests/config/config_options_tests.py | 77 +++++++++++++++++++++ 3 files changed, 103 insertions(+), 7 deletions(-) diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py index 689092ea18..78e4bd741d 100644 --- a/mkdocs/config/config_options.py +++ b/mkdocs/config/config_options.py @@ -919,21 +919,23 @@ class Plugins(OptionallyRequired[plugins.PluginCollection]): initializing the plugin class. """ - def __init__(self, **kwargs) -> None: + def __init__(self, theme_key: t.Optional[str] = None, **kwargs) -> None: super().__init__(**kwargs) self.installed_plugins = plugins.get_plugins() - self.config_file_path: t.Optional[str] = None + self.theme_key = theme_key + self._config: t.Optional[Config] = None self.plugin_cache: Dict[str, plugins.BasePlugin] = {} - def pre_validation(self, config: Config, key_name: str): - self.config_file_path = config.config_file_path + def pre_validation(self, config, key_name): + self._config = config def run_validation(self, value: object) -> plugins.PluginCollection: if not isinstance(value, (list, tuple, dict)): raise ValidationError('Invalid Plugins configuration. Expected a list or dict.') self.plugins = plugins.PluginCollection() for name, cfg in self._parse_configs(value): - self.plugins[name] = self.load_plugin(name, cfg) + name, plugin = self.load_plugin_with_namespace(name, cfg) + self.plugins[name] = plugin return self.plugins @classmethod @@ -956,6 +958,21 @@ def _parse_configs(cls, value: Union[list, tuple, dict]) -> Iterator[Tuple[str, raise ValidationError(f"'{name}' is not a valid plugin name.") yield name, cfg + def load_plugin_with_namespace(self, name: str, config) -> Tuple[str, plugins.BasePlugin]: + if '/' in name: # It's already specified with a namespace. + # Special case: allow to explicitly skip namespaced loading: + if name.startswith('/'): + name = name[1:] + else: + # Attempt to load with prepended namespace for the current theme. + if self.theme_key and self._config: + current_theme = self._config[self.theme_key].name + if current_theme: + expanded_name = f'{current_theme}/{name}' + if expanded_name in self.installed_plugins: + name = expanded_name + return (name, self.load_plugin(name, config)) + def load_plugin(self, name: str, config) -> plugins.BasePlugin: if name not in self.installed_plugins: raise ValidationError(f'The "{name}" plugin is not installed') @@ -979,7 +996,9 @@ def load_plugin(self, name: str, config) -> plugins.BasePlugin: if hasattr(plugin, 'on_startup') or hasattr(plugin, 'on_shutdown'): self.plugin_cache[name] = plugin - errors, warnings = plugin.load_config(config, self.config_file_path) + errors, warnings = plugin.load_config( + config, self._config.config_file_path if self._config else None + ) self.warnings.extend(f"Plugin '{name}' value: '{x}'. Warning: {y}" for x, y in warnings) errors_message = '\n'.join(f"Plugin '{name}' value: '{x}'. Error: {y}" for x, y in errors) if errors_message: diff --git a/mkdocs/config/defaults.py b/mkdocs/config/defaults.py index 2a731795c3..9590ace755 100644 --- a/mkdocs/config/defaults.py +++ b/mkdocs/config/defaults.py @@ -117,7 +117,7 @@ class MkDocsConfig(base.Config): MkDocs itself. A good example here would be including the current project version.""" - plugins = c.Plugins(default=['search']) + plugins = c.Plugins(theme_key='theme', default=['search']) """A list of plugins. Each item may contain a string name or a key value pair. A key value pair should be the string name (as the key) and a dict of config options (as the value).""" diff --git a/mkdocs/tests/config/config_options_tests.py b/mkdocs/tests/config/config_options_tests.py index 34c09673f3..91828c5e77 100644 --- a/mkdocs/tests/config/config_options_tests.py +++ b/mkdocs/tests/config/config_options_tests.py @@ -1638,6 +1638,14 @@ class FakePlugin2(BasePlugin[_FakePlugin2Config]): pass +class ThemePlugin(BasePlugin[_FakePluginConfig]): + pass + + +class ThemePlugin2(BasePlugin[_FakePluginConfig]): + pass + + class FakeEntryPoint: def __init__(self, name, cls): self.name = name @@ -1652,6 +1660,9 @@ def load(self): return_value=[ FakeEntryPoint('sample', FakePlugin), FakeEntryPoint('sample2', FakePlugin2), + FakeEntryPoint('readthedocs/sub_plugin', ThemePlugin), + FakeEntryPoint('overridden', FakePlugin2), + FakeEntryPoint('readthedocs/overridden', ThemePlugin2), ], ) class PluginsTest(TestCase): @@ -1729,6 +1740,72 @@ class Schema(Config): } self.assertEqual(conf.plugins['sample'].config, expected) + def test_plugin_config_with_explicit_theme_namespace(self, mock_class) -> None: + class Schema(Config): + theme = c.Theme(default='mkdocs') + plugins = c.Plugins(theme_key='theme') + + cfg = {'theme': 'readthedocs', 'plugins': ['readthedocs/sub_plugin']} + conf = self.get_config(Schema, cfg) + + self.assertEqual(set(conf.plugins), {'readthedocs/sub_plugin'}) + self.assertIsInstance(conf.plugins['readthedocs/sub_plugin'], ThemePlugin) + + cfg = {'plugins': ['readthedocs/sub_plugin']} + conf = self.get_config(Schema, cfg) + + self.assertEqual(set(conf.plugins), {'readthedocs/sub_plugin'}) + self.assertIsInstance(conf.plugins['readthedocs/sub_plugin'], ThemePlugin) + + def test_plugin_config_with_deduced_theme_namespace(self, mock_class) -> None: + class Schema(Config): + theme = c.Theme(default='mkdocs') + plugins = c.Plugins(theme_key='theme') + + cfg = {'theme': 'readthedocs', 'plugins': ['sub_plugin']} + conf = self.get_config(Schema, cfg) + + self.assertEqual(set(conf.plugins), {'readthedocs/sub_plugin'}) + self.assertIsInstance(conf.plugins['readthedocs/sub_plugin'], ThemePlugin) + + cfg = {'plugins': ['sub_plugin']} + with self.expect_error(plugins='The "sub_plugin" plugin is not installed'): + self.get_config(Schema, cfg) + + def test_plugin_config_with_deduced_theme_namespace_overridden(self, mock_class) -> None: + class Schema(Config): + theme = c.Theme(default='mkdocs') + plugins = c.Plugins(theme_key='theme') + + cfg = {'theme': 'readthedocs', 'plugins': ['overridden']} + conf = self.get_config(Schema, cfg) + + self.assertEqual(set(conf.plugins), {'readthedocs/overridden'}) + self.assertIsInstance(next(iter(conf.plugins.values())), ThemePlugin2) + + cfg = {'plugins': ['overridden']} + conf = self.get_config(Schema, cfg) + + self.assertEqual(set(conf.plugins), {'overridden'}) + self.assertIsInstance(conf.plugins['overridden'], FakePlugin2) + + def test_plugin_config_with_explicit_empty_namespace(self, mock_class) -> None: + class Schema(Config): + theme = c.Theme(default='mkdocs') + plugins = c.Plugins(theme_key='theme') + + cfg = {'theme': 'readthedocs', 'plugins': ['/overridden']} + conf = self.get_config(Schema, cfg) + + self.assertEqual(set(conf.plugins), {'overridden'}) + self.assertIsInstance(next(iter(conf.plugins.values())), FakePlugin2) + + cfg = {'plugins': ['/overridden']} + conf = self.get_config(Schema, cfg) + + self.assertEqual(set(conf.plugins), {'overridden'}) + self.assertIsInstance(conf.plugins['overridden'], FakePlugin2) + def test_plugin_config_empty_list_with_empty_default(self, mock_class) -> None: class Schema(Config): plugins = c.Plugins(default=[])