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
This commit is contained in:
Oleh Prypin
2022-10-11 21:54:40 -04:00
committed by GitHub
parent 568e63ec3f
commit 6fca6b59a2
3 changed files with 103 additions and 7 deletions

View File

@@ -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 @@ class Plugins(OptionallyRequired[plugins.PluginCollection]):
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 @@ class Plugins(OptionallyRequired[plugins.PluginCollection]):
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:

View File

@@ -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)."""

View File

@@ -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 @@ class FakeEntryPoint:
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 PluginsTest(TestCase):
}
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=[])