Skip to content

Commit 6c8ce82

Browse files
committed
Enforce < 400 status assertions, Pydantic response validation, and pytest addopts override in generated SDKs
- Emit addopts = "-o addopts=" and include pytest in [project].dependencies within openapi_client.scaffolding.scaffold_package so running pytest or uv run pytest inside generated SDK directories overrides parent repository coverage flags and resolves pytest in isolated environments. - Add schema_ (alias="schema") to openapi_client.models.Response and emit Pydantic BaseModel classes in openapi_client.classes.emit.emit_classes whenever Schema.properties is defined, supporting both OpenAPI 3.x media-type schemas and Swagger 2.0 response schemas. - Implement _extract_response_model in openapi_client.tests.emit and update emit_operation_test and emit_tests to import generated models (from models import *), assert response.status < 400, and validate single-object and array JSON response payloads via <Model>.model_validate(...). - Add unit tests for response model extraction and an end-to-end integration test executing uv run pytest -c pyproject.toml test/ against a live local HTTP server inside a freshly generated SDK directory.
1 parent fdbb69e commit 6c8ce82

5 files changed

Lines changed: 455 additions & 43 deletions

File tree

‎src/openapi_client/classes/emit.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,10 @@ def emit_classes(schemas: Mapping[str, SchemaOrReference]) -> list[cst.ClassDef]
141141
class_defs: list[cst.ClassDef] = []
142142
if schemas:
143143
for name, schema in schemas.items():
144-
if isinstance(schema, Schema) and getattr(schema, "type", None) == "object":
144+
if isinstance(schema, Schema) and (
145+
getattr(schema, "type", None) == "object"
146+
or getattr(schema, "properties", None) is not None
147+
):
145148
class_defs.append(emit_class(name, schema))
146149
return class_defs
147150

‎src/openapi_client/models.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ class Response(OpenAPIBase):
269269
headers: dict[str, Header | Reference] | None = None
270270
content: dict[str, MediaType | Reference] | None = None
271271
links: dict[str, Link | Reference] | None = None
272+
schema_: SchemaOrReference | None = Field(None, alias="schema")
272273

273274

274275
class Responses(OpenAPIBase):

‎src/openapi_client/scaffolding.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def scaffold_package(out_dir: Path, is_server: bool = False) -> None:
4141
' "pydantic-core==2.41.5",',
4242
' "urllib3",',
4343
' "eval_type_backport",',
44+
' "pytest",',
4445
]
4546

