diff --git a/.gitignore b/.gitignore index 48a0f41..3c29731 100644 --- a/.gitignore +++ b/.gitignore @@ -217,3 +217,8 @@ __marimo__/ # Sandbox / scratch scripts sandbox/ + +# Generated MkDocs site and auto-generated architecture diagrams +site/ +docs/architecture/ + diff --git a/docs/architecture.md b/docs/architecture.md index 60e20cd..7221823 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,7 +1,24 @@ # Architecture Auto-generated diagrams showing the structure of the HaClient codebase. -These are regenerated from source on every docs build. + +The diagrams below are regenerated from source on every documentation build +by an MkDocs ``on_pre_build`` hook (see ``tools/mkdocs_hooks.py``), which +invokes ``tools/generate_diagrams.py``. That script uses ``pyreverse`` to +extract class and package relationships and renders them to SVG via the +Graphviz ``dot`` binary. + +If Graphviz or ``pyreverse`` is not available locally, the hook writes +placeholder SVGs in their place so that ``mkdocs build --strict`` still +succeeds; install the ``docs`` extra and Graphviz to regenerate real +diagrams: + +```bash +pip install -e ".[docs]" +# macOS: brew install graphviz +# Ubuntu: sudo apt-get install graphviz +python tools/generate_diagrams.py +``` ## Class Diagram diff --git a/mkdocs.yml b/mkdocs.yml index 6be5026..2e55555 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,6 +50,9 @@ nav: - Scene: reference/domains/scene.md - Timer: reference/domains/timer.md +hooks: + - tools/mkdocs_hooks.py + plugins: - search - mkdocstrings: diff --git a/tests/test_mkdocs_hooks.py b/tests/test_mkdocs_hooks.py new file mode 100644 index 0000000..98d6fc7 --- /dev/null +++ b/tests/test_mkdocs_hooks.py @@ -0,0 +1,142 @@ +"""Tests for the MkDocs ``on_pre_build`` hook. + +The hook in ``tools/mkdocs_hooks.py`` generates architecture diagrams +before MkDocs validates inter-page links. These tests exercise the +hook's two code paths: successful generation and graceful fallback to +placeholder SVGs when the diagram toolchain is unavailable. + +The module under test lives outside the ``haclient`` package, so it is +loaded directly from its file path. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +HOOK_PATH = REPO_ROOT / "tools" / "mkdocs_hooks.py" + + +def _load_hook_module() -> ModuleType: + """Import ``tools/mkdocs_hooks.py`` as a standalone module.""" + spec = importlib.util.spec_from_file_location("haclient_mkdocs_hooks", HOOK_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def hook(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> ModuleType: + """Load the hook module with ``OUTPUT_DIR`` redirected to a temp dir.""" + module = _load_hook_module() + monkeypatch.setattr(module, "OUTPUT_DIR", tmp_path / "architecture") + return module + + +def test_placeholders_written_when_toolchain_missing( + hook: ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """``on_pre_build`` writes placeholder SVGs if prerequisites are missing.""" + monkeypatch.setattr(hook, "_has_prerequisites", lambda: False) + + hook.on_pre_build(config=None) + + for name in hook.EXPECTED_FILES: + target = hook.OUTPUT_DIR / name + assert target.exists(), f"expected placeholder {name}" + content = target.read_text(encoding="utf-8") + assert " None: + """If the generator fails at runtime, the hook still writes placeholders.""" + monkeypatch.setattr(hook, "_has_prerequisites", lambda: True) + monkeypatch.setattr(hook, "_run_generator", lambda: False) + + hook.on_pre_build(config=None) + + for name in hook.EXPECTED_FILES: + assert (hook.OUTPUT_DIR / name).exists() + + +def test_generator_invoked_when_toolchain_available( + hook: ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """When prerequisites exist the generator runs and no placeholders are needed.""" + calls: list[str] = [] + + def fake_run_generator() -> bool: + calls.append("ran") + return True + + def fail_placeholders() -> None: # pragma: no cover - must not be called + raise AssertionError("placeholders must not be written on success") + + monkeypatch.setattr(hook, "_has_prerequisites", lambda: True) + monkeypatch.setattr(hook, "_run_generator", fake_run_generator) + monkeypatch.setattr(hook, "_write_placeholders", fail_placeholders) + + hook.on_pre_build(config=None) + + assert calls == ["ran"] + + +def test_placeholder_writer_does_not_overwrite_existing(hook: ModuleType) -> None: + """``_write_placeholders`` must preserve existing files (real diagrams).""" + hook.OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + real_file = hook.OUTPUT_DIR / hook.EXPECTED_FILES[0] + real_file.write_text("REAL DIAGRAM", encoding="utf-8") + + hook._write_placeholders() + + assert real_file.read_text(encoding="utf-8") == "REAL DIAGRAM" + # The other expected file should be created as a placeholder. + other = hook.OUTPUT_DIR / hook.EXPECTED_FILES[1] + assert other.exists() + assert "placeholder" in other.read_text(encoding="utf-8").lower() + + +def test_has_prerequisites_false_when_dot_missing( + hook: ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """``_has_prerequisites`` returns False when ``dot`` is not on PATH.""" + monkeypatch.setattr(hook.shutil, "which", lambda _name: None) + + assert hook._has_prerequisites() is False + + +def test_has_prerequisites_false_when_pyreverse_missing( + hook: ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """``_has_prerequisites`` returns False when ``pylint.pyreverse`` is missing.""" + monkeypatch.setattr(hook.shutil, "which", lambda _name: "/usr/bin/dot") + + def fake_run(*_args: Any, **_kwargs: Any) -> None: + raise hook.subprocess.CalledProcessError(returncode=1, cmd=["python"]) + + monkeypatch.setattr(hook.subprocess, "run", fake_run) + + assert hook._has_prerequisites() is False + + +def test_run_generator_reports_failure(hook: ModuleType, monkeypatch: pytest.MonkeyPatch) -> None: + """``_run_generator`` returns False when the subprocess errors out.""" + + def fake_run(*_args: Any, **_kwargs: Any) -> None: + raise hook.subprocess.CalledProcessError(returncode=2, cmd=["script"]) + + monkeypatch.setattr(hook.subprocess, "run", fake_run) + + assert hook._run_generator() is False diff --git a/tools/mkdocs_hooks.py b/tools/mkdocs_hooks.py new file mode 100644 index 0000000..ab72d31 --- /dev/null +++ b/tools/mkdocs_hooks.py @@ -0,0 +1,115 @@ +"""MkDocs hooks for the HaClient documentation build. + +This module is wired into ``mkdocs.yml`` via the ``hooks`` setting so that +every documentation build regenerates the architecture diagrams from source +before MkDocs validates inter-page links. + +If the system prerequisites for diagram generation (``pyreverse`` and the +Graphviz ``dot`` binary) are missing, the hook writes minimal placeholder +SVG files instead of failing. This keeps ``mkdocs build --strict`` working +in environments that do not have Graphviz installed while still producing +real diagrams in CI and in normal development setups. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from mkdocs.config.defaults import MkDocsConfig + +REPO_ROOT = Path(__file__).resolve().parent.parent +OUTPUT_DIR = REPO_ROOT / "docs" / "architecture" +EXPECTED_FILES = ("classes.svg", "packages.svg") + +_PLACEHOLDER_SVG = ( + '\n' + '\n' + ' \n' + ' ' + "Architecture diagram placeholder - install Graphviz to regenerate" + "\n" + "\n" +) + + +def _write_placeholders() -> None: + """Write minimal placeholder SVGs for each expected diagram.""" + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + for name in EXPECTED_FILES: + target = OUTPUT_DIR / name + if not target.exists(): + target.write_text(_PLACEHOLDER_SVG, encoding="utf-8") + + +def _has_prerequisites() -> bool: + """Return ``True`` when both pyreverse and Graphviz ``dot`` are available.""" + if shutil.which("dot") is None: + return False + try: + subprocess.run( # noqa: S603 + [sys.executable, "-c", "import pylint.pyreverse.main"], + check=True, + capture_output=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return False + return True + + +def _run_generator() -> bool: + """Invoke ``tools/generate_diagrams.py`` as a subprocess. + + Returns + ------- + bool + ``True`` if generation succeeded, ``False`` otherwise. + """ + script = REPO_ROOT / "tools" / "generate_diagrams.py" + try: + subprocess.run( # noqa: S603 + [sys.executable, str(script)], + check=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + print(f"[mkdocs_hooks] diagram generation failed: {exc}", file=sys.stderr) + return False + return True + + +def on_pre_build(config: MkDocsConfig, **_: Any) -> None: + """Generate architecture diagrams before MkDocs validates the site. + + Parameters + ---------- + config : MkDocsConfig + The active MkDocs configuration (unused but required by the hook + signature). + **_ : Any + Additional keyword arguments supplied by MkDocs that are ignored + here. + + Side Effects + ------------ + Writes SVG files under ``docs/architecture/``. When the diagram + toolchain is unavailable, writes placeholder SVGs so that + ``mkdocs build --strict`` does not fail on missing image links. + """ + del config # unused + + if _has_prerequisites() and _run_generator(): + return + + print( + "[mkdocs_hooks] Graphviz/pyreverse unavailable; writing placeholder " + "architecture diagrams. Install the 'docs' extra and Graphviz to " + "regenerate real diagrams.", + file=sys.stderr, + ) + _write_placeholders()