# 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. """Integration tests for MkDocs-compatible mkdocstrings artifacts.""" 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 from zensical.compat import mkdocstrings if TYPE_CHECKING: from pathlib import Path _BUILD_OPTIONS: dict[str, Any] = {"clean": False, "strict": False} def test_object_inventory_is_restored_from_cache(tmp_path: Path) -> None: """The cached inventory is published when no handler updates it.""" docs = tmp_path / "docs" docs.mkdir() (docs / "index.md").write_text("# Home\n", encoding="utf-8") config = tmp_path / "zensical.toml" config.write_text('[project]\nsite_name = "Inventory"\n', encoding="utf-8") cache = tmp_path / ".cache" cache.mkdir() inventory = b"cached object inventory" (cache / "objects.inv").write_bytes(inventory) zensical.build(str(config), _BUILD_OPTIONS) assert (tmp_path / "site" / "objects.inv").read_bytes() == inventory assert (cache / "objects.inv").read_bytes() == inventory @pytest.mark.parametrize("enabled", [False, True]) def test_autorefs_without_backlinks_does_not_load_handlers( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, enabled: bool ) -> None: """Ordinary autorefs must work without the backlink dependencies.""" docs = tmp_path / "docs" docs.mkdir() (docs / "index.md").write_text( '# Home\n\nHome\n', encoding="utf-8", ) config = tmp_path / "zensical.toml" config.write_text( '[project]\nsite_name = "No backlinks"\n' '[project.markdown_extensions."zensical.extensions.autorefs"]\n' f"enabled = {str(enabled).lower()}\n", encoding="utf-8", ) original_import = builtins.__import__ def guarded_import(name: str, *args: Any, **kwargs: Any) -> Any: if name in {"mkdocstrings", "mkdocs_autorefs"}: raise AssertionError( f"Backlinks are disabled: unexpected import of {name}" ) return original_import(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", guarded_import) # Check both a fresh build and reuse of the cached Markdown and templates. for _ in range(2): zensical.build(str(config), _BUILD_OPTIONS) content = (tmp_path / "site" / "index.html").read_text(encoding="utf-8") if enabled: assert ( 'Home' in content ) else: assert 'Home' in content assert "zensical:autoref" not in content assert not (tmp_path / ".cache" / "mkdocstrings").exists() @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") docs = tmp_path / "docs" docs.mkdir() overrides = tmp_path / "overrides" overrides.mkdir() # The final HTML pass must resolve template references alongside the # backlink placeholders produced by the handler in the page content. (overrides / "main.html").write_text( "{{ page.content }}" "Template reference", encoding="utf-8", ) (tmp_path / "sample.py").write_text( 'class Target:\n """A documented target."""\n', encoding="utf-8" ) (docs / "api.md").write_text( "# API\n\n::: sample.Target\n", encoding="utf-8" ) if blog: posts = docs / "blog" / "posts" posts.mkdir(parents=True) (docs / "blog" / "index.md").write_text("# Journal\n", encoding="utf-8") (overrides / "blog-post.html").write_text( "{{ page.content }}", encoding="utf-8" ) guide = posts / "guide.md" prefix = "---\ndate: 2026-09-03\n---\n" guide_url = "../blog/2026/09/03/guide/" else: guide = docs / "guide.md" prefix = "" guide_url = "../guide/" guide.write_text( prefix + "# Guide\n\n## Example\n\n[Target][sample.Target]\n", encoding="utf-8", ) handler_options: dict[str, Any] = { "show_root_heading": True, "show_source": False, } if backlinks is not None: handler_options["backlinks"] = backlinks plugins: dict[str, dict[str, Any]] = { "mkdocstrings": { "enable_inventory": enable_inventory, "handlers": { "python": {"paths": ["."], "options": handler_options} }, }, } if blog: plugins["blog"] = { "authors": False, "archive": False, "categories": False, } config = tmp_path / "mkdocs.yml" config.write_text( safe_dump( { "site_name": "Backlinks", "theme": {"custom_dir": "overrides"}, "plugins": plugins, } ), encoding="utf-8", ) def unexpected_backlink_work(*_args: Any, **_kwargs: Any) -> Any: raise AssertionError("This build must not load or render backlinks") # Omitted and false options must never cross the backlink Python bridge. if not backlinks: monkeypatch.setattr( mkdocstrings, "get_backlink_aliases", unexpected_backlink_work ) monkeypatch.setattr( mkdocstrings, "render_backlinks", unexpected_backlink_work ) 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 "Template reference' in first assert "zensical:autoref" not in first if backlinks: assert 'class="doc doc-backlinks"' in first assert f"{guide_url}#example" in first else: assert 'class="doc doc-backlinks"' not in first assert not (tmp_path / ".cache" / "mkdocstrings").exists() # A warm build must reuse rendered backlinks without initializing handlers. with monkeypatch.context() as warm: warm.setattr(mkdocstrings, "_get_handlers", unexpected_backlink_work) 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( prefix + "# Guide\n\n## Updated\n\n[Target][sample.Target]\n", encoding="utf-8", ) 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 else: assert updated == first assert not (tmp_path / ".cache" / "mkdocstrings").exists() @pytest.mark.parametrize("in_template", [False, True]) def test_backlinks_inside_autoref_titles( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, in_template: bool ) -> None: """Nested backlinks survive Markdown caching and template parsing.""" pytest.importorskip("mkdocstrings_handlers.python") docs = tmp_path / "docs" docs.mkdir() overrides = tmp_path / "overrides" overrides.mkdir() nested = ( 'Template ' '' "" ) (overrides / "main.html").write_text( "{{ page.content }}" + (nested if in_template else ""), encoding="utf-8", ) (tmp_path / "sample.py").write_text( 'class Target:\n """A documented target."""\n', encoding="utf-8" ) (docs / "api.md").write_text( "# API\n\n::: sample.Target\n\n" + ("" if in_template else nested), encoding="utf-8", ) (docs / "guide.md").write_text( "# Guide\n\n[Target][sample.Target]\n", encoding="utf-8" ) config = tmp_path / "mkdocs.yml" config.write_text( safe_dump( { "site_name": "Nested replacements", "theme": {"custom_dir": "overrides"}, "plugins": { "mkdocstrings": { "handlers": { "python": { "paths": ["."], "options": { "backlinks": "flat", "show_root_heading": True, }, }, }, }, }, } ), encoding="utf-8", ) # Use an inline fragment so the nested result is valid content for a link. fragment = 'Linked from Guide' monkeypatch.setattr(mkdocstrings, "render_backlinks", lambda *_: fragment) zensical.build(str(config), _BUILD_OPTIONS) api = tmp_path / "site" / "api" / "index.html" output = api.read_text(encoding="utf-8") assert f">Template {fragment}" in output assert " Any: raise AssertionError("The warm build must use cached backlinks") # Cached fragments must also be retained inside a reference title. monkeypatch.setattr(mkdocstrings, "_get_handlers", unexpected_backlink_work) monkeypatch.setattr( mkdocstrings, "render_backlinks", unexpected_backlink_work ) 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"