From dc45916aa1cc4b4d4796dd45656bd1ff60d4ce44 Mon Sep 17 00:00:00 2001 From: Oleh Prypin Date: Wed, 8 Nov 2023 18:33:38 +0100 Subject: [PATCH] Allow adding event handlers at multiple priorities (#3448) -within one plugin. --- docs/dev-guide/plugins.md | 6 +++ mkdocs/plugins.py | 96 ++++++++++++++++++++++++++---------- mkdocs/tests/plugin_tests.py | 14 ++++-- 3 files changed, 88 insertions(+), 28 deletions(-) diff --git a/docs/dev-guide/plugins.md b/docs/dev-guide/plugins.md index 3ec2ac90..8d4a8c63 100644 --- a/docs/dev-guide/plugins.md +++ b/docs/dev-guide/plugins.md @@ -429,6 +429,12 @@ Since MkDocs 1.4, plugins can choose to set a priority value for their events. E #### ::: mkdocs.plugins.event_priority +There may also arise a need to register a handler for the same event at multiple different priorities. + +`CombinedEvent` makes this possible since MkDocs 1.6. + +#### ::: mkdocs.plugins.CombinedEvent + ### Handling Errors MkDocs defines four error types: diff --git a/mkdocs/plugins.py b/mkdocs/plugins.py index fe55b4b7..98a99f38 100644 --- a/mkdocs/plugins.py +++ b/mkdocs/plugins.py @@ -24,6 +24,14 @@ if TYPE_CHECKING: from mkdocs.structure.pages import Page from mkdocs.utils.templates import TemplateContext +if TYPE_CHECKING: + from typing_extensions import Concatenate, ParamSpec +else: + ParamSpec = TypeVar + +P = ParamSpec('P') +T = TypeVar('T') + log = logging.getLogger('mkdocs.plugins') @@ -123,7 +131,7 @@ class BasePlugin(Generic[SomeConfig]): """ def on_serve( - self, server: LiveReloadServer, *, config: MkDocsConfig, builder: Callable + self, server: LiveReloadServer, /, *, config: MkDocsConfig, builder: Callable ) -> LiveReloadServer | None: """ The `serve` event is only called when the `serve` command is used during @@ -167,7 +175,7 @@ class BasePlugin(Generic[SomeConfig]): config: global configuration object """ - def on_files(self, files: Files, *, config: MkDocsConfig) -> Files | None: + def on_files(self, files: Files, /, *, config: MkDocsConfig) -> Files | None: """ The `files` event is called after the files collection is populated from the `docs_dir`. Use this event to add, remove, or alter files in the @@ -184,7 +192,9 @@ class BasePlugin(Generic[SomeConfig]): """ return files - def on_nav(self, nav: Navigation, *, config: MkDocsConfig, files: Files) -> Navigation | None: + def on_nav( + self, nav: Navigation, /, *, config: MkDocsConfig, files: Files + ) -> Navigation | None: """ The `nav` event is called after the site navigation is created and can be used to alter the site navigation. @@ -200,7 +210,7 @@ class BasePlugin(Generic[SomeConfig]): return nav def on_env( - self, env: jinja2.Environment, *, config: MkDocsConfig, files: Files + self, env: jinja2.Environment, /, *, config: MkDocsConfig, files: Files ) -> jinja2.Environment | None: """ The `env` event is called after the Jinja template environment is created @@ -241,7 +251,7 @@ class BasePlugin(Generic[SomeConfig]): # Template events def on_pre_template( - self, template: jinja2.Template, *, template_name: str, config: MkDocsConfig + self, template: jinja2.Template, /, *, template_name: str, config: MkDocsConfig ) -> jinja2.Template | None: """ The `pre_template` event is called immediately after the subject template is @@ -258,7 +268,7 @@ class BasePlugin(Generic[SomeConfig]): return template def on_template_context( - self, context: TemplateContext, *, template_name: str, config: MkDocsConfig + self, context: TemplateContext, /, *, template_name: str, config: MkDocsConfig ) -> TemplateContext | None: """ The `template_context` event is called immediately after the context is created @@ -276,7 +286,7 @@ class BasePlugin(Generic[SomeConfig]): return context def on_post_template( - self, output_content: str, *, template_name: str, config: MkDocsConfig + self, output_content: str, /, *, template_name: str, config: MkDocsConfig ) -> str | None: """ The `post_template` event is called after the template is rendered, but before @@ -296,7 +306,7 @@ class BasePlugin(Generic[SomeConfig]): # Page events - def on_pre_page(self, page: Page, *, config: MkDocsConfig, files: Files) -> Page | None: + def on_pre_page(self, page: Page, /, *, config: MkDocsConfig, files: Files) -> Page | None: """ The `pre_page` event is called before any actions are taken on the subject page and can be used to alter the `Page` instance. @@ -311,7 +321,7 @@ class BasePlugin(Generic[SomeConfig]): """ return page - def on_page_read_source(self, *, page: Page, config: MkDocsConfig) -> str | None: + def on_page_read_source(self, /, *, page: Page, config: MkDocsConfig) -> str | None: """ The `on_page_read_source` event can replace the default mechanism to read the contents of a page's source from the filesystem. @@ -327,7 +337,7 @@ class BasePlugin(Generic[SomeConfig]): return None def on_page_markdown( - self, markdown: str, *, page: Page, config: MkDocsConfig, files: Files + self, markdown: str, /, *, page: Page, config: MkDocsConfig, files: Files ) -> str | None: """ The `page_markdown` event is called after the page's markdown is loaded @@ -346,7 +356,7 @@ class BasePlugin(Generic[SomeConfig]): return markdown def on_page_content( - self, html: str, *, page: Page, config: MkDocsConfig, files: Files + self, html: str, /, *, page: Page, config: MkDocsConfig, files: Files ) -> str | None: """ The `page_content` event is called after the Markdown text is rendered to @@ -365,7 +375,7 @@ class BasePlugin(Generic[SomeConfig]): return html def on_page_context( - self, context: TemplateContext, *, page: Page, config: MkDocsConfig, nav: Navigation + self, context: TemplateContext, /, *, page: Page, config: MkDocsConfig, nav: Navigation ) -> TemplateContext | None: """ The `page_context` event is called after the context for a page is created @@ -382,7 +392,7 @@ class BasePlugin(Generic[SomeConfig]): """ return context - def on_post_page(self, output: str, *, page: Page, config: MkDocsConfig) -> str | None: + def on_post_page(self, output: str, /, *, page: Page, config: MkDocsConfig) -> str | None: """ The `post_page` event is called after the template is rendered, but before it is written to disc and can be used to alter the output of the @@ -407,9 +417,6 @@ for k in EVENTS: delattr(BasePlugin, 'on_' + k) -T = TypeVar('T') - - def event_priority(priority: float) -> Callable[[T], T]: """ A decorator to set an event priority for an event handler method. @@ -418,6 +425,8 @@ def event_priority(priority: float) -> Callable[[T], T]: `100` "first", `50` "early", `0` "default", `-50` "late", `-100` "last". As different plugins discover more precise relations to each other, the values should be further tweaked. + Usage example: + ```python @plugins.event_priority(-100) # Wishing to run this after all other plugins' `on_files` events. def on_files(self, files, config, **kwargs): @@ -442,6 +451,39 @@ def event_priority(priority: float) -> Callable[[T], T]: return decorator +class CombinedEvent(Generic[P, T]): + """ + A descriptor that allows defining multiple event handlers and declaring them under one event's name. + + Usage example: + + ```python + @plugins.event_priority(100) + def _on_page_markdown_1(self, markdown: str, **kwargs): + ... + + @plugins.event_priority(-50) + def _on_page_markdown_2(self, markdown: str, **kwargs): + ... + + on_page_markdown = plugins.CombinedEvent(_on_page_markdown_1, _on_page_markdown_2) + ``` + + NOTE: The names of the sub-methods **can't** start with `on_`; + instead they can start with `_on_` like in the the above example, or anything else. + """ + + def __init__(self, *methods: Callable[Concatenate[Any, P], T]): + self.methods = methods + + # This is only for mypy, so CombinedEvent can be a valid override of the methods in BasePlugin + def __call__(self, instance: BasePlugin, *args: P.args, **kwargs: P.kwargs) -> T: + raise TypeError(f"{type(self).__name__!r} object is not callable") + + def __get__(self, instance, owner=None): + return CombinedEvent(*(f.__get__(instance, owner) for f in self.methods)) + + class PluginCollection(dict, MutableMapping[str, BasePlugin]): """ A collection of plugins. @@ -457,17 +499,21 @@ class PluginCollection(dict, MutableMapping[str, BasePlugin]): self._event_origins: dict[Callable, str] = {} def _register_event( - self, event_name: str, method: Callable, plugin_name: str | None = None + self, event_name: str, method: CombinedEvent | Callable, plugin_name: str | None = None ) -> None: """Register a method for an event.""" - utils.insort( - self.events[event_name], method, key=lambda m: -getattr(m, 'mkdocs_priority', 0) - ) - if plugin_name: - try: - self._event_origins[method] = plugin_name - except TypeError: # If the method is somehow not hashable. - pass + if isinstance(method, CombinedEvent): + for sub in method.methods: + self._register_event(event_name, sub, plugin_name=plugin_name) + else: + utils.insort( + self.events[event_name], method, key=lambda m: -getattr(m, 'mkdocs_priority', 0) + ) + if plugin_name: + try: + self._event_origins[method] = plugin_name + except TypeError: # If the method is somehow not hashable. + pass def __getitem__(self, key: str) -> BasePlugin: return super().__getitem__(key) diff --git a/mkdocs/tests/plugin_tests.py b/mkdocs/tests/plugin_tests.py index 6fa10142..8c09cf4f 100644 --- a/mkdocs/tests/plugin_tests.py +++ b/mkdocs/tests/plugin_tests.py @@ -7,6 +7,8 @@ from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from typing_extensions import assert_type + + from mkdocs.structure.nav import Navigation else: def assert_type(val, typ): @@ -138,10 +140,16 @@ class TestPluginCollection(unittest.TestCase): def on_page_content(self, html, **kwargs) -> None: pass - @plugins.event_priority(-100) - def on_nav(self, nav, **kwargs) -> None: + @plugins.event_priority(50) + def _on_nav_1(self, nav: Navigation, **kwargs) -> None: pass + @plugins.event_priority(-100) + def _on_nav_2(self, nav, **kwargs) -> None: + pass + + on_nav = plugins.CombinedEvent(_on_nav_1, _on_nav_2) + def on_page_read_source(self, **kwargs) -> None: pass @@ -158,7 +166,7 @@ class TestPluginCollection(unittest.TestCase): ) self.assertEqual( collection.events['nav'], - [dummy.on_nav, prio.on_nav], + [prio._on_nav_1, dummy.on_nav, prio._on_nav_2], ) self.assertEqual( collection.events['page_read_source'],