"""Render the versioned A-card architecture YAML files to PNG and SVG. Setup once: python3 -m pip install diagrams pyyaml Usage: python3 docs/architecture/render_diagrams.py python3 docs/architecture/render_diagrams.py path/to/one.yaml """ from __future__ import annotations import argparse import importlib from pathlib import Path from typing import Any import yaml from diagrams import Cluster, Diagram, Edge ICONS = { "users": "diagrams.onprem.client:Users", "nginx": "diagrams.onprem.network:Nginx", "server": "diagrams.onprem.compute:Server", "storage": "diagrams.generic.storage:Storage", "process": "diagrams.programming.flowchart:PredefinedProcess", "flowdb": "diagrams.programming.flowchart:Database", "fastapi": "diagrams.programming.framework:Fastapi", "react": "diagrams.programming.framework:React", "mysql": "diagrams.onprem.database:MySQL", } EDGE_TYPES = { "flow": {"color": "#2f6fe0"}, "write": {"color": "#3f9ad0"}, "async": {"color": "#e0902f", "style": "dashed"}, "obs": {"color": "#4f9d78", "style": "dashed"}, "dep": {"color": "#8b6fc4", "style": "dashed"}, "plain": {"color": "#9aa2ad"}, } def resolve_icon(name: str) -> Any: module_path, class_name = ICONS[name].split(":") return getattr(importlib.import_module(module_path), class_name) def cluster_attr(style: dict[str, Any], font: str) -> dict[str, str]: return { "bgcolor": style.get("bg", "#F5F6F8"), "pencolor": style.get("pen", "#B8C0CC"), "style": "rounded,dashed" if style.get("dashed") else "rounded", "fontname": font, "fontsize": str(style.get("fontsize", 15)), "margin": str(style.get("margin", 18)), } def build_nodes(definitions: list[dict], registry: dict[str, Any]) -> None: for definition in definitions: label = str(definition.get("label", definition["id"])).replace("\\n", "\n") registry[definition["id"]] = resolve_icon(definition.get("icon", "server"))(label) def build_clusters( definitions: list[dict], registry: dict[str, Any], font: str ) -> None: for definition in definitions: with Cluster( definition["name"], graph_attr=cluster_attr(definition.get("style", {}), font), ): build_nodes(definition.get("nodes", []), registry) build_clusters(definition.get("clusters", []), registry, font) def draw_edges(definitions: list[dict], registry: dict[str, Any]) -> None: for definition in definitions: attributes = dict( EDGE_TYPES.get(definition.get("type", "plain"), EDGE_TYPES["plain"]) ) for key, output_key in ( ("label", "label"), ("style", "style"), ("width", "penwidth"), ("color", "color"), ("dir", "dir"), ): if key in definition: value = definition[key] attributes[output_key] = ( str(value).replace("\\n", "\n") if key == "label" else str(value) ) if definition.get("free"): attributes["constraint"] = "false" registry[definition["from"]] >> Edge(**attributes) >> registry[definition["to"]] def render(path: Path) -> None: spec = yaml.safe_load(path.read_text(encoding="utf-8")) theme = spec.get("theme", {}) font = theme.get("font", "PingFang SC") graph_attr = { "fontname": font, "fontsize": str(theme.get("title_size", 28)), "bgcolor": "white", "pad": str(theme.get("pad", 0.7)), "splines": theme.get("splines", "ortho"), "nodesep": str(theme.get("nodesep", 0.75)), "ranksep": str(theme.get("ranksep", 1.0)), "compound": "true", "newrank": "true", "label": str(spec.get("title", "")).replace("\\n", "\n"), "labelloc": "t", } for optional in ("dpi", "size"): if theme.get(optional): graph_attr[optional] = str(theme[optional]) registry: dict[str, Any] = {} with Diagram( str(spec.get("title", path.stem)).replace("\\n", "\n"), filename=str(path.with_suffix("")), show=False, direction=spec.get("direction", "TB"), graph_attr=graph_attr, node_attr={"fontname": font, "fontsize": str(theme.get("node_size", 12))}, edge_attr={"fontname": font, "fontsize": str(theme.get("edge_size", 10))}, outformat=spec.get("formats", ["png", "svg"]), ): build_nodes(spec.get("nodes", []), registry) build_clusters(spec.get("clusters", []), registry, font) draw_edges(spec.get("edges", []), registry) print(f"rendered {path.name}: {len(registry)} nodes") def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("paths", nargs="*", type=Path) args = parser.parse_args() paths = args.paths or sorted(Path(__file__).parent.glob("*.yaml")) for path in paths: render(path.resolve()) if __name__ == "__main__": main()