Files
2026-09-08 11:00:05 +05:30

183 lines
6.0 KiB
Python

"""
B0.1.3 — classify every route as freeze / skip / websocket.
Introspects the live FastAPI application rather than grepping for decorators,
so the count cannot drift from what the app actually serves and nothing hidden
behind a conditional include is missed.
Writes tests/characterization/inventory.json, which is committed and read by
test_inventory.py. When the inventory changes, that test fails and the diff
shows exactly which endpoint appeared or vanished.
APP_ENV=rework_test python scripts/endpoint_inventory.py
"""
import json
import os
import sys
from pathlib import Path
os.environ.setdefault("APP_ENV", "rework_test")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
sys.path.insert(0, str(Path(__file__).parent.parent))
OUTPUT = Path(__file__).parent.parent / "tests" / "characterization" / "inventory.json"
# Reasons an endpoint is not worth freezing. Order matters: the first match wins.
SKIP_RULES = [
("webhook", "Inbound webhook — the contract belongs to Zoho/DocuSeal, not us"),
("/api/health", "Liveness probe, no data"),
("/docs", "Generated documentation"),
("/redoc", "Generated documentation"),
("/openapi.json", "Generated schema — covered by the B0.3 contract gate"),
]
# Paths that stream bytes rather than return JSON. Worth an existence and auth
# test, but recording a byte-for-byte body is not characterization, it is a
# fixture with extra steps.
BINARY_HINTS = ("/download", "/preview", "/thumbnail", "/image", "/stream", "/file/")
# Modules that own at least one of the 15 tenant-scoped tables. A mutation in
# one of these can move data across a tenant boundary, so it is worth freezing
# before B1 rewrites how scoping works. A mutation elsewhere cannot, so it is
# deferred rather than skipped — see D9 in the plan.
TENANT_OWNING_MODULES = {
"activity_logs",
"auth",
"chat",
"documents",
"drive",
"notifications",
"signing",
"storage",
"tenant",
}
def classify(path: str, method: str, module: str) -> tuple[str, str]:
lowered = path.lower()
for needle, reason in SKIP_RULES:
if needle in lowered:
return "skip", reason
if any(hint in lowered for hint in BINARY_HINTS):
return "freeze-shallow", "Streams bytes — freeze status and headers, not the body"
if method == "GET":
return "freeze", "Read endpoint"
if module in TENANT_OWNING_MODULES:
return "freeze", f"Mutation in {module}, which owns tenant-scoped tables"
return "defer", (
f"Mutation in {module}, which owns no tenant-scoped table — "
"covered after B1, not before it"
)
def module_of(path: str, endpoint) -> str:
mod = getattr(endpoint, "__module__", "") or ""
if ".modules." in mod:
return mod.split(".modules.")[1].split(".")[0]
if mod.startswith("app.api"):
return "_api"
if mod.startswith("app.tasks"):
return "_tasks"
if mod.startswith("app.ai_service"):
return "_ai_service"
if mod.startswith("app.main"):
return "_main"
return "_other"
def main() -> int:
from fastapi.routing import APIRoute, APIWebSocketRoute
from app.main import app
records = []
for route in app.routes:
path = getattr(route, "path", None)
if not path:
continue
endpoint = getattr(route, "endpoint", None)
if isinstance(route, APIWebSocketRoute):
records.append(
{
"path": path,
"method": "WEBSOCKET",
"module": module_of(path, endpoint),
"classification": "websocket",
"reason": "Bidirectional — probed by B0.2.6, not frozen",
"response_model": False,
}
)
continue
if not isinstance(route, APIRoute):
continue
has_model = getattr(route, "response_model", None) is not None
module = module_of(path, endpoint)
for method in sorted(route.methods or []):
if method in {"HEAD", "OPTIONS"}:
continue
classification, reason = classify(path, method, module)
records.append(
{
"path": path,
"method": method,
"module": module_of(path, endpoint),
"classification": classification,
"reason": reason,
"response_model": has_model,
}
)
records.sort(key=lambda r: (r["module"], r["path"], r["method"]))
totals = {}
for r in records:
totals[r["classification"]] = totals.get(r["classification"], 0) + 1
by_module = {}
for r in records:
m = by_module.setdefault(r["module"], {"total": 0, "response_model": 0})
m["total"] += 1
m["response_model"] += 1 if r["response_model"] else 0
payload = {
"_comment": (
"Generated by scripts/endpoint_inventory.py (B0.1.3). Do not hand-edit. "
"Regenerate and commit the diff when routes change."
),
"totals": {
"endpoints": len(records),
"by_classification": dict(sorted(totals.items())),
"with_response_model": sum(1 for r in records if r["response_model"]),
"missing_response_model": sum(1 for r in records if not r["response_model"]),
},
"by_module": dict(sorted(by_module.items())),
"endpoints": records,
}
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
OUTPUT.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
print(f"wrote {OUTPUT.relative_to(Path.cwd())}")
print(f" endpoints {len(records)}")
for k, v in sorted(totals.items()):
print(f" {k:<20}{v}")
print(f" response_model {payload['totals']['with_response_model']}")
print(f" missing {payload['totals']['missing_response_model']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())