mirror of
https://github.com/mkdocs/mkdocs.git
synced 2026-03-27 09:58:31 +07:00
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:
@@ -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:
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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=[])
|
||||
|
||||
Reference in New Issue
Block a user