chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
"""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,
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user