feature: support mkdocstrings' enable_inventory setting

Signed-off-by: Timothée Mazzucotelli <dev@pawamoy.fr>
This commit is contained in:
Timothée Mazzucotelli committed 2026-09-24 17:50:05 +02:00
1 parent 0223fc9b64
commit 25b95e9eaf
8 files changed
+445 -26

No files matched your search

+130
View File
@@ -26,9 +26,12 @@
from __future__ import annotations
import builtins
from io import BytesIO
from typing import TYPE_CHECKING, Any
import pytest
from mkdocstrings import Inventory
from mkdocstrings_handlers.python import PythonHandler
from yaml import safe_dump
import zensical
@@ -107,11 +110,13 @@ def test_autorefs_without_backlinks_does_not_load_handlers(
@pytest.mark.parametrize("backlinks", [None, False, "flat", "tree"])
@pytest.mark.parametrize("blog", [False, True])
@pytest.mark.parametrize("enable_inventory", [False, True, None])
def test_backlinks_across_cold_cached_and_changed_builds(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
backlinks: str | bool | None,
blog: bool,
enable_inventory: bool | None,
) -> None:
"""Backlinks are opt-in and refresh when only a referring page changes."""
pytest.importorskip("mkdocstrings_handlers.python")
@@ -159,6 +164,7 @@ def test_backlinks_across_cold_cached_and_changed_builds(
handler_options["backlinks"] = backlinks
plugins: dict[str, dict[str, Any]] = {
"mkdocstrings": {
"enable_inventory": enable_inventory,
"handlers": {
"python": {"paths": ["."], "options": handler_options}
},
@@ -197,6 +203,8 @@ def test_backlinks_across_cold_cached_and_changed_builds(
zensical.build(str(config), _BUILD_OPTIONS)
api = tmp_path / "site" / "api" / "index.html"
inventory = tmp_path / "site" / "objects.inv"
assert inventory.exists() is (enable_inventory is not False)
first = api.read_text(encoding="utf-8")
assert "<backlinks" not in first
assert "<autoref" not in first
@@ -215,6 +223,7 @@ def test_backlinks_across_cold_cached_and_changed_builds(
zensical.build(str(config), _BUILD_OPTIONS)
assert api.read_text(encoding="utf-8") == first
assert inventory.exists() is (enable_inventory is not False)
# Keep the API page cached while changing the backlink title and anchor.
guide.write_text(
@@ -224,6 +233,7 @@ def test_backlinks_across_cold_cached_and_changed_builds(
zensical.build(str(config), _BUILD_OPTIONS)
updated = api.read_text(encoding="utf-8")
assert inventory.exists() is (enable_inventory is not False)
if backlinks:
assert f"{guide_url}#updated" in updated
assert f"{guide_url}#example" not in updated
@@ -311,3 +321,123 @@ def test_backlinks_inside_autoref_titles(
zensical.build(str(config), _BUILD_OPTIONS)
assert api.read_text(encoding="utf-8") == output
def _write_project(root: Path, options: dict[str, Any]) -> Path:
docs = root / "docs"
docs.mkdir(exist_ok=True)
if not (docs / "index.md").exists():
(docs / "index.md").write_text(
"# API\n\n::: sample_api.greet\n\n"
"[Greeting function][sample_api.greet]\n",
encoding="utf-8",
)
(docs / "other.md").write_text("# Other\n", encoding="utf-8")
(root / "sample_api.py").write_text(
"def greet(name: str) -> str:\n"
' """Return a greeting."""\n'
' return f"Hello {name}"\n',
encoding="utf-8",
)
config = root / "mkdocs.yml"
config.write_text(
safe_dump(
{
"site_name": "Inventory",
"plugins": {
"mkdocstrings": {
"handlers": {"python": {"paths": [str(root)]}},
**options,
}
},
}
),
encoding="utf-8",
)
return config
@pytest.mark.parametrize("setting", [True, False, None])
def test_inventory_setting_keeps_api_rendering_and_cross_references(
tmp_path: Path, setting: bool | None
) -> None:
config = _write_project(tmp_path, {"enable_inventory": setting})
zensical.build(str(config), _BUILD_OPTIONS)
html = (tmp_path / "site" / "index.html").read_text(encoding="utf-8")
assert "Return a greeting." in html
assert 'class="autorefs autorefs-internal"' in html
assert 'href="#sample_api.greet"' in html
exported = tmp_path / "site" / "objects.inv"
assert exported.exists() is (setting is not False)
data = (tmp_path / ".cache" / "objects.inv").read_bytes()
assert "sample_api.greet" in Inventory.parse_sphinx(BytesIO(data))
def test_inventory_can_be_disabled_and_reenabled_between_builds(
tmp_path: Path,
) -> None:
for enabled in [True, False, True]:
config = _write_project(tmp_path, {"enable_inventory": enabled})
zensical.build(str(config), _BUILD_OPTIONS)
exported = tmp_path / "site" / "objects.inv"
assert exported.exists() is enabled
if enabled:
assert (
exported.read_bytes()
== (tmp_path / ".cache" / "objects.inv").read_bytes()
)
def test_automatic_inventory_survives_cached_api_pages(tmp_path: Path) -> None:
config = _write_project(tmp_path, {})
zensical.build(str(config), _BUILD_OPTIONS)
expected = (tmp_path / "site" / "objects.inv").read_bytes()
zensical.build(str(config), _BUILD_OPTIONS)
assert mkdocstrings.HANDLERS is None
assert (tmp_path / "site" / "objects.inv").read_bytes() == expected
(tmp_path / "docs" / "other.md").write_text(
"# Other\n\nChanged\n", encoding="utf-8"
)
zensical.build(str(config), _BUILD_OPTIONS)
assert mkdocstrings.HANDLERS is not None
assert not mkdocstrings.HANDLERS.inventory
assert (tmp_path / "site" / "objects.inv").read_bytes() == expected
def test_automatic_inventory_resets_handler_preference_with_configuration(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config = _write_project(tmp_path, {})
zensical.build(str(config), _BUILD_OPTIONS)
assert (tmp_path / "site" / "objects.inv").exists()
monkeypatch.setattr(PythonHandler, "enable_inventory", False)
# Changing the config makes the next build render pages again and check
# whether the handlers enable `objects.inv`.
config = _write_project(tmp_path, {"enable_inventory": None})
zensical.build(str(config), _BUILD_OPTIONS)
assert not (tmp_path / "site" / "objects.inv").exists()
zensical.build(str(config), _BUILD_OPTIONS)
assert mkdocstrings.HANDLERS is None
assert not (tmp_path / "site" / "objects.inv").exists()
def test_disabled_plugin_preserves_static_inventory(tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n", encoding="utf-8")
(docs / "objects.inv").write_bytes(b"user-provided inventory")
config = tmp_path / "zensical.toml"
config.write_text(
'[project]\nsite_name = "Inventory"\n'
"[project.plugins.mkdocstrings]\n"
"enabled = false\nenable_inventory = false\n",
encoding="utf-8",
)
zensical.build(str(config), _BUILD_OPTIONS)
assert (
tmp_path / "site" / "objects.inv"
).read_bytes() == b"user-provided inventory"
+23 -1
View File
@@ -291,7 +291,7 @@ class TestPluginShimming:
"callouts": {"aliases": False, "breakless_lists": False},
"glightbox": {"slide_effect": "fade"},
"mike": {"javascript_dir": "scripts"},
"mkdocstrings": {"enable_inventory": False, "watch": ["src"]},
"mkdocstrings": {"watch": ["src"]},
"search": {"lang": ["en", "fr"]},
"material/tags": {"tags_file": "tags.md", "export_only": True},
}.items():
@@ -848,6 +848,28 @@ class TestPluginShimming:
autorefs = config["mdx_configs"][AutorefsExtension.name]
assert autorefs["record_backlinks"] is (backlinks is not False)
@pytest.mark.parametrize("value", [True, False, None])
def test_mkdocstrings_inventory_setting_forwarded_and_hashed(
self,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
value: bool | None,
) -> None:
monkeypatch.setattr("zensical.config.find_spec", lambda _name: True)
baseline = self._parse_yaml(tmp_path, plugins={"mkdocstrings": {}})
config_file = tmp_path / "mkdocs.yml"
config_file.write_text(
_minimal_yaml(plugins={"mkdocstrings": {"enable_inventory": value}})
)
configured = parse_config(str(config_file))
assert (
configured["mdx_configs"][MkdocstringsExtension.name][
"enable_inventory"
]
is value
)
assert configured["plugins_hash"] != baseline["plugins_hash"]
def test_mkdocstrings_not_installed_raises(
self,
monkeypatch: pytest.MonkeyPatch,
+113
View File
@@ -0,0 +1,113 @@
# Copyright (c) 2025-2026 Zensical and contributors
# SPDX-License-Identifier: MIT
# All contributions are certified under the DCO
# 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.
"""Test when mkdocstrings writes `objects.inv`, using fake handlers."""
from types import SimpleNamespace
import pytest
import zensical.config as config_module
from zensical.compat import mkdocstrings
@pytest.mark.parametrize(
("setting", "handlers", "cached", "expected", "automatic"),
[
(None, None, False, False, False),
(None, None, True, True, True),
(None, [], False, False, False),
(None, [False], False, False, False),
(None, [False, True], False, True, True),
(None, [False], True, True, True),
(True, [False], False, True, False),
(True, None, False, True, False),
(False, [True], False, False, True),
(False, None, True, False, True),
],
)
def test_inventory_policy_combines_settings_with_cached_handlers(
monkeypatch: pytest.MonkeyPatch,
setting: bool | None,
handlers: list[bool] | None,
cached: bool,
expected: bool,
automatic: bool,
) -> None:
monkeypatch.setattr(
config_module,
"_CONFIG",
{
"plugins": {
"mkdocstrings": {"config": {"enable_inventory": setting}}
}
},
)
monkeypatch.setattr(mkdocstrings, "_ENABLE_INVENTORY", setting)
monkeypatch.setattr(
mkdocstrings,
"HANDLERS",
(
None
if handlers is None
else SimpleNamespace(
seen_handlers=[
SimpleNamespace(enable_inventory=value)
for value in handlers
]
)
),
)
assert mkdocstrings.get_inventory_policy(cached) == (expected, automatic)
@pytest.mark.parametrize("as_extension", [False, True])
def test_disabled_mkdocstrings_does_not_export_cached_inventory(
monkeypatch: pytest.MonkeyPatch, as_extension: bool
) -> None:
options = {"enabled": False, "enable_inventory": True}
config = (
{"mdx_configs": {"zensical.extensions.mkdocstrings": options}}
if as_extension
else {"plugins": {"mkdocstrings": {"config": options}}}
)
monkeypatch.setattr(config_module, "_CONFIG", config)
monkeypatch.setattr(mkdocstrings, "HANDLERS", None)
assert mkdocstrings.get_inventory_policy(True) == (False, True)
def test_cached_inventory_uses_explicit_extension_options(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
config_module,
"_CONFIG",
{
"plugins": {"mkdocstrings": {"config": {"enable_inventory": True}}},
"mdx_configs": {
"zensical.extensions.mkdocstrings": {"enable_inventory": False}
},
},
)
monkeypatch.setattr(mkdocstrings, "HANDLERS", None)
assert mkdocstrings.get_inventory_policy(True) == (False, True)
+19 -1
View File
@@ -289,7 +289,6 @@ def test_rejects_invalid_blog_configuration(name: str, data: Any) -> None:
("glightbox", "shadow"),
("mike", "css_dir"),
("mike", "javascript_dir"),
("mkdocstrings", "enable_inventory"),
("mkdocstrings", "watch"),
("search", "fields"),
("search", "indexing"),
@@ -416,6 +415,7 @@ def test_normalizes_null_shim_configuration(name: str) -> None:
"mkdocstrings",
{
"enabled": False,
"enable_inventory": False,
"handlers": {"python": {"options": {}}},
"custom_templates": None,
"default_handler": "python",
@@ -480,6 +480,24 @@ def test_accepts_supported_shim_options(
assert plugins[name]["config"] == config
@pytest.mark.parametrize("plugin", ["mkdocstrings", "material/mkdocstrings"])
@pytest.mark.parametrize("value", [True, False, None])
def test_preserves_mkdocstrings_inventory_setting(
plugin: str, value: bool | None
) -> None:
data = {"enable_inventory": value}
assert _convert_plugins({plugin: data})["mkdocstrings"]["config"] == data
@pytest.mark.parametrize("value", [0, 1, "true", "auto", [], {}])
def test_rejects_invalid_mkdocstrings_inventory_setting(value: Any) -> None:
with pytest.raises(
ConfigurationError,
match="mkdocstrings enable_inventory must be a boolean or null",
):
_convert_plugins({"mkdocstrings": {"enable_inventory": value}})
@pytest.mark.parametrize("plugin", ["macros", "material/macros"])
@pytest.mark.parametrize(
("option", "value"),
+55 -15
View File
@@ -26,12 +26,12 @@ from __future__ import annotations
from dataclasses import dataclass
from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
from zensical.extensions.autorefs import get_autorefs_store
if TYPE_CHECKING:
from mkdocstrings import ( # ty:ignore[unresolved-import]
from mkdocstrings import (
Handlers,
MkdocstringsExtension,
)
@@ -43,6 +43,7 @@ if TYPE_CHECKING:
HANDLERS: Handlers | None = None
_ENABLE_INVENTORY: bool | None = None
# ----------------------------------------------------------------------------
@@ -87,16 +88,18 @@ def _get_handlers(
handlers: dict[str, Any] | None = None,
*,
custom_templates: str | None = None,
enable_inventory: bool | None = None,
default_handler: str = "python",
locale: str = "en",
config: dict[str, Any],
) -> Handlers:
"""Create or return the handlers shared by the current build."""
from mkdocstrings import ( # noqa: PLC0415 # ty:ignore[unresolved-import]
from mkdocstrings import ( # noqa: PLC0415
Handlers,
)
global HANDLERS # noqa: PLW0603
global HANDLERS, _ENABLE_INVENTORY # noqa: PLW0603
_ENABLE_INVENTORY = enable_inventory
if HANDLERS is None:
root_dir = Path(config["root_dir"])
config_file = root_dir / "zensical.toml"
@@ -135,7 +138,6 @@ def _ensure_handlers() -> Handlers | None:
return None
options = dict(options)
options.pop("enabled", None)
options.pop("enable_inventory", None)
return _get_handlers(**options, config=config)
@@ -157,13 +159,13 @@ def get_mkdocstrings_extension(
handlers: dict[str, Any] | None = None,
*,
custom_templates: str | None = None,
enable_inventory: bool = True, # noqa: ARG001
enable_inventory: bool | None = None,
default_handler: str = "python",
locale: str = "en",
config: dict[str, Any],
) -> MkdocstringsExtension:
"""Create the mkdocstrings Markdown extension."""
from mkdocstrings import ( # noqa: PLC0415 # ty:ignore[unresolved-import]
from mkdocstrings import ( # noqa: PLC0415
MkdocstringsExtension,
)
@@ -172,13 +174,15 @@ def get_mkdocstrings_extension(
handlers_instance = _get_handlers(
handlers,
custom_templates=custom_templates,
enable_inventory=enable_inventory,
default_handler=default_handler,
locale=locale,
config=config,
)
# Upstream annotates this as its full plugin; our store supplies the
# compatible anchor-registration interface used by the extension.
return MkdocstringsExtension(
handlers=handlers_instance,
autorefs=autorefs,
handlers=handlers_instance, autorefs=cast("Any", autorefs)
)
@@ -188,7 +192,7 @@ def get_inventory(cached: bytes | None) -> bytes:
return cached or b""
try:
from mkdocstrings import ( # noqa: PLC0415 # ty:ignore[unresolved-import]
from mkdocstrings import ( # noqa: PLC0415
Inventory,
)
except ImportError:
@@ -232,18 +236,22 @@ def render_backlinks(
from inspect import signature # noqa: PLC0415
from mkdocs_autorefs import ( # noqa: PLC0415 # ty:ignore[unresolved-import]
from mkdocs_autorefs import ( # noqa: PLC0415
Backlink,
)
# Rust already returns these paths sorted and deduplicated. Preserve that
# order instead of introducing Python's process-random set order again.
# Our crumbs supply the title and URL fields expected by the handler.
data = {
backlink_type: tuple(
Backlink(
tuple(
_SortableBacklinkCrumb(title=title, url=url)
for title, url in crumbs
cast(
"Any",
tuple(
_SortableBacklinkCrumb(title=title, url=url)
for title, url in crumbs
),
)
)
for crumbs in backlink_list
@@ -256,7 +264,39 @@ def render_backlinks(
return handler.render_backlinks(data, **kwargs)
def get_inventory_policy(cached_auto_enabled: bool) -> tuple[bool, bool]:
"""Decide whether to write `objects.inv`.
Return two booleans: whether to write the file, and whether any handler
enables it by default.
We remember the second value for cached pages, whose handlers may not run
again. We reuse it only if the project configuration has not changed.
"""
from zensical.config import get_config # noqa: PLC0415
config = get_config()
plugin = config.get("plugins", {}).get("mkdocstrings", {}).get("config", {})
options = config.get("mdx_configs", {}).get(
"zensical.extensions.mkdocstrings", plugin
)
auto_enabled = cached_auto_enabled or (
HANDLERS is not None
and any(handler.enable_inventory for handler in HANDLERS.seen_handlers)
)
setting = (
_ENABLE_INVENTORY
if HANDLERS is not None
else options.get("enable_inventory")
)
enabled = options.get("enabled", True) and (
auto_enabled if setting is None else setting
)
return enabled, auto_enabled
def reset() -> None:
"""Reset global state in-between rebuilds."""
global HANDLERS # noqa: PLW0603
global HANDLERS, _ENABLE_INVENTORY # noqa: PLW0603
HANDLERS = None
_ENABLE_INVENTORY = None
+8 -2
View File
@@ -145,8 +145,7 @@ _PLUGIN_UNSUPPORTED_OPTIONS = {
),
"minify": (),
"mkdocstrings": (
# TODO: Gate native objects.inv generation on this setting.
"enable_inventory",
# TODO: Merge the removed plugin watch setting into project.watch.
"watch",
),
"offline": (),
@@ -2055,11 +2054,18 @@ def _convert_plugins(value: Any, config: dict) -> dict:
string_options
| {
"enabled",
"enable_inventory",
"handlers",
"custom_templates",
},
)
_validate_boolean_options("mkdocstrings", mkdocstrings, ("enabled",))
if mkdocstrings.get("enable_inventory") is not None and not isinstance(
mkdocstrings["enable_inventory"], bool
):
raise ConfigurationError(
"mkdocstrings enable_inventory must be a boolean or null"
)
if (
"handlers" in mkdocstrings
and mkdocstrings["handlers"] is not None