diff --git a/crates/zensical/src/compat/mkdocs/plugin/mkdocstrings.rs b/crates/zensical/src/compat/mkdocs/plugin/mkdocstrings.rs index 5b8be24..3b13cb2 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/mkdocstrings.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/mkdocstrings.rs @@ -31,7 +31,7 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use std::fs; use std::hash::{DefaultHasher, Hash, Hasher}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use zrx::id::Id; use zrx::stream::Signal; @@ -85,12 +85,27 @@ struct Cached { /// Mkdocstrings compatibility pipeline. #[derive(Clone, Debug)] pub struct Mkdocstrings { + /// Hash of the project configuration, also used by the Markdown cache. + config_hash: u64, /// Cache directory shared with the Python compatibility layer. cache: PathBuf, /// Backlink cache, created only when collection is enabled. backlink_cache: Option, /// Site output directory. output: OutputRoot, + /// Path to an `objects.inv` file supplied by the user in the docs directory. + source_inventory: PathBuf, +} + +// ---------------------------------------------------------------------------- + +/// Saved result of checking whether any handler enables `objects.inv` by default. +#[derive(Debug, Serialize, Deserialize)] +struct InventoryState { + /// Hash of the project configuration when this result was saved. + config_hash: u64, + /// Whether any handler used so far enables `objects.inv` by default. + auto_enabled: bool, } // ---------------------------------------------------------------------------- @@ -174,9 +189,11 @@ impl Mkdocstrings { config_hash: config.hash, }); Self { + config_hash: config.hash, cache, backlink_cache, output: config.output_root().clone(), + source_inventory: config.docs_root().as_path().join("objects.inv"), } } @@ -186,21 +203,53 @@ impl Mkdocstrings { let _ = dependencies.navigation.map(move |_: &Navigation| { let cache_path = pipeline.cache.join("objects.inv"); let cached = fs::read(&cache_path).ok(); + let state_path = pipeline.cache.join("mkdocstrings.json"); + let previous = fs::read(&state_path).ok().and_then(|data| { + serde_json::from_slice::(&data).ok() + }); + let cached_auto_enabled = previous.map_or_else( + // Older versions did not save whether handlers enabled the + // inventory. They always wrote `objects.inv`, so keep doing + // that when using an old cache. + || cached.as_ref().is_some_and(|data| !data.is_empty()), + |state| { + state.config_hash == pipeline.config_hash + && state.auto_enabled + }, + ); - let data = Python::attach(|py| { + let (data, enabled, auto_enabled) = Python::attach(|py| { let module = py.import("zensical.compat.mkdocstrings")?; - module + let data = module .call_method1("get_inventory", (cached,))? - .extract::>() + .extract::>()?; + let (enabled, auto_enabled) = module + .call_method1( + "get_inventory_policy", + (cached_auto_enabled,), + )? + .extract::<(bool, bool)>()?; + Ok::<_, pyo3::PyErr>((data, enabled, auto_enabled)) })?; let path = pipeline.output.join( &"objects.inv".parse::().expect("static site path"), ); - fs::create_dir_all(path.parent().expect("invariant"))?; - fs::write(path, &data)?; + publish( + &path, + &data, + enabled, + pipeline.source_inventory.is_file(), + )?; + // Keep the inventory in the cache even when the public file + // is disabled, so later builds can still use it. fs::create_dir_all(&pipeline.cache)?; fs::write(&cache_path, &data)?; + let state = InventoryState { + config_hash: pipeline.config_hash, + auto_enabled, + }; + fs::write(state_path, serde_json::to_vec(&state)?)?; Ok::<_, anyhow::Error>(()) }); } @@ -293,6 +342,23 @@ impl Mkdocstrings { } } +/// Write `objects.inv` when enabled. +/// +/// When disabled, remove the generated file unless the docs directory contains +/// its own `objects.inv`. +fn publish( + path: &Path, data: &[u8], enabled: bool, has_source: bool, +) -> std::io::Result<()> { + if enabled { + fs::create_dir_all(path.parent().expect("inventory has a parent"))?; + fs::write(path, data) + } else if !has_source && path.is_file() { + fs::remove_file(path) + } else { + Ok(()) + } +} + // ---------------------------------------------------------------------------- // Tests // ---------------------------------------------------------------------------- @@ -300,10 +366,11 @@ impl Mkdocstrings { #[cfg(test)] mod tests { use std::cell::Cell; + use std::fs; use tempfile::tempdir; - use super::BacklinkCache; + use super::{publish, BacklinkCache}; #[test] fn backlink_pages_are_reused_without_computing_again() { @@ -363,4 +430,26 @@ mod tests { assert_eq!(first, "first"); assert_eq!(second, "second"); } + + #[test] + fn retracts_generated_inventory_and_can_publish_again() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("site/objects.inv"); + publish(&path, b"old inventory", true, false).unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"old inventory"); + publish(&path, b"new inventory", false, false).unwrap(); + assert!(!path.exists()); + publish(&path, b"new inventory", false, false).unwrap(); + publish(&path, b"new inventory", true, false).unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"new inventory"); + } + + #[test] + fn disabling_export_preserves_a_documentation_resource() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("objects.inv"); + fs::write(&path, b"user-provided inventory").unwrap(); + publish(&path, b"generated", false, true).unwrap(); + assert_eq!(fs::read(path).unwrap(), b"user-provided inventory"); + } } diff --git a/pyproject.toml b/pyproject.toml index d3c699a..8f00a1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,7 @@ dev = [ "beautifulsoup4>=4.14.3", "lxml>=6.1.0", "maturin>=1.10.2", + "mkdocstrings[python]>=1.0.6", "pandas>=2.3.3", "pytest>=9.0.3", "ruff>=0.12.8", diff --git a/python/tests/integration/test_mkdocstrings.py b/python/tests/integration/test_mkdocstrings.py index e7c89cf..82fbabd 100644 --- a/python/tests/integration/test_mkdocstrings.py +++ b/python/tests/integration/test_mkdocstrings.py @@ -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 " 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" diff --git a/python/tests/unit/test_config.py b/python/tests/unit/test_config.py index 3f701a5..6961598 100644 --- a/python/tests/unit/test_config.py +++ b/python/tests/unit/test_config.py @@ -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, diff --git a/python/tests/unit/test_mkdocstrings.py b/python/tests/unit/test_mkdocstrings.py new file mode 100644 index 0000000..d845a36 --- /dev/null +++ b/python/tests/unit/test_mkdocstrings.py @@ -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) diff --git a/python/tests/unit/test_plugin_config.py b/python/tests/unit/test_plugin_config.py index 48b6754..5fccd2b 100644 --- a/python/tests/unit/test_plugin_config.py +++ b/python/tests/unit/test_plugin_config.py @@ -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"), diff --git a/python/zensical/compat/mkdocstrings.py b/python/zensical/compat/mkdocstrings.py index 657de2f..41ca0cc 100644 --- a/python/zensical/compat/mkdocstrings.py +++ b/python/zensical/compat/mkdocstrings.py @@ -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 diff --git a/python/zensical/config.py b/python/zensical/config.py index 1be5276..7bc2bd8 100644 --- a/python/zensical/config.py +++ b/python/zensical/config.py @@ -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