11"""Introspection over the public client surface.
22
3- Every resource method declares its HTTP verb and path template on the first line
4- of its docstring, and its inputs in its signature. These helpers read both so the
5- suite can exercise all endpoints without checked-in snapshots .
3+ HTTP verbs, paths, required inputs, and response kinds come from a frozen fixture.
4+ Method signatures and docstrings are checked against that independent contract so
5+ an implementation and its documentation cannot drift together unnoticed .
66"""
77
88from __future__ import annotations
1313from dataclasses import dataclass
1414from functools import cached_property
1515from importlib import import_module
16+ from pathlib import Path
1617from typing import Any , Callable , Iterator
1718
1819import httpx
2526DECLARATION = re .compile (r"^(GET|POST|PUT|PATCH|DELETE) (/[^\s.]*)\." )
2627CONTROL_PARAMS = ("extra_headers" , "extra_query" , "extra_body" , "timeout" )
2728HANDWRITTEN_RESOURCE_METHODS = {"sessions.events.resumable_stream" }
29+ CONTRACT_FIXTURE = json .loads ((Path (__file__ ).parent / "fixtures" / "api-contracts.json" ).read_text ())
30+ CONTRACT_ROWS = CONTRACT_FIXTURE ["contracts" ]
31+ CONTRACTS = {row ["id" ]: row for row in CONTRACT_ROWS }
32+ assert len (CONTRACTS ) == len (CONTRACT_ROWS ), "duplicate API contract fixture ID"
2833CLIENTS = {
2934 ("forward" , False ): Forward ,
3035 ("forward" , True ): AsyncForward ,
@@ -129,28 +134,30 @@ def method_names(client: Any) -> set[str]:
129134
130135
131136def _endpoint (mode : str , attribute : str , function : Any ) -> Endpoint :
132- summary = (function .__doc__ or "" ).strip ()
133- declaration = DECLARATION .match (summary )
134- assert declaration , f"{ mode } .{ attribute } must document its route on the first docstring line: { summary !r} "
135- verb , template = declaration .groups ()
137+ endpoint_id = f"{ mode } .{ attribute } "
138+ contract = CONTRACTS .get (endpoint_id )
139+ assert contract , f"{ endpoint_id } is missing from tests/fixtures/api-contracts.json"
136140 signature = inspect .signature (function )
137141 arguments = {
138142 name : _value (name , str (parameter .annotation ))
139143 for name , parameter in signature .parameters .items ()
140144 if name not in ("self" , * CONTROL_PARAMS ) and parameter .default is inspect .Parameter .empty
141145 }
142- placeholders = tuple (re .findall (r"\{(\w+)\}" , template ))
146+ assert sorted (arguments ) == contract ["required" ], f"{ endpoint_id } required parameters drifted"
147+ placeholders = tuple (re .findall (r"\{(\w+)\}" , contract ["path" ]))
143148 missing = [name for name in placeholders if name not in arguments ]
144- assert not missing , f"{ mode } . { attribute } does not accept path parameters { missing } "
145- return Endpoint (
149+ assert not missing , f"{ endpoint_id } does not accept path parameters { missing } "
150+ endpoint = Endpoint (
146151 mode = mode ,
147152 attribute = attribute ,
148- verb = verb ,
149- template = template ,
153+ verb = contract [ "method" ] ,
154+ template = contract [ "path" ] ,
150155 returns = str (signature .return_annotation ),
151156 arguments = arguments ,
152157 path_params = placeholders ,
153158 )
159+ assert endpoint .kind == contract ["kind" ], f"{ endpoint_id } response kind drifted"
160+ return endpoint
154161
155162
156163def endpoints () -> list [Endpoint ]:
@@ -163,6 +170,11 @@ def endpoints() -> list[Endpoint]:
163170 if attribute not in HANDWRITTEN_RESOURCE_METHODS
164171 )
165172 client .close ()
173+ actual_ids = {endpoint .id for endpoint in found }
174+ assert actual_ids == set (CONTRACTS ), (
175+ f"API contract fixture drift: missing={ sorted (actual_ids - set (CONTRACTS ))} , "
176+ f"orphaned={ sorted (set (CONTRACTS ) - actual_ids )} "
177+ )
166178 return sorted (found , key = lambda endpoint : endpoint .id )
167179
168180
0 commit comments