Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support theme-namespaced plugin loading #2998

Merged
merged 2 commits into from Oct 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
31 changes: 25 additions & 6 deletions mkdocs/config/config_options.py
Expand Up @@ -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
Expand All @@ -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')
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion mkdocs/config/defaults.py
Expand Up @@ -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)."""
Expand Down
77 changes: 77 additions & 0 deletions mkdocs/tests/config/config_options_tests.py
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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=[])
Expand Down