Add plugin events that persist across builds in mkdocs serve

"One-time events" `on_startup(command)`, `on_shutdown`.

Their presence also shows that a plugin *wants* to persist across builds. Otherwise they will be re-created, to not change any existing behavior.
This commit is contained in:
Oleh Prypin
2022-09-11 20:04:10 +02:00
parent 9ac7322b00
commit a56ac6e051
13 changed files with 537 additions and 401 deletions

View File

@@ -157,11 +157,23 @@ There are three kinds of events: [Global Events], [Page Events] and
</details>
<br>
#### Global Events
#### One-time Events
Global events are called once per build at either the beginning or end of the
build process. Any changes made in these events will have a global effect on the
entire site.
One-time events run once per `mkdocs` invocation. The only case where these tangibly differ from [global events](#global-events) is for `mkdocs serve`: global events, unlike these, will run multiple times -- once per *build*.
##### on_startup
::: mkdocs.plugins.BasePlugin.on_startup
options:
show_root_heading: false
show_root_toc_entry: false
##### on_shutdown
::: mkdocs.plugins.BasePlugin.on_shutdown
options:
show_root_heading: false
show_root_toc_entry: false
##### on_serve
@@ -170,6 +182,12 @@ entire site.
show_root_heading: false
show_root_toc_entry: false
#### Global Events
Global events are called once per build at either the beginning or end of the
build process. Any changes made in these events will have a global effect on the
entire site.
##### on_config
::: mkdocs.plugins.BasePlugin.on_config

View File

@@ -7,11 +7,11 @@ import re
from graphviz import Digraph
g = Digraph("MkDocs", format="svg")
g.attr(compound="true", bgcolor="transparent")
g.graph_attr.update(fontname="inherit", tooltip=" ")
g.node_attr.update(fontname="inherit", tooltip=" ", style="filled")
g.edge_attr.update(fontname="inherit", tooltip=" ")
graph = Digraph("MkDocs", format="svg")
graph.attr(compound="true", bgcolor="transparent")
graph.graph_attr.update(fontname="inherit", tooltip=" ")
graph.node_attr.update(fontname="inherit", tooltip=" ", style="filled")
graph.edge_attr.update(fontname="inherit", tooltip=" ")
def strip_suffix(name):
@@ -60,7 +60,7 @@ def edge(g, a, b, dashed=False, **kwargs):
def ensure_order(a, b):
edge(g, a, b, style="invis")
edge(graph, a, b, style="invis")
@contextlib.contextmanager
@@ -79,7 +79,7 @@ def event(g, name, parameters):
g, f"cluster_{name}", href=f"#{name}", bgcolor="#ffff3388", pencolor="#00000088"
) as c:
label = "|".join(f"<{p}>{p}" for p in parameters.split())
node(c, name, shape="record", label=label, fillcolor="#ffffff55")
node(c, name, shape="record" if parameters else "point", label=label, fillcolor="#ffffff55")
def placeholder_cluster(g, name):
@@ -87,72 +87,74 @@ def placeholder_cluster(g, name):
node(c, f"placeholder_{name}", label="...", fillcolor="transparent", color="transparent")
event(g, "on_config", "config")
event(g, "on_pre_build", "config")
event(g, "on_files", "files config")
event(g, "on_nav", "nav config files")
event(graph, "on_startup", "command")
edge(g, "load_config", "on_config:config")
edge(g, "on_config:config", "on_pre_build:config")
edge(g, "on_config:config", "get_files")
edge(g, "get_files", "on_files:files")
edge(g, "on_files:files", "get_nav")
edge(g, "get_nav", "on_nav:nav")
edge(g, "on_files:files", "on_nav:files")
with cluster(graph, "cluster_build", bgcolor="#dddddd11") as g:
event(g, "on_config", "config")
event(g, "on_pre_build", "config")
event(g, "on_files", "files config")
event(g, "on_nav", "nav config files")
with cluster(g, "cluster_populate_page") as c:
event(c, "on_pre_page", "page config files")
event(c, "on_page_read_source", "page config")
event(c, "on_page_markdown", "markdown page config files")
event(c, "on_page_content", "html page config files")
edge(g, "load_config", "on_config:config")
edge(g, "on_config:config", "on_pre_build:config")
edge(g, "on_config:config", "get_files")
edge(g, "get_files", "on_files:files")
edge(g, "on_files:files", "get_nav")
edge(g, "get_nav", "on_nav:nav")
edge(g, "on_files:files", "on_nav:files")
edge(c, "on_pre_page:page", "on_page_read_source:page", style="dashed")
edge(c, "cluster_on_page_read_source", "on_page_markdown:markdown", style="dashed")
edge(c, "on_page_markdown:markdown", "render_p", style="dashed")
edge(c, "render_p", "on_page_content:html", style="dashed")
with cluster(g, "cluster_populate_page") as c:
event(c, "on_pre_page", "page config files")
event(c, "on_page_read_source", "page config")
event(c, "on_page_markdown", "markdown page config files")
event(c, "on_page_content", "html page config files")
edge(g, "on_nav:files", "pages_point_a", arrowhead="none")
edge(g, "pages_point_a", "on_pre_page:page", style="dashed")
edge(g, "pages_point_a", "cluster_populate_page")
edge(c, "on_pre_page:page", "on_page_read_source:page", style="dashed")
edge(c, "cluster_on_page_read_source", "on_page_markdown:markdown", style="dashed")
edge(c, "on_page_markdown:markdown", "render_p", style="dashed")
edge(c, "render_p", "on_page_content:html", style="dashed")
for i in 2, 3:
placeholder_cluster(g, f"cluster_populate_page_{i}")
edge(g, "pages_point_a", f"cluster_populate_page_{i}", style="dashed")
edge(g, f"cluster_populate_page_{i}", "pages_point_b", style="dashed")
event(g, "on_env", "env config files")
edge(g, "on_page_content:html", "pages_point_b", style="dashed")
edge(g, "pages_point_b", "on_env:files")
edge(g, "pages_point_b", "pages_point_c", arrowhead="none")
edge(g, "pages_point_c", "on_page_context:page", style="dashed")
with cluster(g, "cluster_build_page") as c:
event(c, "on_page_context", "context page config nav")
event(c, "on_post_page", "output page config")
edge(c, "get_context", "on_page_context:context")
edge(c, "on_page_context:context", "render")
edge(c, "get_template", "render")
edge(c, "render", "on_post_page:output")
edge(c, "on_post_page:output", "write_file")
edge(g, "on_nav:nav", "cluster_build_page")
edge(g, "on_env:env", "cluster_build_page")
for i in 2, 3:
placeholder_cluster(g, f"cluster_build_page_{i}")
edge(g, "pages_point_c", f"cluster_build_page_{i}", style="dashed")
event(g, "on_post_build", "config")
event(g, "on_serve", "server config")
edge(g, "on_nav:files", "pages_point_a", arrowhead="none")
edge(g, "pages_point_a", "on_pre_page:page", style="dashed")
edge(g, "pages_point_a", "cluster_populate_page")
for i in 2, 3:
placeholder_cluster(g, f"cluster_populate_page_{i}")
edge(g, "pages_point_a", f"cluster_populate_page_{i}", style="dashed")
edge(g, f"cluster_populate_page_{i}", "pages_point_b", style="dashed")
event(g, "on_env", "env config files")
edge(g, "on_page_content:html", "pages_point_b", style="dashed")
edge(g, "pages_point_b", "on_env:files")
edge(g, "pages_point_b", "pages_point_c", arrowhead="none")
edge(g, "pages_point_c", "on_page_context:page", style="dashed")
with cluster(g, "cluster_build_page") as c:
event(c, "on_page_context", "context page config nav")
event(c, "on_post_page", "output page config")
edge(c, "get_context", "on_page_context:context")
edge(c, "on_page_context:context", "render")
edge(c, "get_template", "render")
edge(c, "render", "on_post_page:output")
edge(c, "on_post_page:output", "write_file")
edge(g, "on_nav:nav", "cluster_build_page")
edge(g, "on_env:env", "cluster_build_page")
for i in 2, 3:
placeholder_cluster(g, f"cluster_build_page_{i}")
edge(g, "pages_point_c", f"cluster_build_page_{i}", style="dashed")
event(g, "on_post_build", "config")
event(graph, "on_serve", "server config")
event(graph, "on_shutdown", "")
ensure_order("on_startup", "cluster_build")
ensure_order("on_pre_build", "on_files")
ensure_order("on_nav", "cluster_populate_page")
ensure_order("cluster_populate_page_2", "cluster_populate_page_3")
@@ -161,8 +163,9 @@ ensure_order("pages_point_c", "cluster_build_page")
ensure_order("cluster_build_page_2", "cluster_build_page_3")
ensure_order("cluster_build_page", "on_post_build")
ensure_order("on_post_build", "on_serve")
ensure_order("on_serve", "on_shutdown")
data = g.pipe()
data = graph.pipe()
data = data[data.index(b"<svg ") :]
pathlib.Path(__file__).with_suffix(".svg").write_bytes(data)

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -240,7 +240,12 @@ def build_command(clean, **kwargs):
from mkdocs.commands import build
_enable_warnings()
build.build(config.load_config(**kwargs), dirty=not clean)
cfg = config.load_config(**kwargs)
cfg['plugins'].run_event('startup', command='build')
try:
build.build(cfg, dirty=not clean)
finally:
cfg['plugins'].run_event('shutdown')
@cli.command(name="gh-deploy")
@@ -263,7 +268,11 @@ def gh_deploy_command(
_enable_warnings()
cfg = config.load_config(remote_branch=remote_branch, remote_name=remote_name, **kwargs)
build.build(cfg, dirty=not clean)
cfg['plugins'].run_event('startup', command='gh-deploy')
try:
build.build(cfg, dirty=not clean)
finally:
cfg['plugins'].run_event('shutdown')
gh_deploy.gh_deploy(
cfg,
message=message,

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import gzip
import logging
import os
import time
from typing import Any, Dict, Optional, Sequence, Set, Union
from urllib.parse import urlsplit
@@ -265,8 +266,6 @@ def build(config: Config, live_server: bool = False, dirty: bool = False) -> Non
logging.getLogger('mkdocs').addHandler(warning_counter)
try:
import time
start = time.monotonic()
# Run `config` plugin events.

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import functools
import logging
import shutil
import tempfile
@@ -42,17 +43,21 @@ def serve(
def mount_path(config):
return urlsplit(config['site_url'] or '/').path
def builder():
get_config = functools.partial(
load_config,
config_file=config_file,
dev_addr=dev_addr,
strict=strict,
theme=theme,
theme_dir=theme_dir,
site_dir=site_dir,
**kwargs,
)
def builder(config=None):
log.info("Building documentation...")
config = load_config(
config_file=config_file,
dev_addr=dev_addr,
strict=strict,
theme=theme,
theme_dir=theme_dir,
site_dir=site_dir,
**kwargs,
)
if config is None:
config = get_config()
# combine CLI watch arguments with config file values
if config["watch"] is None:
@@ -66,11 +71,13 @@ def serve(
live_server = livereload in ['dirty', 'livereload']
dirty = livereload == 'dirty'
build(config, live_server=live_server, dirty=dirty)
return config
config = get_config()
config['plugins'].run_event('startup', command='serve')
try:
# Perform the initial build
config = builder()
builder(config)
host, port = config['dev_addr']
server = LiveReloadServer(
@@ -114,5 +121,6 @@ def serve(
# Avoid ugly, unhelpful traceback
raise Abort(f'{type(e).__name__}: {e}')
finally:
config['plugins'].run_event('shutdown')
if isdir(site_dir):
shutil.rmtree(site_dir)

View File

@@ -5,7 +5,7 @@ import os
import sys
import traceback
import typing as t
from typing import NamedTuple
from typing import Dict, NamedTuple
from urllib.parse import urlsplit, urlunsplit
import markdown
@@ -705,6 +705,7 @@ class Plugins(OptionallyRequired):
super().__init__(**kwargs)
self.installed_plugins = plugins.get_plugins()
self.config_file_path = None
self.plugin_cache: Dict[str, plugins.BasePlugin] = {}
def pre_validation(self, config, key_name):
self.config_file_path = config.config_file_path
@@ -738,15 +739,22 @@ class Plugins(OptionallyRequired):
if not isinstance(config, dict):
raise ValidationError(f"Invalid config options for the '{name}' plugin.")
Plugin = self.installed_plugins[name].load()
try:
plugin = self.plugin_cache[name]
except KeyError:
Plugin = self.installed_plugins[name].load()
if not issubclass(Plugin, plugins.BasePlugin):
raise ValidationError(
f'{Plugin.__module__}.{Plugin.__name__} must be a subclass of'
f' {plugins.BasePlugin.__module__}.{plugins.BasePlugin.__name__}'
)
if not issubclass(Plugin, plugins.BasePlugin):
raise ValidationError(
f'{Plugin.__module__}.{Plugin.__name__} must be a subclass of'
f' {plugins.BasePlugin.__module__}.{plugins.BasePlugin.__name__}'
)
plugin = Plugin()
if hasattr(plugin, 'on_startup') or hasattr(plugin, 'on_shutdown'):
self.plugin_cache[name] = plugin
plugin = Plugin()
errors, warnings = plugin.load_config(config, self.config_file_path)
self.warnings.extend(warnings)
errors_message = '\n'.join(f"Plugin '{name}' value: '{x}'. Error: {y}" for x, y in errors)

View File

@@ -171,7 +171,7 @@ class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGISe
self._visible_epoch = self._wanted_epoch
self._epoch_cond.notify_all()
def shutdown(self) -> None:
def shutdown(self, wait=False) -> None:
self.observer.stop()
with self._rebuild_cond:
self._shutdown = True
@@ -179,6 +179,7 @@ class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGISe
if self.serve_thread.is_alive():
super().shutdown()
if wait:
self.serve_thread.join()
self.observer.join()

View File

@@ -14,6 +14,11 @@ if sys.version_info >= (3, 10):
else:
from importlib_metadata import EntryPoint, entry_points
if sys.version_info >= (3, 8):
from typing import Literal
else:
from typing_extensions import Literal
import jinja2.environment
from mkdocs.config.base import Config, ConfigErrors, ConfigWarnings, PlainConfigSchema
@@ -61,16 +66,48 @@ class BasePlugin:
return self.config.validate()
# (Note that event implementations shouldn't actually be static methods in subclasses)
# One-time events
# Global events
def on_startup(self, command: Literal['build', 'gh-deploy', 'serve']) -> None:
"""
The `startup` event runs once at the very beginning of an `mkdocs` invocation.
New in MkDocs 1.4.
The presence of an `on_startup` method (even if empty) migrates the plugin to the new
system where the plugin object is kept across builds within one `mkdocs serve`.
Note that for initializing variables, the `__init__` method is still preferred.
For initializing per-build variables (and whenever in doubt), use the `on_config` event.
Parameters:
command: the command that MkDocs was invoked with, e.g. "serve" for `mkdocs serve`.
"""
def on_shutdown(self) -> None:
"""
The `shutdown` event runs once at the very end of an `mkdocs` invocation, before exiting.
This event is relevant only for support of `mkdocs serve`, otherwise within a
single build it's undistinguishable from `on_post_build`.
New in MkDocs 1.4.
The presence of an `on_shutdown` method (even if empty) migrates the plugin to the new
system where the plugin object is kept across builds within one `mkdocs serve`.
Note the `on_post_build` method is still preferred for cleanups, when possible, as it has
a much higher chance of actually triggering. `on_shutdown` is "best effort" because it
relies on detecting a graceful shutdown of MkDocs.
"""
def on_serve(
self, server: LiveReloadServer, config: Config, builder: Callable
) -> Optional[LiveReloadServer]:
"""
The `serve` event is only called when the `serve` command is used during
development. It is passed the `Server` instance which can be modified before
development. It runs only once, after the first build finishes.
It is passed the `Server` instance which can be modified before
it is activated. For example, additional files or directories could be added
to the list of "watched" files for auto-reloading.
@@ -84,6 +121,8 @@ class BasePlugin:
"""
return server
# Global events
def on_config(self, config: Config) -> Optional[Config]:
"""
The `config` event is the first event called on build and is run immediately
@@ -395,7 +434,10 @@ class PluginCollection(OrderedDict):
be modified by the event method.
"""
pass_item = item is not None
for method in self.events[name]:
events = self.events[name]
if events:
log.debug(f'Running {len(events)} `{name}` events')
for method in events:
if pass_item:
result = method(item, **kwargs)
else:

View File

@@ -97,6 +97,8 @@ class TestPluginCollection(unittest.TestCase):
self.assertEqual(
collection.events,
{
'startup': [],
'shutdown': [],
'serve': [],
'config': [],
'pre_build': [plugin.on_pre_build],

View File

@@ -7,6 +7,7 @@ watchdog==2.0.0
ghp-import==1.0
pyyaml_env_tag==0.1
importlib_metadata==4.3; python_version < "3.10"
typing_extensions==3.10; python_version < "3.8"
packaging==20.5
mergedeep==1.3.4
babel==2.9.0

View File

@@ -7,6 +7,7 @@ watchdog>=2.0.0
ghp-import>=1.0
pyyaml_env_tag>=0.1
importlib_metadata>=4.3; python_version < "3.10"
typing_extensions>=3.10; python_version < "3.8"
packaging>=20.5
mergedeep>=1.3.4
babel>=2.9.0

View File

@@ -72,6 +72,7 @@ setup(
'ghp-import>=1.0',
'pyyaml_env_tag>=0.1',
'importlib_metadata>=4.3; python_version < "3.10"',
'typing_extensions>=3.10; python_version < "3.8"',
'packaging>=20.5',
'mergedeep>=1.3.4'
],