diff --git a/docs/css/extra.css b/docs/css/extra.css index 5b5f8290..75040dcd 100644 --- a/docs/css/extra.css +++ b/docs/css/extra.css @@ -60,6 +60,11 @@ body.homepage>div.container>div.row>div.col-md-9 { /* mkdocstrings */ +.doc-object { + padding-left: 10px; + border-left: 4px solid rgba(230, 230, 230); +} + .doc-contents .field-body p:first-of-type { display: inline; } diff --git a/docs/dev-guide/plugins.md b/docs/dev-guide/plugins.md index 2d692fc6..b4cd7c3e 100644 --- a/docs/dev-guide/plugins.md +++ b/docs/dev-guide/plugins.md @@ -295,6 +295,16 @@ page events are called after the [post_template] event and before the show_root_heading: false show_root_toc_entry: false +### Event Priorities + +For each event type, corresponding methods of plugins are called in the order that the plugins appear in the `plugins` [config][]. + +Since MkDocs 1.4, plugins can choose to set a priority value for their events. Events with higher priority are called first. Events without a chosen priority get a default of 0. Events that have the same priority are ordered as they appear in the config. + +::: mkdocs.plugins.event_priority + options: + show_root_heading: true + ### Handling Errors MkDocs defines four error types: diff --git a/mkdocs/plugins.py b/mkdocs/plugins.py index c7afc758..494827a9 100644 --- a/mkdocs/plugins.py +++ b/mkdocs/plugins.py @@ -16,6 +16,7 @@ else: import jinja2.environment +from mkdocs import utils from mkdocs.config.base import Config, ConfigErrors, ConfigWarnings, PlainConfigSchema from mkdocs.livereload import LiveReloadServer from mkdocs.structure.files import Files @@ -346,6 +347,37 @@ for k in EVENTS: T = TypeVar('T') +def event_priority(priority: float) -> Callable[[T], T]: + """A decorator to set an event priority for an event handler method. + + Recommended priority values: + `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. + + ```python + @plugins.event_priority(-100) # Wishing to run this after all other plugins' `on_files` events. + def on_files(self, files, config, **kwargs): + ... + ``` + + New in MkDocs 1.4. + Recommended shim for backwards compatibility: + + ```python + try: + from mkdocs.plugins import event_priority + except ImportError: + event_priority = lambda priority: lambda f: f # No-op fallback + ``` + """ + + def decorator(event_method): + event_method.mkdocs_priority = priority + return event_method + + return decorator + + class PluginCollection(OrderedDict): """ A collection of plugins. @@ -361,7 +393,9 @@ class PluginCollection(OrderedDict): def _register_event(self, event_name: str, method: Callable) -> None: """Register a method for an event.""" - self.events[event_name].append(method) + utils.insort( + self.events[event_name], method, key=lambda m: -getattr(m, 'mkdocs_priority', 0) + ) def __setitem__(self, key: str, value: BasePlugin, **kwargs) -> None: if not isinstance(value, BasePlugin): diff --git a/mkdocs/tests/plugin_tests.py b/mkdocs/tests/plugin_tests.py index 7b1f6866..073512bf 100644 --- a/mkdocs/tests/plugin_tests.py +++ b/mkdocs/tests/plugin_tests.py @@ -117,6 +117,45 @@ class TestPluginCollection(unittest.TestCase): }, ) + def test_event_priorities(self): + class PrioPlugin(plugins.BasePlugin): + config_scheme = base.get_schema(_DummyPluginConfig) + + @plugins.event_priority(100) + def on_pre_page(self, content, **kwargs): + pass + + @plugins.event_priority(-100) + def on_nav(self, item, **kwargs): + pass + + def on_page_read_source(self, **kwargs): + pass + + @plugins.event_priority(-50) + def on_post_build(self, **kwargs): + pass + + collection = plugins.PluginCollection() + collection['dummy'] = dummy = DummyPlugin() + collection['prio'] = prio = PrioPlugin() + self.assertEqual( + collection.events['pre_page'], + [prio.on_pre_page, dummy.on_pre_page], + ) + self.assertEqual( + collection.events['nav'], + [dummy.on_nav, prio.on_nav], + ) + self.assertEqual( + collection.events['page_read_source'], + [dummy.on_page_read_source, prio.on_page_read_source], + ) + self.assertEqual( + collection.events['post_build'], + [prio.on_post_build], + ) + def test_set_plugin_on_collection(self): collection = plugins.PluginCollection() plugin = DummyPlugin() diff --git a/mkdocs/tests/utils/utils_tests.py b/mkdocs/tests/utils/utils_tests.py index c4eadda6..9ea9b382 100644 --- a/mkdocs/tests/utils/utils_tests.py +++ b/mkdocs/tests/utils/utils_tests.py @@ -285,6 +285,22 @@ class UtilsTests(unittest.TestCase): [1, 2, 3, 4, 5, 6, 7, 8], ) + def test_insort(self): + a = [1, 2, 3] + utils.insort(a, 5) + self.assertEqual(a, [1, 2, 3, 5]) + utils.insort(a, -1) + self.assertEqual(a, [-1, 1, 2, 3, 5]) + utils.insort(a, 2) + self.assertEqual(a, [-1, 1, 2, 2, 3, 5]) + utils.insort(a, 4) + self.assertEqual(a, [-1, 1, 2, 2, 3, 4, 5]) + + def test_insort_key(self): + a = [(1, 'a'), (1, 'b'), (2, 'c')] + utils.insort(a, (1, 'a'), key=lambda v: v[0]) + self.assertEqual(a, [(1, 'a'), (1, 'b'), (1, 'a'), (2, 'c')]) + def test_get_themes(self): themes = utils.get_theme_names() self.assertIn('mkdocs', themes) diff --git a/mkdocs/utils/__init__.py b/mkdocs/utils/__init__.py index c9d2a552..f4b9c696 100644 --- a/mkdocs/utils/__init__.py +++ b/mkdocs/utils/__init__.py @@ -17,7 +17,18 @@ import warnings from collections import defaultdict from datetime import datetime, timezone from pathlib import PurePath -from typing import IO, TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple +from typing import ( + IO, + TYPE_CHECKING, + Any, + Dict, + Iterable, + List, + MutableSequence, + Optional, + Tuple, + TypeVar, +) from urllib.parse import urlsplit if sys.version_info >= (3, 10): @@ -34,6 +45,8 @@ from mkdocs import exceptions if TYPE_CHECKING: from mkdocs.structure.pages import Page +T = TypeVar('T') + log = logging.getLogger(__name__) markdown_extensions = ( @@ -132,6 +145,18 @@ def reduce_list(data_set: Iterable[str]) -> List[str]: return list(dict.fromkeys(data_set)) +if sys.version_info >= (3, 10): + from bisect import insort +else: + + def insort(a: MutableSequence[T], x: T, *, key=lambda v: v) -> None: + kx = key(x) + i = len(a) + while i > 0 and kx < key(a[i - 1]): + i -= 1 + a.insert(i, x) + + def copy_file(source_path: str, output_path: str) -> None: """ Copy source_path to output_path, making sure any parent directories exist.