# Copyright (c) 2016-2023 Martin Donath # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to # deal in the Software without restriction, including without limitation the # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or # sell copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS # IN THE SOFTWARE. from __future__ import annotations import logging from collections.abc import Callable from mkdocs.config.config_options import Plugins from mkdocs.config.defaults import MkDocsConfig from mkdocs.exceptions import PluginError from mkdocs.plugins import BasePlugin, event_priority from .config import GroupConfig # ----------------------------------------------------------------------------- # Classes # ----------------------------------------------------------------------------- # Group plugin class GroupPlugin(BasePlugin[GroupConfig]): supports_multiple_instances = True # Determine whether we're serving the site def on_startup(self, *, command, dirty): self.is_serve = command == "serve" self.is_dirty = dirty # If the group is enabled, conditionally load plugins - at first, this might # sound easier than it actually is, as we need to jump through some hoops to # ensure correct ordering among plugins. We're effectively initializing the # plugins that are part of the group after all MkDocs finished initializing # all other plugins, so we need to patch the order of the methods. Moreover, # we must use MkDocs existing plugin collection, or we might have collisions # with other plugins that are not part of the group. As so often, this is a # little hacky, but has huge potential making plugin configuration easier. # There's one little caveat: the `__init__` and `on_startup` methods of the # plugins that are part of the group are called after all other plugins, so # the `event_priority` decorator for `on_startup` events and is effectively # useless. However, the `on_startup` event is only intended to set up the # plugin and doesn't receive anything else than the invoked command and # whether we're running a dirty build, so there should be no problems. @event_priority(150) def on_config(self, config): if not self.config.enabled: return # Retrieve plugin collection from configuration option: Plugins = dict(config._schema)["plugins"] assert isinstance(option, Plugins) # Load all plugins in group self.plugins: dict[str, BasePlugin] = {} try: for name, plugin in self._load(option): self.plugins[name] = plugin # The plugin could not be loaded, likely because it's not installed or # misconfigured, so we raise a plugin error for a nicer error message except Exception as e: raise PluginError(str(e)) # Patch order of plugin methods for events in option.plugins.events.values(): self._patch(events, config) # Invoke `on_startup` event for plugins in group command = "serve" if self.is_serve else "build" for method in option.plugins.events["startup"]: plugin = self._get_plugin(method) # Ensure that we have a method bound to a plugin (and not a hook) if plugin and plugin in self.plugins.values(): method(command = command, dirty = self.is_dirty) # ------------------------------------------------------------------------- # Retrieve plugin instance for bound method or nothing def _get_plugin(self, method: Callable): return getattr(method, "__self__", None) # Retrieve priority of plugin method def _get_priority(self, method: Callable): return getattr(method, "mkdocs_priority", 0) # Retrieve position of plugin def _get_position(self, plugin: BasePlugin, config: MkDocsConfig) -> int: for at, (_, candidate) in enumerate(config.plugins.items()): if plugin == candidate: return at # ------------------------------------------------------------------------- # Load plugins that are part of the group def _load(self, option: Plugins): for name, data in option._parse_configs(self.config.plugins): yield option.load_plugin_with_namespace(name, data) # ------------------------------------------------------------------------- # Patch order of plugin methods - all other plugin methods are already in # the right order, so we only need to check those that are part of the group # and bubble them up into the right location. Some plugin methods may define # priorities, so we need to make sure to order correctly within those. def _patch(self, methods: list[Callable], config: MkDocsConfig): position = self._get_position(self, config) for at in reversed(range(1, len(methods))): tail = methods[at - 1] head = methods[at] # Skip if the plugin is not part of the group plugin = self._get_plugin(head) if not plugin or plugin not in self.plugins.values(): continue # Skip if the previous method has a higher priority than the current # one, because we know we can't swap them anyway if self._get_priority(tail) > self._get_priority(head): continue # Ensure that we have a method bound to a plugin (and not a hook) plugin = self._get_plugin(tail) if not plugin: continue # Both methods have the same priority, so we check if the ordering # of both methods is violated, and if it is, swap them if (position < self._get_position(plugin, config)): methods[at], methods[at - 1] = tail, head # ----------------------------------------------------------------------------- # Data # ----------------------------------------------------------------------------- # Set up logging log = logging.getLogger("mkdocs.material.group")