scripts/update_api_contract.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""Schreibt die Schnappschüsse des öffentlichen Vertrags neu (Nodvard Link).
python scripts/update_api_contract.py # nachziehen, wenn nur etwas dazukam
python scripts/update_api_contract.py --allow-breaking # Bruch, der in der Ausnahmeliste steht
Schnappschüsse (werden mit eingecheckt):
backend/tests/contract/api_v1.json /api/v1 samt aller mitgelieferten Extensions
backend/tests/contract/sdk_public.json öffentliche Namen von nodvard_sdk
Regel (docs/04-API.md "Kompatibilität"): /api/v1 und nodvard_sdk ändern sich nur
abwärtskompatibel. Das Skript verweigert das Schreiben, wenn dabei ein Bruch entstünde.
Mit --allow-breaking geht es nur, wenn JEDER Bruch in
backend/tests/contract/breaking_exceptions.toml steht (mit Begründung).
Exit-Code 0 = ok, 1 = verweigert (Bruch), 2 = Aufrufproblem.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import sys
import tempfile
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "backend" / "tests" / "contract"))
import contract_lib as cl
async def _current_api_doc() -> dict[str, Any]:
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from nodvard_deck.config import Settings
from nodvard_deck.db.session import reset_engine_cache, set_engine_for_testing
from nodvard_deck.main import app
from nodvard_deck.models import Base
with tempfile.TemporaryDirectory() as tmp_name:
tmp = Path(tmp_name)
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:", poolclass=StaticPool, connect_args={"check_same_thread": False}
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
set_engine_for_testing(engine)
settings = Settings(
env="dev",
data_dir=tmp,
database_url="sqlite+aiosqlite:///:memory:",
master_key_path=tmp / "master.key",
vault_keyring_path=tmp / "vault_keyring.json",
ext_data_dir=tmp / "ext",
)
try:
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
return await cl.collect_openapi(app, session, settings, REPO_ROOT / "extensions")
finally:
await engine.dispose()
reset_engine_cache()
def _load(path: Path) -> dict[str, Any] | None:
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else None
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument(
"--allow-breaking",
action="store_true",
help="Bruch zulassen -- nur wenn jeder Bruch in breaking_exceptions.toml mit Begruendung steht",
)
args = parser.parse_args()
try:
exceptions = cl.load_exceptions()
except ValueError as exc:
print(f"FEHLER: {exc}", file=sys.stderr)
return 2
# Extension-Hintergrundaufgaben finden hier keine eigenen Tabellen und loggen Fehler -- Lärm.
logging.disable(logging.CRITICAL)
new_api = cl.build_api_snapshot(asyncio.run(_current_api_doc()))
logging.disable(logging.NOTSET)
new_sdk = cl.build_sdk_snapshot()
jobs = [
("API", cl.API_SNAPSHOT, new_api, cl.find_api_breaks),
("SDK", cl.SDK_SNAPSHOT, new_sdk, cl.find_sdk_breaks),
]
all_breaks: list[cl.Break] = []
for _label, path, new, finder in jobs:
old = _load(path)
if old is not None:
all_breaks += finder(old, new)
if all_breaks:
open_ones = cl.uncovered(all_breaks, exceptions)
if not args.allow_breaking:
print("VERWEIGERT: Das Aktualisieren würde die Abwärtskompatibilität brechen.\n", file=sys.stderr)
print(cl.format_breaks(all_breaks), file=sys.stderr)
return 1
if open_ones:
print("VERWEIGERT: Nicht jeder Bruch steht in der Ausnahmeliste.\n", file=sys.stderr)
print(cl.format_breaks(open_ones), file=sys.stderr)
return 1
print(f"{len(all_breaks)} Bruch/Brüche stehen in der Ausnahmeliste und werden übernommen.")
for label, path, new, _finder in jobs:
text = cl.dump_json(new)
if path.exists() and path.read_text(encoding="utf-8") == text:
print(f"{label}: {path.relative_to(REPO_ROOT)} ist aktuell.")
continue
path.write_text(text, encoding="utf-8")
print(f"{label}: {path.relative_to(REPO_ROOT)} geschrieben.")
return 0
if __name__ == "__main__":
raise SystemExit(main())