Skip to content

Commit cf0af05

Browse files
authored
feat: Allow extensions to add templates
An extension here is simply a Python package that defines an entry-point for a specific handler. For example, an extension can add templates to the Python handler thanks to this entry-point: ```toml [project.entry-points."mkdocstrings.python.templates"] extension-name = "extension_package:get_templates_path" ``` This entry-point assumes that the extension provides a `get_templates_path` function directly under the `extension_package` package. This function doesn't accept any argument and returns the path to a directory containing templates. The directory must contain one subfolder for each supported theme, for example: ``` templates/ material/ readthedocs/ mkdocs/ ``` mkdocstrings will add the folders corresponding to the user-selected theme, and to the handler's defined fallback theme, as usual, to the Jinja loader. The names of the extension templates must not overlap with the handler's original templates. The extension is then responsible, in collaboration with its target handler, for mutating the collected data in order to instruct the handler to use one of the extension template when rendering particular objects. For example, the Python handler will look for a `template` attribute on objects, and use it to render the object. This `template` attribute will be set by Griffe extensions (Griffe is the tool used by the Python handler to collect data). PR #569: #569
1 parent c9f99bc commit cf0af05

4 files changed

Lines changed: 167 additions & 2 deletions

File tree

docs/usage/handlers.md

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ is written for inspiration.
192192
### Templates
193193

