Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,8 @@ __marimo__/

# Sandbox / scratch scripts
sandbox/

# Generated MkDocs site and auto-generated architecture diagrams
site/
docs/architecture/

19 changes: 18 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
3 changes: 3 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ nav:
- Scene: reference/domains/scene.md
- Timer: reference/domains/timer.md

hooks:
- tools/mkdocs_hooks.py

plugins:
- search
- mkdocstrings:
Expand Down
142 changes: 142 additions & 0 deletions tests/test_mkdocs_hooks.py
Original file line number Diff line number Diff line change
@@ -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 "<svg" in content
assert "placeholder" in content.lower()


def test_placeholders_written_when_generator_fails(
hook: ModuleType, monkeypatch: pytest.MonkeyPatch
) -> 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
115 changes: 115 additions & 0 deletions tools/mkdocs_hooks.py
Original file line number Diff line number Diff line change
@@ -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 = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<svg xmlns="http://www.w3.org/2000/svg" width="480" height="80" '
'viewBox="0 0 480 80">\n'
' <rect width="100%" height="100%" fill="#f5f5f5" stroke="#999"/>\n'
' <text x="50%" y="50%" font-family="sans-serif" font-size="14" '
'fill="#555" text-anchor="middle" dominant-baseline="middle">'
"Architecture diagram placeholder - install Graphviz to regenerate"
"</text>\n"
"</svg>\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()
Loading