Ctrl K
ComunidadGuías…

scripts/gen_api_reference.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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",
        "",
        "<!-- Generated by scripts/gen_api_reference.py from the app's own routes. Do not edit by hand. -->",
        "",
        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 = "<br>".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())