Skip to content

Commit ce4a756

Browse files
dmealingclaude
andcommitted
test(python): wire api-contract-conformance runner — 10/10 (FR-008 §2.6 Python)
Adds the Python reference runner for fixtures/api-contract-conformance/. Mirrors the Java/Kotlin/C# runners: one parameterized pytest invocation per scenario, each spinning up a fresh Postgres testcontainer and driving a FastAPI Author CRUD app via TestClient. The handler shape, wire envelopes, and status codes mirror what router_generator emits byte-for-byte (the corpus is the contract both must satisfy). Backend: pg8000 (pure-python DB-API — same driver the existing persistence-conformance runner uses). Schema applied via raw DDL since the api-contract corpus exercises the HTTP surface, not the migrate pipeline. CONFORMANCE.md: Python column for api-contract goes from "not yet wired" to 10 / 10 + the run command. All four ports (TS / Java+Kotlin / C# / Python) now exercise the shared corpus. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 26ab500 commit ce4a756

7 files changed

Lines changed: 730 additions & 2 deletions

File tree

docs/CONFORMANCE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ human-readable explanation somewhere, look it up in the
1919
| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 31 | 31 / 31 | 31 / 31 | inherits via Java | 31 / 31 | 31 / 31 |
2020
| [`fixtures/render-conformance/`](../fixtures/render-conformance/) | 4 | 4 / 4 | 4 / 4 | inherits via Java | 4 / 4 | 4 / 4 |
2121
| [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 12 (9 query + 3 migration) | 12 / 12 | 12 / 12 | 12 / 12 (via Exposed) | 12 / 12 | 12 / 12 |
22-
| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 10 | 10 / 10 (Fastify reference runner) | 10 / 10 (embedded HTTP + JDBC reference runner) | 10 / 10 (embedded HTTP + Exposed reference runner) | 10 / 10 (HttpListener + Npgsql reference runner) | not yet wired |
22+
| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 10 | 10 / 10 (Fastify reference runner) | 10 / 10 (embedded HTTP + JDBC reference runner) | 10 / 10 (embedded HTTP + Exposed reference runner) | 10 / 10 (HttpListener + Npgsql reference runner) | 10 / 10 (FastAPI + pg8000 reference runner) |
2323
| `fixtures/codegen-conformance/` (FR-007 — DROPPED in favor of `persistence-conformance` participation) | 0 | n/a | n/a | n/a | n/a | n/a |
2424

2525
Per-port runners + commands:
@@ -30,7 +30,7 @@ Per-port runners + commands:
3030
| Java | `mvn -pl metaobjects-conformance test` (and per-tier `-pl render`, etc.) | `scripts/integration-test.sh java` (needs Docker) | `mvn -f server/java/integration-tests/pom.xml test -Dtest=ApiContractConformanceTest` (needs Docker) |
3131
| Kotlin | `mvn -pl codegen-kotlin test` (snapshot suite) | `mvn -pl integration-tests-kotlin test` (needs Docker) | `mvn -f server/java/integration-tests-kotlin/pom.xml test -Dtest=ApiContractConformanceTest` (needs Docker) |
3232
| C# | `dotnet test` (per project) | `scripts/integration-test.sh csharp` (needs Docker) | `dotnet test server/csharp/MetaObjects.IntegrationTests/MetaObjects.IntegrationTests.csproj --filter "FullyQualifiedName~ApiContractConformanceTest"` (needs Docker) |
33-
| Python | `pytest` (per package) | `scripts/integration-test.sh python` (needs Docker) | not yet wired |
33+
| Python | `pytest` (per package) | `scripts/integration-test.sh python` (needs Docker) | `cd server/python && uv run --extra integration pytest tests/integration/test_api_contract.py` (needs Docker) |
3434

3535
The `persistence-conformance` corpus is intentionally **on-demand** — none of the
3636
unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker.

server/python/pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ dependencies = [
1111

1212
[project.optional-dependencies]
1313
dev = ["pytest>=8", "mypy>=1.10", "ruff>=0.6", "pydantic>=2"]
14+
# On-demand cross-language api-contract-conformance suite. pg8000 is pure-python
15+
# so this extra installs cleanly without native deps; FastAPI + httpx (the
16+
# starlette TestClient transport) drive the reference Author API in-process.
17+
integration = ["pg8000>=1.31", "fastapi>=0.110", "httpx>=0.27"]
1418

1519
[build-system]
1620
requires = ["hatchling"]

server/python/tests/integration/__init__.py

Whitespace-only changes.
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""Assertion engine for api-contract-conformance scenarios.
2+
3+
The vocabulary (``equals`` / ``length`` / ``ids`` / ``names`` / ``row`` /
4+
``hasId`` / ``envelope`` / ``error`` / ``empty``) is the cross-port contract —
5+
every per-port runner must implement these keys identically. See
6+
``fixtures/api-contract-conformance/README.md``.
7+
8+
``body`` here is the parsed JSON response (dict / list / scalar / None).
9+
Mirrors ``ApiContractAssertions.java`` and ``ApiContractAssertions.kt``.
10+
"""
11+
from __future__ import annotations
12+
13+
from typing import Any
14+
15+
16+
def assert_response(
17+
scenario_name: str,
18+
request_id: str,
19+
expect_status: int,
20+
expect_body: dict[str, Any] | None,
21+
status: int,
22+
body: Any,
23+
) -> None:
24+
"""Walk the expect-body vocabulary against an actual response."""
25+
if status != expect_status:
26+
raise AssertionError(
27+
f"{scenario_name} / {request_id}: expected status {expect_status}, "
28+
f"got {status}; body: {body!r}"
29+
)
30+
if expect_body is None:
31+
return
32+
33+
if expect_body.get("empty") is True:
34+
if not _is_empty(body):
35+
raise AssertionError(
36+
f"{scenario_name} / {request_id}: expected empty body, got: {body!r}"
37+
)
38+
return
39+
40+
if "equals" in expect_body:
41+
want = expect_body["equals"]
42+
if not _structural_equals(body, want):
43+
raise AssertionError(
44+
f"{scenario_name} / {request_id}: body mismatch\n"
45+
f" expected: {want!r}\n"
46+
f" actual: {body!r}"
47+
)
48+
return
49+
50+
if expect_body.get("envelope") is True:
51+
if not isinstance(body, dict):
52+
raise AssertionError(
53+
f"{scenario_name} / {request_id}: expected envelope {{rows,total}}, got: {body!r}"
54+
)
55+
rows = body.get("rows")
56+
if not isinstance(rows, list):
57+
raise AssertionError(
58+
f"{scenario_name} / {request_id}: envelope.rows missing or not a list; got: {body!r}"
59+
)
60+
total = body.get("total")
61+
if not isinstance(total, (int, float)) or isinstance(total, bool):
62+
raise AssertionError(
63+
f"{scenario_name} / {request_id}: envelope.total missing or not a number; got: {body!r}"
64+
)
65+
want_len = expect_body.get("rowsLength")
66+
if isinstance(want_len, int) and len(rows) != want_len:
67+
raise AssertionError(
68+
f"{scenario_name} / {request_id}: expected rows.length={want_len}, got {len(rows)}"
69+
)
70+
want_total = expect_body.get("total")
71+
if isinstance(want_total, int) and int(total) != want_total:
72+
raise AssertionError(
73+
f"{scenario_name} / {request_id}: expected total={want_total}, got {int(total)}"
74+
)
75+
return
76+
77+
if "error" in expect_body:
78+
want_err = expect_body["error"]
79+
actual_err = body.get("error") if isinstance(body, dict) else None
80+
if actual_err != want_err:
81+
raise AssertionError(
82+
f"{scenario_name} / {request_id}: expected error=\"{want_err}\", got: {body!r}"
83+
)
84+
return
85+
86+
if "length" in expect_body:
87+
want_len = expect_body["length"]
88+
if not isinstance(body, list):
89+
raise AssertionError(
90+
f"{scenario_name} / {request_id}: expected array, got: {body!r}"
91+
)
92+
if len(body) != want_len:
93+
raise AssertionError(
94+
f"{scenario_name} / {request_id}: expected length={want_len}, got {len(body)}"
95+
)
96+
97+
if "ids" in expect_body:
98+
want_ids = expect_body["ids"]
99+
if not isinstance(body, list):
100+
raise AssertionError(
101+
f"{scenario_name} / {request_id}: expected array, got: {body!r}"
102+
)
103+
actual_ids = [row.get("id") if isinstance(row, dict) else None for row in body]
104+
if actual_ids != want_ids:
105+
raise AssertionError(
106+
f"{scenario_name} / {request_id}: expected ids {want_ids}, got {actual_ids}"
107+
)
108+
109+
if "names" in expect_body:
110+
want_names = expect_body["names"]
111+
if not isinstance(body, list):
112+
raise AssertionError(
113+
f"{scenario_name} / {request_id}: expected array, got: {body!r}"
114+
)
115+
actual_names = [row.get("name") if isinstance(row, dict) else None for row in body]
116+
if actual_names != want_names:
117+
raise AssertionError(
118+
f"{scenario_name} / {request_id}: expected names {want_names}, got {actual_names}"
119+
)
120+
121+
if "row" in expect_body:
122+
want_row = expect_body["row"]
123+
if not isinstance(body, dict):
124+
raise AssertionError(
125+
f"{scenario_name} / {request_id}: expected object, got: {body!r}"
126+
)
127+
for k, v in want_row.items():
128+
actual = body.get(k)
129+
if not _structural_equals(actual, v):
130+
raise AssertionError(
131+
f"{scenario_name} / {request_id}: expected row.{k}={v!r}, got {actual!r}"
132+
)
133+
134+
if expect_body.get("hasId") is True:
135+
if not isinstance(body, dict):
136+
raise AssertionError(
137+
f"{scenario_name} / {request_id}: expected object, got: {body!r}"
138+
)
139+
ident = body.get("id")
140+
if not isinstance(ident, (int, float)) or isinstance(ident, bool):
141+
raise AssertionError(
142+
f"{scenario_name} / {request_id}: expected numeric id in body, got: {body!r}"
143+
)
144+
145+
146+
def _structural_equals(a: Any, b: Any) -> bool:
147+
"""Recursively compare; ints and floats with equal long value match."""
148+
if a is b:
149+
return True
150+
if a is None or b is None:
151+
return a is None and b is None
152+
# Numbers: compare as int when both are numeric (mirror Java's longValue compare).
153+
if isinstance(a, (int, float)) and not isinstance(a, bool) \
154+
and isinstance(b, (int, float)) and not isinstance(b, bool):
155+
return int(a) == int(b)
156+
if isinstance(a, list) and isinstance(b, list):
157+
if len(a) != len(b):
158+
return False
159+
return all(_structural_equals(x, y) for x, y in zip(a, b))
160+
if isinstance(a, dict) and isinstance(b, dict):
161+
if set(a.keys()) != set(b.keys()):
162+
return False
163+
return all(_structural_equals(a[k], b[k]) for k in a)
164+
return a == b
165+
166+
167+
def _is_empty(body: Any) -> bool:
168+
if body is None:
169+
return True
170+
if isinstance(body, (str, list, dict)):
171+
return len(body) == 0
172+
return False

0 commit comments

Comments
 (0)