@@ -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+
127224def 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