4647
if is_server:
@@ -50,7 +51,6 @@ def scaffold_package(out_dir: Path, is_server: bool = False) -> None:
5051
' "uvicorn",',
5152
' "sqlalchemy",',
5253
' "faker",',
53-
' "pytest",',
5454
' "httpx",',
5555
]
5656
)
@@ -82,7 +82,7 @@ def scaffold_package(out_dir: Path, is_server: bool = False) -> None:
8282
packages = ["src"]
8383
8484
[tool.pytest.ini_options]
85-
addopts = ""
85+
addopts = "-o addopts="
8686
testpaths = {testpaths_str}
8787
""",
8888
encoding="utf-8",

‎src/openapi_client/tests/emit.py‎

Lines changed: 150 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,103 @@ def get_stub_body(schema_obj: Any, spec: OpenAPI | None = None) -> cst.BaseExpre
124124
return cst.Dict(elements=[])
125125

126126

127+
_PRIMITIVE_ANNOTATIONS = frozenset(
128+
{"str", "int", "float", "bool", "Any", "list", "dict", "dict[str, Any]"}
129+
)
130+
131+
132+
def _extract_response_model(
133+
operation: Operation, spec: OpenAPI | None = None
134+
) -> tuple[str | None, bool]:
135+
"""Extract the Pydantic response model name and whether it is a list from an operation.
136+
137+
Args:
138+
operation: The OpenAPI Operation object.
139+
spec: Optional full OpenAPI specification for `$ref` and schema lookup.
140+
141+
Returns:
142+
A tuple of `(model_name, is_list)` where `model_name` is `None` if no Pydantic
143+
response model applies.
144+
"""
145+
responses = getattr(operation, "responses", None)
146+
if not isinstance(responses, dict) or not responses:
147+
return None, False
148+
149+
available_models: set[str] | None = None
150+
if spec is not None and spec.components and spec.components.schemas:
151+
available_models = {
152+
name
153+
for name, schema in spec.components.schemas.items()
154+
if getattr(schema, "type", None) == "object"
155+
or getattr(schema, "properties", None) is not None
156+
}
157+
158+
ordered_keys = ["200", "201", "202", "default"] + [
159+
str(k)
160+
for k in responses
161+
if str(k) not in ("200", "201", "202", "default") and str(k).startswith("2")
162+
]
163+
for code in ordered_keys:
164+
resp = responses.get(code)
165+
if resp is None:
166+
continue
167+
ref_str = (
168+
resp.get("$ref") if isinstance(resp, dict) else getattr(resp, "ref", None)
169+
)
170+
if (
171+
isinstance(ref_str, str)
172+
and "/" in ref_str
173+
and spec is not None
174+
and spec.components
175+
and spec.components.responses
176+
):
177+
ref_name = ref_str.split("/")[-1]
178+
resp = spec.components.responses.get(ref_name, resp)
179+
180+
schema_obj: Any = None
181+
content = (
182+
resp.get("content")
183+
if isinstance(resp, dict)
184+
else getattr(resp, "content", None)
185+
)
186+
if isinstance(content, dict) and content:
187+
media = (
188+
content["application/json"]
189+
if "application/json" in content
190+
else next(iter(content.values()))
191+
)
192+
schema_obj = (
193+
media.get("schema")
194+
if isinstance(media, dict)
195+
else getattr(media, "schema_", getattr(media, "schema", None))
196+
)
197+
if schema_obj is None:
198+
schema_obj = (
199+
resp.get("schema")
200+
if isinstance(resp, dict)
201+
else getattr(resp, "schema_", None)
202+
)
203+
204+
if schema_obj is not None:
205+
ann = get_annotation_for_schema(schema_obj)
206+
if ann.startswith("list[") and ann.endswith("]"):
207+
inner = ann[5:-1].strip()
208+
if (
209+
inner.isidentifier()
210+
and inner not in _PRIMITIVE_ANNOTATIONS
211+
and (available_models is None or inner in available_models)
212+
):
213+
return inner, True
214+
elif (
215+
ann.isidentifier()
216+
and ann not in _PRIMITIVE_ANNOTATIONS
217+
and (available_models is None or ann in available_models)
218+
):
219+
return ann, False
220+
221+
return None, False
222+
223+
127224
def emit_operation_test(
128225
method: str,
129226
path: str,
@@ -216,40 +313,50 @@ def emit_operation_test(
216313
)
217314

218315
if composable:
219-
responses = getattr(operation, "responses", None) or {}
220-
has_only_200 = (
221-
"200" in responses
222-
and method.lower() == "get"
223-
and "{" not in path
224-
and not any(str(code).startswith("4") for code in responses)
225-
)
226-
227-
status_expr = cst.Call(
228-
func=cst.Name("getattr"),
229-
args=[
230-
cst.Arg(cst.Name("response")),
231-
cst.Arg(cst.SimpleString('"status"')),
232-
cst.Arg(cst.Integer("200")),
316+
assertion_test = cst.Comparison(
317+
left=cst.Attribute(value=cst.Name("response"), attr=cst.Name("status")),
318+
comparisons=[
319+
cst.ComparisonTarget(
320+
operator=cst.LessThan(), comparator=cst.Integer("400")
321+
)
233322
],
234323
)
235-
if has_only_200:
236-
assertion_test = cst.Comparison(
237-
left=status_expr,
238-
comparisons=[
239-
cst.ComparisonTarget(
240-
operator=cst.Equal(), comparator=cst.Integer("200")
241-
)
242-
],
243-
)
324+
325+
model_name, is_list = _extract_response_model(operation, spec)
326+
if model_name is not None and is_list:
327+
validation_block = f"""if getattr(response, "data", None):
328+
payload = response.json() if hasattr(response, "json") else json.loads(response.data.decode("utf-8"))
329+
assert payload is not None
330+
if isinstance(payload, list):
331+
for item in payload:
332+
if isinstance(item, dict):
333+
assert "sabotage" not in item
334+
{model_name}.model_validate(item)
335+
else:
336+
if isinstance(payload, dict):
337+
assert "sabotage" not in payload
338+
{model_name}.model_validate(payload)
339+
elif isinstance(response, dict):
340+
assert "sabotage" not in response
341+
{model_name}.model_validate(response)"""
342+
elif model_name is not None:
343+
validation_block = f"""if getattr(response, "data", None):
344+
payload = response.json() if hasattr(response, "json") else json.loads(response.data.decode("utf-8"))
345+
assert payload is not None
346+
if isinstance(payload, dict):
347+
assert "sabotage" not in payload
348+
{model_name}.model_validate(payload)
349+
elif isinstance(response, dict):
350+
assert "sabotage" not in response
351+
{model_name}.model_validate(response)"""
244352
else:
245-
assertion_test = cst.Comparison(
246-
left=status_expr,
247-
comparisons=[
248-
cst.ComparisonTarget(
249-
operator=cst.LessThan(), comparator=cst.Integer("500")
250-
)
251-
],
252-
)
353+
validation_block = """if getattr(response, "data", None):
354+
payload = response.json() if hasattr(response, "json") else json.loads(response.data.decode("utf-8"))
355+
assert payload is not None
356+
if isinstance(payload, dict):
357+
assert "sabotage" not in payload
358+
elif isinstance(response, dict):
359+
assert "sabotage" not in response"""
253360

254361
body: list[cst.BaseStatement] = [
255362
cst.SimpleStatementLine(
@@ -264,15 +371,7 @@ def emit_operation_test(
264371
cst.SimpleStatementLine(
265372
[cst.Import(names=[cst.ImportAlias(name=cst.Name("json"))])]
266373
),
267-
cst.parse_statement(
268-
"""if getattr(response, "data", None):
269-
payload = response.json() if hasattr(response, "json") else json.loads(response.data.decode("utf-8"))
270-
assert payload is not None
271-
if isinstance(payload, dict):
272-
assert "sabotage" not in payload
273-
elif isinstance(response, dict):
274-
assert "sabotage" not in response"""
275-
),
374+
cst.parse_statement(validation_block),
276375
]
277376

278377
params = cst.Parameters(params=[cst.Param(name=cst.Name("client"))])
@@ -434,6 +533,17 @@ def emit_tests(spec: OpenAPI, composable: bool = False) -> cst.Module:
434533
]
435534
)
436535
)
536+
if spec.components and spec.components.schemas:
537+
body.append(
538+
cst.SimpleStatementLine(
539+
[
540+
cst.ImportFrom(
541+
module=cst.Name("models"),
542+
names=cst.ImportStar(),
543+
)
544+
]
545+
)
546+
)
437547
body.append(cst.EmptyLine())
438548

439549
if composable:

0 commit comments

Comments
 (0)