#!/usr/bin/env python3 """Generate architecture graphs for the bincio codebase. Outputs: docs/architecture.mmd — Mermaid source (embeddable in markdown / GitHub) docs/graph.html — interactive vis.js graph (open in a browser) Usage: uv run python scripts/gen_graph.py # or just: python scripts/gen_graph.py """ import json import re from pathlib import Path ROOT = Path(__file__).parent.parent SITE_SRC = ROOT / "site" / "src" DOCS = ROOT / "docs" DOCS.mkdir(exist_ok=True) # ── helpers ─────────────────────────────────────────────────────────────────── def read(path: Path) -> str: try: return path.read_text(encoding="utf-8") except Exception: return "" def short(path: Path, base: Path) -> str: """Return a short display label for a file path.""" try: rel = path.relative_to(base) except ValueError: rel = path parts = rel.parts # Drop leading site/src/ or bincio/ if parts[:2] == ("site", "src"): parts = parts[2:] elif parts[:1] == ("bincio",): parts = parts[1:] name = "/".join(parts) # Strip index.astro → parent dir if name.endswith("/index.astro"): name = name[: -len("/index.astro")] + "/" return name # ── 1. API routes from server.py ────────────────────────────────────────────── def extract_routes(server_path: Path) -> list[dict]: """Parse @app.{method}("/api/...") decorators.""" text = read(server_path) routes = [] for m in re.finditer( r'@app\.(get|post|put|patch|delete)\("(/api/[^"]+)"', text, re.MULTILINE, ): method, path = m.group(1).upper(), m.group(2) # Find the function name on the next non-blank line tail = text[m.end():] fn_m = re.search(r"async def (\w+)", tail[:200]) fn = fn_m.group(1) if fn_m else "?" routes.append({"method": method, "path": path, "fn": fn}) return routes # ── 2. Frontend → API edges ─────────────────────────────────────────────────── _FETCH_RE = re.compile(r"""fetch\(\s*[`'"](/api/[^`'"]+)[`'"]""") _INTERP_RE = re.compile(r"""`[^`]*/api/([^`$\s{]+)""") # template literals def extract_api_calls(file_path: Path) -> list[str]: """Return all /api/... paths referenced by a frontend file.""" text = read(file_path) found = [] for m in _FETCH_RE.finditer(text): found.append(m.group(1).split("?")[0]) # strip query string # Template literals: `/api/admin/users/${h}/rebuild` → /api/admin/users/{h}/rebuild for m in _INTERP_RE.finditer(text): raw = "/api/" + m.group(1) normalised = re.sub(r"\$\{[^}]+\}", "{x}", raw) found.append(normalised) return found def normalise_route(path: str, routes: list[dict]) -> str | None: """Match a raw path like /api/admin/users/brut/rebuild to a known route pattern.""" for r in routes: pattern = re.sub(r"\{[^}]+\}", r"[^/]+", re.escape(r["path"])) + "$" if re.match(pattern, path): return r["path"] return path # keep as-is if not matched # ── 3. Component imports (Svelte / Astro) ───────────────────────────────────── _IMPORT_SVELTE_RE = re.compile( r"""import\s+\w+\s+from\s+['"]([^'"]+\.svelte)['"]""" ) _IMPORT_ASTRO_RE = re.compile( r"""import\s+\w+\s+from\s+['"]([^'"]+\.astro)['"]""" ) def extract_component_imports(file_path: Path) -> list[Path]: text = read(file_path) results = [] for pattern in (_IMPORT_SVELTE_RE, _IMPORT_ASTRO_RE): for m in pattern.finditer(text): ref = m.group(1) target = (file_path.parent / ref).resolve() if target.exists(): results.append(target) return results # ── 4. Python module imports ────────────────────────────────────────────────── _PY_FROM_RE = re.compile(r"^from (bincio\.\S+) import", re.MULTILINE) _PY_IMP_RE = re.compile(r"^import (bincio\.\S+)", re.MULTILINE) def extract_py_imports(file_path: Path, py_files: list[Path]) -> list[Path]: text = read(file_path) modules = set() for m in _PY_FROM_RE.finditer(text): modules.add(m.group(1)) for m in _PY_IMP_RE.finditer(text): modules.add(m.group(1)) results = [] for mod in modules: # bincio.serve.db → bincio/serve/db.py candidate = ROOT / Path(*mod.split(".")).with_suffix(".py") if candidate.exists() and candidate != file_path: results.append(candidate) return results # ── 5. Collect all data ─────────────────────────────────────────────────────── def collect() -> dict: server_path = ROOT / "bincio" / "serve" / "server.py" routes = extract_routes(server_path) # Frontend files fe_files = list(SITE_SRC.rglob("*.svelte")) + list(SITE_SRC.rglob("*.astro")) # Python files (bincio package only) py_files = [ p for p in (ROOT / "bincio").rglob("*.py") if "__pycache__" not in str(p) and p.name != "__init__.py" ] # --- edges: page/component → API endpoint api_edges = [] # (source_file, route_path) for f in fe_files: calls = extract_api_calls(f) for call in calls: norm = normalise_route(call, routes) api_edges.append((f, norm)) # --- edges: component imports comp_edges = [] # (importer_file, imported_file) for f in fe_files: for dep in extract_component_imports(f): comp_edges.append((f, dep)) # --- edges: python imports py_edges = [] # (importer_file, imported_file) for f in py_files: for dep in extract_py_imports(f, py_files): py_edges.append((f, dep)) return { "routes": routes, "fe_files": fe_files, "py_files": py_files, "api_edges": api_edges, "comp_edges": comp_edges, "py_edges": py_edges, } # ── 6. Mermaid output ───────────────────────────────────────────────────────── def to_node_id(path: Path) -> str: return re.sub(r"[^a-zA-Z0-9]", "_", str(path.relative_to(ROOT))) def write_mermaid(data: dict) -> Path: lines = ["graph LR", ""] routes = data["routes"] # Subgraph: API endpoints grouped by domain domains: dict[str, list[dict]] = {} for r in routes: parts = r["path"].strip("/").split("/") domain = parts[1] if len(parts) > 1 else "other" domains.setdefault(domain, []).append(r) lines.append(" subgraph API") for domain, rs in sorted(domains.items()): lines.append(f" subgraph api_{domain}[\"{domain}\"]") for r in rs: nid = "api_" + re.sub(r"[^a-zA-Z0-9]", "_", r["path"]) lines.append(f' {nid}["{r["method"]} {r["path"]}"]') lines.append(" end") lines.append(" end") lines.append("") # Subgraph: pages pages = [f for f in data["fe_files"] if "/pages/" in str(f)] lines.append(" subgraph Pages") for f in sorted(pages): nid = to_node_id(f) label = short(f, ROOT) lines.append(f' {nid}["{label}"]') lines.append(" end") lines.append("") # Subgraph: components comps = [f for f in data["fe_files"] if "/components/" in str(f)] lines.append(" subgraph Components") for f in sorted(comps): nid = to_node_id(f) label = short(f, ROOT) lines.append(f' {nid}["{label}"]') lines.append(" end") lines.append("") # Subgraph: Python modules py_groups: dict[str, list[Path]] = {} for f in data["py_files"]: rel = f.relative_to(ROOT / "bincio") group = rel.parts[0] if len(rel.parts) > 1 else "root" py_groups.setdefault(group, []).append(f) lines.append(" subgraph Python") for group, files in sorted(py_groups.items()): lines.append(f' subgraph py_{group}["{group}"]') for f in sorted(files): nid = to_node_id(f) lines.append(f' {nid}["{f.stem}"]') lines.append(" end") lines.append(" end") lines.append("") # Edges: page/component → API seen = set() for src, route_path in data["api_edges"]: src_nid = to_node_id(src) dst_nid = "api_" + re.sub(r"[^a-zA-Z0-9]", "_", route_path) edge = f" {src_nid} -->|fetch| {dst_nid}" if edge not in seen: lines.append(edge) seen.add(edge) # Edges: component imports seen_comp = set() for src, dst in data["comp_edges"]: src_nid = to_node_id(src) dst_nid = to_node_id(dst) edge = f" {src_nid} --> {dst_nid}" if edge not in seen_comp: lines.append(edge) seen_comp.add(edge) # Edges: python imports seen_py = set() for src, dst in data["py_edges"]: src_nid = to_node_id(src) dst_nid = to_node_id(dst) edge = f" {src_nid} --> {dst_nid}" if edge not in seen_py: lines.append(edge) seen_py.add(edge) out = DOCS / "architecture.mmd" out.write_text("\n".join(lines), encoding="utf-8") return out # ── 7. vis.js HTML output ───────────────────────────────────────────────────── def write_visjs(data: dict) -> Path: nodes: list[dict] = [] edges: list[dict] = [] node_ids: dict[str, int] = {} def add_node(key: str, label: str, group: str, title: str = "") -> int: if key in node_ids: return node_ids[key] nid = len(nodes) node_ids[key] = nid nodes.append({"id": nid, "label": label, "group": group, "title": title or label}) return nid def add_edge(src_key: str, dst_key: str, label: str = "") -> None: if src_key not in node_ids or dst_key not in node_ids: return e: dict = {"from": node_ids[src_key], "to": node_ids[dst_key], "arrows": "to"} if label: e["label"] = label edges.append(e) # API endpoint nodes for r in data["routes"]: key = f"api:{r['path']}" label = f"{r['method']}\n{r['path']}" add_node(key, label, "api", f"{r['method']} {r['path']} → {r['fn']}()") # Frontend file nodes for f in data["fe_files"]: key = str(f) label = f.name.replace("/index.astro", "/").replace("index.astro", f.parent.name + "/") is_page = "/pages/" in str(f) is_layout = "/layouts/" in str(f) group = "page" if is_page else ("layout" if is_layout else "component") title = short(f, ROOT) add_node(key, label, group, title) # Python module nodes for f in data["py_files"]: key = str(f) rel = f.relative_to(ROOT / "bincio") group = "py_" + rel.parts[0] if len(rel.parts) > 1 else "py_root" add_node(key, f.stem, group, str(f.relative_to(ROOT))) # Edges: page/component → API seen = set() for src, route_path in data["api_edges"]: src_key = str(src) dst_key = f"api:{route_path}" k = (src_key, dst_key) if k not in seen: seen.add(k) add_edge(src_key, dst_key, "fetch") # Edges: component imports seen_comp = set() for src, dst in data["comp_edges"]: k = (str(src), str(dst)) if k not in seen_comp: seen_comp.add(k) add_edge(str(src), str(dst)) # Edges: python imports seen_py = set() for src, dst in data["py_edges"]: k = (str(src), str(dst)) if k not in seen_py: seen_py.add(k) add_edge(str(src), str(dst)) # Group colours for legend groups = { "api": {"color": {"background": "#f59e0b", "border": "#d97706"}, "font": {"color": "#000"}}, "page": {"color": {"background": "#3b82f6", "border": "#2563eb"}, "font": {"color": "#fff"}}, "component": {"color": {"background": "#8b5cf6", "border": "#7c3aed"}, "font": {"color": "#fff"}}, "layout": {"color": {"background": "#06b6d4", "border": "#0891b2"}, "font": {"color": "#000"}}, "py_extract": {"color": {"background": "#22c55e", "border": "#16a34a"}, "font": {"color": "#000"}}, "py_render": {"color": {"background": "#84cc16", "border": "#65a30d"}, "font": {"color": "#000"}}, "py_serve": {"color": {"background": "#ef4444", "border": "#dc2626"}, "font": {"color": "#fff"}}, "py_edit": {"color": {"background": "#f97316", "border": "#ea580c"}, "font": {"color": "#fff"}}, "py_root": {"color": {"background": "#6b7280", "border": "#4b5563"}, "font": {"color": "#fff"}}, } html = f"""