Skip to content

Commit

Permalink
Support theme-namespaced plugin loading (#2998)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
oprypin committed Oct 12, 2022
1 parent 568e63e commit 6fca6b5
Show file tree
Hide file tree
Showing 3 changed files with 103 additions and 7 deletions.
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

1 comment on commit 6fca6b5

@squidfunk
Copy link
Contributor

@squidfunk squidfunk commented on 6fca6b5 Oct 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your work on this feature! Ideally, this could be released in the coming weeks before the next funding goal is hit, as it will force me to release the new search plugin which this change is mostly about. I will then up the MkDocs version requirement in Material for MkDocs to that version.

Please sign in to comment.