194194
Your handler's implementation should normally be backed by templates, which go
195-
to the directory `mkdocstrings_handlers/custom_handler/templates/some_theme`.
195+
to the directory `mkdocstrings_handlers/custom_handler/templates/some_theme`
196196
(`custom_handler` here should be replaced with the actual name of your handler,
197197
and `some_theme` should be the name of an actual MkDocs theme that you support,
198198
e.g. `material`).
@@ -258,3 +258,79 @@ plugins:
258258
some_config_option: "b"
259259
other_config_option: 1
260260
```
261+
262+
## Handler extensions
263+
264+
*mkdocstrings* provides a way for third-party packages
265+
to extend or alter the behavior of handlers.
266+
For example, an extension of the Python handler
267+
could add specific support for another Python library.
268+
269+
NOTE: This feature is intended for developers.
270+
If you are a user and want to customize how objects are rendered,
271+
see [Theming / Customization](../theming/#customization).
272+
273+
Such extensions can register additional template folders
274+
that will be used when rendering collected data.
275+
Extensions are responsible for synchronizing
276+
with the handler itself so that it uses the additional templates.
277+
278+
An extension is a Python package
279+
that defines an entry-point for a specific handler:
280+
281+
```toml title="pyproject.toml"
282+
[project.entry-points."mkdocstrings.python.templates"] # (1)!
283+
extension-name = "extension_package:get_templates_path" # (2)!
284+
```
285+
286+
1. Replace `python` by the name of the handler you want to add templates to.
287+
1. Replace `extension-name` by any name you want,
288+
and replace `extension_package:get_templates_path`
289+
by the actual module path and function name in your package.
290+
291+
This entry-point assumes that the extension provides
292+
a `get_templates_path` function directly under the `extension_package` package:
293+
294+
```tree
295+
pyproject.toml
296+
extension_package/
297+
__init__.py
298+
templates/
299+
```
300+
301+
```python title="extension_package/__init__.py"
302+
from pathlib import Path
303+
304+
305+
def get_templates_path() -> Path:
306+
return Path(__file__).parent / "templates"
307+
```
308+
309+
This function doesn't accept any argument
310+
and returns the path ([`pathlib.Path`][] or [`str`][])
311+
to a directory containing templates.
312+
The directory must contain one subfolder
313+
for each supported theme, even if empty
314+
(see "fallback theme" in [custom handlers templates](#templates_1)).
315+
For example:
316+
317+
```tree
318+
pyproject.toml
319+
extension_package/
320+
__init__.py
321+
templates/
322+
material/
323+
readthedocs/
324+
mkdocs/
325+
```
326+
327+
*mkdocstrings* will add the folders corresponding to the user-selected theme,
328+
and to the handler's defined fallback theme, as usual.
329+
330+
The names of the extension templates
331+
must not overlap with the handler's original templates.
332+
333+
The extension is then responsible, in collaboration with its target handler,
334+
for mutating the collected data in order to instruct the handler
335+
to use one of the extension template when rendering particular objects.
336+
See each handler's docs to see if they support extensions, and how.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ dependencies = [
3535
"mkdocs>=1.2",
3636
"mkdocs-autorefs>=0.3.1",
3737
"pymdown-extensions>=6.3",
38+
"importlib-metadata>=4.6; python_version < '3.10'",
3839
"typing-extensions>=4.1; python_version < '3.10'",
3940
]
4041

src/mkdocstrings/handlers/base.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from __future__ import annotations
1212

1313
import importlib
14+
import sys
1415
import warnings
1516
from contextlib import suppress
1617
from pathlib import Path
@@ -31,6 +32,12 @@
3132
from mkdocstrings.inventory import Inventory
3233
from mkdocstrings.loggers import get_template_logger
3334

35+
# TODO: remove once support for Python 3.9 is dropped
36+
if sys.version_info < (3, 10):
37+
from importlib_metadata import entry_points
38+
else:
39+
from importlib.metadata import entry_points
40+
3441
CollectorItem = Any
3542

3643

@@ -93,12 +100,23 @@ def __init__(self, handler: str, theme: str, custom_templates: str | None = None
93100
self._theme = theme
94101
self._custom_templates = custom_templates
95102

103+
# add selected theme templates
96104
themes_dir = self.get_templates_dir(handler)
97105
paths.append(themes_dir / theme)
98106

107+
# add extended theme templates
108+
extended_templates_dirs = self.get_extended_templates_dirs(handler)
109+
for templates_dir in extended_templates_dirs:
110+
paths.append(templates_dir / theme)
111+
112+
# add fallback theme templates
99113
if self.fallback_theme and self.fallback_theme != theme:
100114
paths.append(themes_dir / self.fallback_theme)
101115

116+
# add fallback theme of extended templates
117+
for templates_dir in extended_templates_dirs:
118+
paths.append(templates_dir / self.fallback_theme)
119+
102120
for path in paths:
103121
css_path = path / "style.css"
104122
if css_path.is_file():
@@ -179,6 +197,18 @@ def get_templates_dir(self, handler: str) -> Path:
179197

180198
raise FileNotFoundError(f"Can't find 'templates' folder for handler '{handler}'")
181199

200+
def get_extended_templates_dirs(self, handler: str) -> list[Path]:
201+
"""Load template extensions for the given handler, return their templates directories.
202+
203+
Arguments:
204+
handler: The name of the handler to get the extended templates directory of.
205+
206+
Returns:
207+
The extensions templates directories.
208+
"""
209+
discovered_extensions = entry_points(group=f"mkdocstrings.{handler}.templates")
210+
return [extension.load()() for extension in discovered_extensions]
211+
182212
def get_anchors(self, data: CollectorItem) -> tuple[str, ...] | set[str]:
183213
"""Return the possible identifiers (HTML anchors) for a collected item.
184214

tests/test_handlers.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,18 @@
22

33
from __future__ import annotations
44

5+
from contextlib import suppress
6+
from pathlib import Path
7+
from typing import TYPE_CHECKING
8+
59
import pytest
10+
from jinja2.exceptions import TemplateNotFound
611
from markdown import Markdown
712

8-
from mkdocstrings.handlers.base import Highlighter
13+
from mkdocstrings.handlers.base import BaseRenderer, Highlighter
14+
15+
if TYPE_CHECKING:
16+
from mkdocstrings.plugin import MkdocstringsPlugin
917

1018

1119
@pytest.mark.parametrize("extension_name", ["codehilite", "pymdownx.highlight"])
@@ -43,3 +51,53 @@ def test_highlighter_basic(extension_name: str | None, inline: bool) -> None:
4351
actual = hl.highlight("import foo", language="python", inline=inline)
4452
assert "import" in actual
4553
assert "import foo" not in actual # Highlighting has split it up.
54+
55+
56+
@pytest.fixture(name="extended_templates")
57+
def fixture_extended_templates(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: # noqa: D103
58+
monkeypatch.setattr(BaseRenderer, "get_extended_templates_dirs", lambda self, handler: [tmp_path])
59+
return tmp_path
60+
61+
62+
def test_extended_templates(extended_templates: Path, plugin: MkdocstringsPlugin) -> None:
63+
"""Test the extended templates functionality.
64+
65+
Parameters:
66+
extended_templates: Temporary folder.
67+
plugin: Instance of our plugin.
68+
"""
69+
handler = plugin._handlers.get_handler("python") # type: ignore[union-attr]
70+
71+
# assert mocked method added temp path to loader
72+
search_paths = handler.env.loader.searchpath # type: ignore[union-attr]
73+
assert any(str(extended_templates) in path for path in search_paths)
74+
75+
# assert "new" template is not found
76+
for path in search_paths:
77+
# TODO: use missing_ok=True once support for Python 3.7 is dropped
78+
with suppress(FileNotFoundError):
79+
Path(path).joinpath("new.html").unlink()
80+
with pytest.raises(expected_exception=TemplateNotFound):
81+
handler.env.get_template("new.html")
82+
83+
# check precedence: base theme, base fallback theme, extended theme, extended fallback theme
84+
# start with last one and go back up
85+
handler.env.cache = None
86+
87+
extended_fallback_theme = extended_templates.joinpath(handler.fallback_theme)
88+
extended_fallback_theme.mkdir()
89+
extended_fallback_theme.joinpath("new.html").write_text("extended fallback new")
90+
assert handler.env.get_template("new.html").render() == "extended fallback new"
91+
92+
extended_theme = extended_templates.joinpath("mkdocs")
93+
extended_theme.mkdir()
94+
extended_theme.joinpath("new.html").write_text("extended new")
95+
assert handler.env.get_template("new.html").render() == "extended new"
96+
97+
base_fallback_theme = Path(search_paths[1])
98+
base_fallback_theme.joinpath("new.html").write_text("base fallback new")
99+
assert handler.env.get_template("new.html").render() == "base fallback new"
100+
101+
base_theme = Path(search_paths[0])
102+
base_theme.joinpath("new.html").write_text("base new")
103+
assert handler.env.get_template("new.html").render() == "base new"

0 commit comments

Comments
 (0)