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"),