Skip to content

Commit ec14feb

Browse files
authored
Merge pull request #96 from graphras-com/fix/89-docs-architecture-diagrams
docs: regenerate architecture diagrams on every mkdocs build (#89)
2 parents 7ad13a6 + 7e9d91f commit ec14feb

5 files changed

Lines changed: 283 additions & 1 deletion

File tree

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,8 @@ __marimo__/
217217

218218
# Sandbox / scratch scripts
219219
sandbox/
220+
221+
# Generated MkDocs site and auto-generated architecture diagrams
222+
site/
223+
docs/architecture/
224+

docs/architecture.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,24 @@
11
# Architecture
22

33
Auto-generated diagrams showing the structure of the HaClient codebase.
4-
These are regenerated from source on every docs build.
4+
5+
The diagrams below are regenerated from source on every documentation build
6+
by an MkDocs ``on_pre_build`` hook (see ``tools/mkdocs_hooks.py``), which
7+
invokes ``tools/generate_diagrams.py``. That script uses ``pyreverse`` to
8+
extract class and package relationships and renders them to SVG via the
9+
Graphviz ``dot`` binary.
10+
11+
If Graphviz or ``pyreverse`` is not available locally, the hook writes
12+
placeholder SVGs in their place so that ``mkdocs build --strict`` still
13+
succeeds; install the ``docs`` extra and Graphviz to regenerate real
14+
diagrams:
15+
16+
```bash
17+
pip install -e ".[docs]"
18+
# macOS: brew install graphviz
19+
# Ubuntu: sudo apt-get install graphviz
20+
python tools/generate_diagrams.py
21+
```
522

623
## Class Diagram
724

mkdocs.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ nav:
5050
- Scene: reference/domains/scene.md
5151
- Timer: reference/domains/timer.md
5252

53+
hooks:
54+
- tools/mkdocs_hooks.py
55+
5356
plugins:
5457
- search
5558
- mkdocstrings:

tests/test_mkdocs_hooks.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""Tests for the MkDocs ``on_pre_build`` hook.
2+
3+
The hook in ``tools/mkdocs_hooks.py`` generates architecture diagrams
4+
before MkDocs validates inter-page links. These tests exercise the
5+
hook's two code paths: successful generation and graceful fallback to
6+
placeholder SVGs when the diagram toolchain is unavailable.
7+
8+
The module under test lives outside the ``haclient`` package, so it is
9+
loaded directly from its file path.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import importlib.util
15+
import sys
16+
from pathlib import Path
17+
from types import ModuleType
18+
from typing import Any
19+
20+
import pytest
21+
22+
REPO_ROOT = Path(__file__).resolve().parent.parent
23+
HOOK_PATH = REPO_ROOT / "tools" / "mkdocs_hooks.py"
24+
25+
26+
def _load_hook_module() -> ModuleType:
27+
"""Import ``tools/mkdocs_hooks.py`` as a standalone module."""
28+
spec = importlib.util.spec_from_file_location("haclient_mkdocs_hooks", HOOK_PATH)
29+
assert spec is not None
30+
assert spec.loader is not None
31+
module = importlib.util.module_from_spec(spec)
32+
sys.modules[spec.name] = module
33+
spec.loader.exec_module(module)
34+
return module
35+
36+
37+
@pytest.fixture
38+
def hook(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> ModuleType:
39+
"""Load the hook module with ``OUTPUT_DIR`` redirected to a temp dir."""
40+
module = _load_hook_module()
41+
monkeypatch.setattr(module, "OUTPUT_DIR", tmp_path / "architecture")
42+
return module
43+
44+
45+
def test_placeholders_written_when_toolchain_missing(
46+
hook: ModuleType, monkeypatch: pytest.MonkeyPatch
47+
) -> None:
48+
"""``on_pre_build`` writes placeholder SVGs if prerequisites are missing."""
49+
monkeypatch.setattr(hook, "_has_prerequisites", lambda: False)
50+
51+
hook.on_pre_build(config=None)
52+
53+
for name in hook.EXPECTED_FILES:
54+
target = hook.OUTPUT_DIR / name
55+
assert target.exists(), f"expected placeholder {name}"
56+
content = target.read_text(encoding="utf-8")
57+
assert "<svg" in content
58+
assert "placeholder" in content.lower()
59+
60+
61+
def test_placeholders_written_when_generator_fails(
62+
hook: ModuleType, monkeypatch: pytest.MonkeyPatch
63+
) -> None:
64+
"""If the generator fails at runtime, the hook still writes placeholders."""
65+
monkeypatch.setattr(hook, "_has_prerequisites", lambda: True)
66+
monkeypatch.setattr(hook, "_run_generator", lambda: False)
67+
68+
hook.on_pre_build(config=None)
69+
70+
for name in hook.EXPECTED_FILES:
71+
assert (hook.OUTPUT_DIR / name).exists()
72+
73+
74+
def test_generator_invoked_when_toolchain_available(
75+
hook: ModuleType, monkeypatch: pytest.MonkeyPatch
76+
) -> None:
77+
"""When prerequisites exist the generator runs and no placeholders are needed."""
78+
calls: list[str] = []
79+
80+
def fake_run_generator() -> bool:
81+
calls.append("ran")
82+
return True
83+
84+
def fail_placeholders() -> None: # pragma: no cover - must not be called
85+
raise AssertionError("placeholders must not be written on success")
86+
87+
monkeypatch.setattr(hook, "_has_prerequisites", lambda: True)
88+
monkeypatch.setattr(hook, "_run_generator", fake_run_generator)
89+
monkeypatch.setattr(hook, "_write_placeholders", fail_placeholders)
90+
91+
hook.on_pre_build(config=None)
92+
93+
assert calls == ["ran"]
94+
95+
96+
def test_placeholder_writer_does_not_overwrite_existing(hook: ModuleType) -> None:
97+
"""``_write_placeholders`` must preserve existing files (real diagrams)."""
98+
hook.OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
99+
real_file = hook.OUTPUT_DIR / hook.EXPECTED_FILES[0]
100+
real_file.write_text("REAL DIAGRAM", encoding="utf-8")
101+
102+
hook._write_placeholders()
103+
104+
assert real_file.read_text(encoding="utf-8") == "REAL DIAGRAM"
105+
# The other expected file should be created as a placeholder.
106+
other = hook.OUTPUT_DIR / hook.EXPECTED_FILES[1]
107+
assert other.exists()
108+
assert "placeholder" in other.read_text(encoding="utf-8").lower()
109+
110+
111+
def test_has_prerequisites_false_when_dot_missing(
112+
hook: ModuleType, monkeypatch: pytest.MonkeyPatch
113+
) -> None:
114+
"""``_has_prerequisites`` returns False when ``dot`` is not on PATH."""
115+
monkeypatch.setattr(hook.shutil, "which", lambda _name: None)
116+
117+
assert hook._has_prerequisites() is False
118+
119+
120+
def test_has_prerequisites_false_when_pyreverse_missing(
121+
hook: ModuleType, monkeypatch: pytest.MonkeyPatch
122+
) -> None:
123+
"""``_has_prerequisites`` returns False when ``pylint.pyreverse`` is missing."""
124+
monkeypatch.setattr(hook.shutil, "which", lambda _name: "/usr/bin/dot")
125+
126+
def fake_run(*_args: Any, **_kwargs: Any) -> None:
127+
raise hook.subprocess.CalledProcessError(returncode=1, cmd=["python"])
128+
129+
monkeypatch.setattr(hook.subprocess, "run", fake_run)
130+
131+
assert hook._has_prerequisites() is False
132+
133+
134+
def test_run_generator_reports_failure(hook: ModuleType, monkeypatch: pytest.MonkeyPatch) -> None:
135+
"""``_run_generator`` returns False when the subprocess errors out."""
136+
137+
def fake_run(*_args: Any, **_kwargs: Any) -> None:
138+
raise hook.subprocess.CalledProcessError(returncode=2, cmd=["script"])
139+
140+
monkeypatch.setattr(hook.subprocess, "run", fake_run)
141+
142+
assert hook._run_generator() is False

tools/mkdocs_hooks.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""MkDocs hooks for the HaClient documentation build.
2+
3+
This module is wired into ``mkdocs.yml`` via the ``hooks`` setting so that
4+
every documentation build regenerates the architecture diagrams from source
5+
before MkDocs validates inter-page links.
6+
7+
If the system prerequisites for diagram generation (``pyreverse`` and the
8+
Graphviz ``dot`` binary) are missing, the hook writes minimal placeholder
9+
SVG files instead of failing. This keeps ``mkdocs build --strict`` working
10+
in environments that do not have Graphviz installed while still producing
11+
real diagrams in CI and in normal development setups.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import shutil
17+
import subprocess
18+
import sys
19+
from pathlib import Path
20+
from typing import TYPE_CHECKING, Any
21+
22+
if TYPE_CHECKING:
23+
from mkdocs.config.defaults import MkDocsConfig
24+
25+
REPO_ROOT = Path(__file__).resolve().parent.parent
26+
OUTPUT_DIR = REPO_ROOT / "docs" / "architecture"
27+
EXPECTED_FILES = ("classes.svg", "packages.svg")
28+
29+
_PLACEHOLDER_SVG = (
30+
'<?xml version="1.0" encoding="UTF-8"?>\n'
31+
'<svg xmlns="http://www.w3.org/2000/svg" width="480" height="80" '
32+
'viewBox="0 0 480 80">\n'
33+
' <rect width="100%" height="100%" fill="#f5f5f5" stroke="#999"/>\n'
34+
' <text x="50%" y="50%" font-family="sans-serif" font-size="14" '
35+
'fill="#555" text-anchor="middle" dominant-baseline="middle">'
36+
"Architecture diagram placeholder - install Graphviz to regenerate"
37+
"</text>\n"
38+
"</svg>\n"
39+
)
40+
41+
42+
def _write_placeholders() -> None:
43+
"""Write minimal placeholder SVGs for each expected diagram."""
44+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
45+
for name in EXPECTED_FILES:
46+
target = OUTPUT_DIR / name
47+
if not target.exists():
48+
target.write_text(_PLACEHOLDER_SVG, encoding="utf-8")
49+
50+
51+
def _has_prerequisites() -> bool:
52+
"""Return ``True`` when both pyreverse and Graphviz ``dot`` are available."""
53+
if shutil.which("dot") is None:
54+
return False
55+
try:
56+
subprocess.run( # noqa: S603
57+
[sys.executable, "-c", "import pylint.pyreverse.main"],
58+
check=True,
59+
capture_output=True,
60+
)
61+
except (subprocess.CalledProcessError, FileNotFoundError):
62+
return False
63+
return True
64+
65+
66+
def _run_generator() -> bool:
67+
"""Invoke ``tools/generate_diagrams.py`` as a subprocess.
68+
69+
Returns
70+
-------
71+
bool
72+
``True`` if generation succeeded, ``False`` otherwise.
73+
"""
74+
script = REPO_ROOT / "tools" / "generate_diagrams.py"
75+
try:
76+
subprocess.run( # noqa: S603
77+
[sys.executable, str(script)],
78+
check=True,
79+
)
80+
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
81+
print(f"[mkdocs_hooks] diagram generation failed: {exc}", file=sys.stderr)
82+
return False
83+
return True
84+
85+
86+
def on_pre_build(config: MkDocsConfig, **_: Any) -> None:
87+
"""Generate architecture diagrams before MkDocs validates the site.
88+
89+
Parameters
90+
----------
91+
config : MkDocsConfig
92+
The active MkDocs configuration (unused but required by the hook
93+
signature).
94+
**_ : Any
95+
Additional keyword arguments supplied by MkDocs that are ignored
96+
here.
97+
98+
Side Effects
99+
------------
100+
Writes SVG files under ``docs/architecture/``. When the diagram
101+
toolchain is unavailable, writes placeholder SVGs so that
102+
``mkdocs build --strict`` does not fail on missing image links.
103+
"""
104+
del config # unused
105+
106+
if _has_prerequisites() and _run_generator():
107+
return
108+
109+
print(
110+
"[mkdocs_hooks] Graphviz/pyreverse unavailable; writing placeholder "
111+
"architecture diagrams. Install the 'docs' extra and Graphviz to "
112+
"regenerate real diagrams.",
113+
file=sys.stderr,
114+
)
115+
_write_placeholders()

0 commit comments

Comments
 (0)