212 lines
8.0 KiB
Python
212 lines
8.0 KiB
Python
"""Strict drift detector for the checked-in OpenAPI artifact.
|
|
|
|
Whereas :mod:`tests.server.test_stream_events` validates a specific
|
|
slice of the spec (SSE route surfaces), this test does a byte-for-byte
|
|
comparison between :file:`openapi.json` (the artifact committed at the
|
|
repo root) and the spec produced live by :func:`generate_spec` in
|
|
:mod:`scripts.dump_openapi`. Any divergence — added/removed paths,
|
|
schema changes, or even whitespace differences in the serialized JSON
|
|
— fails this test with a unified diff and explicit regen instructions.
|
|
|
|
The contract:
|
|
|
|
* ``openapi.json`` is the source of truth that external SDK / docs
|
|
tooling reads. It must always match what ``scripts/dump_openapi.py``
|
|
emits against the current FastAPI app.
|
|
* When a developer changes routes or response schemas, this test
|
|
fails until they run the dump script and commit the result.
|
|
|
|
This test does NOT exercise behavior — it exclusively guards the
|
|
on-disk artifact.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import difflib
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# Locate ``scripts/dump_openapi.py`` and import it dynamically.
|
|
# ``scripts/`` is not a package on the Python path, so a normal
|
|
# ``from scripts.dump_openapi import ...`` would not resolve. We
|
|
# load it via ``importlib`` so the test is robust to the layout.
|
|
_REPO_ROOT: Path = Path(__file__).resolve().parent.parent.parent
|
|
_DUMP_OPENAPI_PATH: Path = _REPO_ROOT / "scripts" / "dump_openapi.py"
|
|
_OPENAPI_JSON_PATH: Path = _REPO_ROOT / "openapi.json"
|
|
|
|
# Cap on diff-snippet size in the failure message. Larger diffs
|
|
# truncate at this line count so the assertion message stays
|
|
# readable; the developer regenerates and inspects the full diff
|
|
# via ``git diff openapi.json`` after running the script.
|
|
_MAX_DIFF_LINES: int = 60
|
|
|
|
|
|
def _load_dump_openapi_module() -> Any:
|
|
"""
|
|
Import :mod:`scripts.dump_openapi` from its file path.
|
|
|
|
The ``scripts/`` directory is not on ``sys.path`` by default, so a
|
|
regular import would fail. ``importlib.util`` lets us load the
|
|
module directly from its known location.
|
|
|
|
:returns: The imported :mod:`scripts.dump_openapi` module object,
|
|
exposing :func:`generate_spec`.
|
|
"""
|
|
spec = importlib.util.spec_from_file_location(
|
|
"scripts_dump_openapi",
|
|
_DUMP_OPENAPI_PATH,
|
|
)
|
|
assert spec is not None and spec.loader is not None, (
|
|
f"Could not locate dump_openapi.py at {_DUMP_OPENAPI_PATH}. "
|
|
f"The script must exist at the documented path."
|
|
)
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules["scripts_dump_openapi"] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def _summarize_path_changes(
|
|
on_disk: dict[str, Any],
|
|
generated: dict[str, Any],
|
|
) -> str:
|
|
"""
|
|
Build a one-line summary of path-level changes.
|
|
|
|
Compares the top-level ``paths`` keys of the two specs. The result
|
|
is informational only — it does NOT replace the unified diff,
|
|
which captures field-level changes inside each operation.
|
|
|
|
:param on_disk: The parsed ``openapi.json`` contents.
|
|
:param generated: The freshly generated spec dict.
|
|
:returns: A summary string, e.g.
|
|
``"+2 paths added, -1 removed, ~3 changed"``.
|
|
"""
|
|
on_disk_paths = set(on_disk.get("paths", {}).keys())
|
|
generated_paths = set(generated.get("paths", {}).keys())
|
|
added = generated_paths - on_disk_paths
|
|
removed = on_disk_paths - generated_paths
|
|
# "changed" = same path key, different operation dict body.
|
|
overlap = on_disk_paths & generated_paths
|
|
changed = {p for p in overlap if on_disk["paths"][p] != generated["paths"][p]}
|
|
return f"+{len(added)} paths added, -{len(removed)} removed, ~{len(changed)} changed"
|
|
|
|
|
|
def _build_failure_message(
|
|
on_disk_text: str,
|
|
generated_text: str,
|
|
on_disk_spec: dict[str, Any],
|
|
generated_spec: dict[str, Any],
|
|
) -> str:
|
|
"""
|
|
Compose the assertion message for a drift failure.
|
|
|
|
Includes (in order):
|
|
|
|
1. A path-level change summary.
|
|
2. A capped unified diff snippet so the developer can see what
|
|
diverged at a glance.
|
|
3. Explicit regen instructions as the LAST lines, which is the
|
|
single most important payload — the test exists to point at
|
|
this command.
|
|
|
|
:param on_disk_text: Serialized contents of ``openapi.json`` on disk.
|
|
:param generated_text: Serialized contents emitted by
|
|
:func:`generate_spec`.
|
|
:param on_disk_spec: Parsed dict of the on-disk file.
|
|
:param generated_spec: Parsed dict of the generated spec.
|
|
:returns: A multi-line string suitable as the second arg to
|
|
``assert ... , msg``.
|
|
"""
|
|
summary = _summarize_path_changes(on_disk_spec, generated_spec)
|
|
diff_lines = list(
|
|
difflib.unified_diff(
|
|
on_disk_text.splitlines(keepends=True),
|
|
generated_text.splitlines(keepends=True),
|
|
fromfile="openapi.json (on disk)",
|
|
tofile="openapi.json (generated)",
|
|
n=2,
|
|
),
|
|
)
|
|
truncated = ""
|
|
if len(diff_lines) > _MAX_DIFF_LINES:
|
|
diff_lines = diff_lines[:_MAX_DIFF_LINES]
|
|
truncated = (
|
|
f"\n... (diff truncated at {_MAX_DIFF_LINES} lines — run "
|
|
f"`git diff openapi.json` after regenerating to see full diff)"
|
|
)
|
|
diff_text = "".join(diff_lines) + truncated
|
|
|
|
# NOTE: the regen instructions are the LAST lines of this
|
|
# message by design — pytest's assertion-rewrite formatting
|
|
# truncates long messages from the top, so putting the call to
|
|
# action at the bottom maximizes the chance the developer sees it.
|
|
return (
|
|
"openapi.json on disk is out of sync with the generator output.\n"
|
|
f"Summary: {summary}\n"
|
|
"\n"
|
|
"Diff (truncated):\n"
|
|
f"{diff_text}\n"
|
|
"\n"
|
|
"To regenerate the artifact:\n"
|
|
f" cd {_REPO_ROOT}\n"
|
|
" .venv/bin/python scripts/dump_openapi.py\n"
|
|
" git add openapi.json\n"
|
|
"\n"
|
|
"If the new spec is intentional, commit the regenerated file.\n"
|
|
"If accidental, revert the route/schema change that caused it."
|
|
)
|
|
|
|
|
|
def test_openapi_json_matches_generator_output() -> None:
|
|
"""
|
|
``openapi.json`` on disk matches :func:`generate_spec` byte-for-byte.
|
|
|
|
Production breakage that causes this test to fail: a developer
|
|
added a route, changed a request/response schema, added a tag,
|
|
or renamed an operation without rerunning
|
|
``scripts/dump_openapi.py`` to refresh the checked-in artifact.
|
|
External SDK / docs tooling consumes ``openapi.json`` directly,
|
|
so the artifact must always reflect the live app.
|
|
|
|
The serialization mirrors ``scripts/dump_openapi.py`` exactly
|
|
(``json.dumps(..., indent=2, sort_keys=True) + "\\n"``); any
|
|
formatting change there must be matched here to keep the
|
|
comparison meaningful.
|
|
|
|
Sibling to
|
|
:func:`test_openapi_json_surfaces_sse_routes_with_typed_schema` in
|
|
:mod:`tests.server.test_stream_events`, which validates only the
|
|
SSE-route slice — this test is strictly a drift detector.
|
|
"""
|
|
assert _OPENAPI_JSON_PATH.exists(), (
|
|
f"openapi.json not found at {_OPENAPI_JSON_PATH}. "
|
|
f"Run `.venv/bin/python scripts/dump_openapi.py` to generate it, "
|
|
f"then commit the resulting file."
|
|
)
|
|
|
|
dump_module = _load_dump_openapi_module()
|
|
generated_spec = dump_module.generate_spec()
|
|
# Match dump_openapi.py's serialization exactly so the comparison
|
|
# is byte-for-byte against what the script would write.
|
|
generated_text = json.dumps(generated_spec, indent=2, sort_keys=True) + "\n"
|
|
|
|
on_disk_text = _OPENAPI_JSON_PATH.read_text()
|
|
|
|
if on_disk_text == generated_text:
|
|
return
|
|
|
|
# Drift detected — assemble a useful failure message before raising.
|
|
on_disk_spec = json.loads(on_disk_text)
|
|
raise AssertionError(
|
|
_build_failure_message(
|
|
on_disk_text=on_disk_text,
|
|
generated_text=generated_text,
|
|
on_disk_spec=on_disk_spec,
|
|
generated_spec=generated_spec,
|
|
),
|
|
)
|