"""Regenerate the developer API reference from the running app's own routes. python scripts/gen_api_reference.py # rewrite docs/developers/api-reference.md python scripts/gen_api_reference.py --check # fail if it is out of date python scripts/gen_api_reference.py --update-contract # re-pin the stable routes' shapes The reference lists every route the engine serves with its stability label (server/api_stability.py). It is built from the app itself, so a route that exists is never missing from it and a route that was removed never lingers. --update-contract rewrites tests/fixtures/api/stable_contract.json, the shapes tests/test_api_contract.py holds the stable routes to. Only run it when a change to a stable route is meant: adding an optional field is fine; removing or reshaping one is an incompatible change and also bumps API_VERSION. Needs the engine's own dependencies (FastAPI). Nothing is started: the app is built in a temporary data folder and only its route table is read. """ import argparse import json import sys import tempfile from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) REFERENCE = ROOT / "docs" / "developers" / "api-reference.md" CONTRACT = ROOT / "tests" / "fixtures" / "api" / "stable_contract.json" def build_app(data_dir: Path): from main import BUNDLED_CONFIG, load_config from server.api import create_app config = load_config(BUNDLED_CONFIG) config["paths"]["data_dir"] = str(data_dir / "data") return create_app(config, data_dir / "settings.yaml") def _area(path: str) -> str: """The first path segment, for grouping internal routes.""" return "/" + path.strip("/").split("/", 1)[0] def render(app) -> str: from server import api_stability as st from server.api import API_VERSION routes = st.app_routes(app) counts = dict.fromkeys((st.STABLE, st.EXPERIMENTAL, st.INTERNAL), 0) for method, path, _module in routes: counts[st.label(method, path)] += 1 out = [ "# Local API reference", "", "", "", f"API version **{API_VERSION}** (`GET /health` → `api_version`). The engine listens on " "`http://127.0.0.1:8765`. How to call it, with examples and the gotchas, is in " "[`docs/API.md`](../API.md); what the labels promise is in [API](api.md).", "", f"{len(routes)} routes: **{counts[st.STABLE]} stable**, **{counts[st.EXPERIMENTAL]} experimental**, " f"{counts[st.INTERNAL]} internal.", "", "| Label | Promise |", "|---|---|", "| stable | Documented as supported in `docs/API.md`. Changes only by adding; removing or reshaping it bumps the API version. Its request shape is pinned by `tests/test_api_contract.py`. |", "| experimental | Meant for outside use, but its shape may still change in a release; every change is noted in `CHANGELOG.md`. |", "| internal | Serves one screen of the desktop app and changes with it. Works, but depending on it is at your own risk. |", "", ] for key, (title, anchor) in st.SECTIONS.items(): rows = [(m, p, st.ROUTES[(m, p)]) for m, p, _ in routes if (m, p) in st.ROUTES and st.ROUTES[(m, p)][1] == key] if not rows: continue out += [f"## {title}", "", f"Details: [`docs/API.md` › {title}](../API.md#{anchor}).", "", "| Route | Label | What it is for |", "|---|---|---|"] for method, path, (lab, _section, purpose) in rows: out.append(f"| `{method} {path}` | {lab} | {purpose} |") out.append("") internal = [(m, p, mod) for m, p, mod in routes if st.label(m, p) == st.INTERNAL] out += ["## Internal", "", "Everything else. Grouped by the first part of the path; the module is where it is defined.", ""] areas: dict[str, list] = {} for method, path, module in internal: areas.setdefault(_area(path), []).append((method, path, module)) out += ["| Area | Routes | Module |", "|---|---|---|"] for area in sorted(areas): items = areas[area] listed = "
".join(f"`{m} {p}`" for m, p, _ in items) modules = ", ".join(sorted({f"`{mod}`" for _, _, mod in items})) out.append(f"| `{area}` | {listed} | {modules} |") out.append("") return "\n".join(out) def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--check", action="store_true", help="exit 1 if the reference is out of date") ap.add_argument("--update-contract", action="store_true", help="re-pin the stable routes' request shapes") args = ap.parse_args() with tempfile.TemporaryDirectory() as tmp: app = build_app(Path(tmp)) text = render(app) if args.update_contract: from server import api_stability as st CONTRACT.parent.mkdir(parents=True, exist_ok=True) CONTRACT.write_text(json.dumps(st.contract(app), indent=1, sort_keys=True) + "\n", encoding="utf-8") print(f"pinned {CONTRACT.relative_to(ROOT)}") if args.check: current = REFERENCE.read_text(encoding="utf-8") if REFERENCE.exists() else "" if current != text: print(f"{REFERENCE.relative_to(ROOT)} is out of date: run python scripts/gen_api_reference.py") return 1 print("API reference is up to date") return 0 REFERENCE.parent.mkdir(parents=True, exist_ok=True) REFERENCE.write_text(text, encoding="utf-8") print(f"wrote {REFERENCE.relative_to(ROOT)}") return 0 if __name__ == "__main__": sys.exit(main())