pyopenapi-gen 2.7.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. pyopenapi_gen/__init__.py +224 -0
  2. pyopenapi_gen/__main__.py +6 -0
  3. pyopenapi_gen/cli.py +62 -0
  4. pyopenapi_gen/context/CLAUDE.md +284 -0
  5. pyopenapi_gen/context/file_manager.py +52 -0
  6. pyopenapi_gen/context/import_collector.py +382 -0
  7. pyopenapi_gen/context/render_context.py +726 -0
  8. pyopenapi_gen/core/CLAUDE.md +224 -0
  9. pyopenapi_gen/core/__init__.py +0 -0
  10. pyopenapi_gen/core/auth/base.py +22 -0
  11. pyopenapi_gen/core/auth/plugins.py +89 -0
  12. pyopenapi_gen/core/cattrs_converter.py +810 -0
  13. pyopenapi_gen/core/exceptions.py +20 -0
  14. pyopenapi_gen/core/http_status_codes.py +218 -0
  15. pyopenapi_gen/core/http_transport.py +222 -0
  16. pyopenapi_gen/core/loader/__init__.py +12 -0
  17. pyopenapi_gen/core/loader/loader.py +174 -0
  18. pyopenapi_gen/core/loader/operations/__init__.py +12 -0
  19. pyopenapi_gen/core/loader/operations/parser.py +161 -0
  20. pyopenapi_gen/core/loader/operations/post_processor.py +62 -0
  21. pyopenapi_gen/core/loader/operations/request_body.py +90 -0
  22. pyopenapi_gen/core/loader/parameters/__init__.py +10 -0
  23. pyopenapi_gen/core/loader/parameters/parser.py +186 -0
  24. pyopenapi_gen/core/loader/responses/__init__.py +10 -0
  25. pyopenapi_gen/core/loader/responses/parser.py +111 -0
  26. pyopenapi_gen/core/loader/schemas/__init__.py +11 -0
  27. pyopenapi_gen/core/loader/schemas/extractor.py +275 -0
  28. pyopenapi_gen/core/pagination.py +64 -0
  29. pyopenapi_gen/core/parsing/__init__.py +13 -0
  30. pyopenapi_gen/core/parsing/common/__init__.py +1 -0
  31. pyopenapi_gen/core/parsing/common/ref_resolution/__init__.py +9 -0
  32. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/__init__.py +0 -0
  33. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/cyclic_properties.py +66 -0
  34. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/direct_cycle.py +33 -0
  35. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/existing_schema.py +22 -0
  36. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/list_response.py +54 -0
  37. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/missing_ref.py +52 -0
  38. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/new_schema.py +50 -0
  39. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/stripped_suffix.py +51 -0
  40. pyopenapi_gen/core/parsing/common/ref_resolution/resolve_schema_ref.py +86 -0
  41. pyopenapi_gen/core/parsing/common/type_parser.py +73 -0
  42. pyopenapi_gen/core/parsing/context.py +187 -0
  43. pyopenapi_gen/core/parsing/cycle_helpers.py +126 -0
  44. pyopenapi_gen/core/parsing/keywords/__init__.py +1 -0
  45. pyopenapi_gen/core/parsing/keywords/all_of_parser.py +81 -0
  46. pyopenapi_gen/core/parsing/keywords/any_of_parser.py +84 -0
  47. pyopenapi_gen/core/parsing/keywords/array_items_parser.py +72 -0
  48. pyopenapi_gen/core/parsing/keywords/one_of_parser.py +77 -0
  49. pyopenapi_gen/core/parsing/keywords/properties_parser.py +98 -0
  50. pyopenapi_gen/core/parsing/schema_finalizer.py +169 -0
  51. pyopenapi_gen/core/parsing/schema_parser.py +804 -0
  52. pyopenapi_gen/core/parsing/transformers/__init__.py +0 -0
  53. pyopenapi_gen/core/parsing/transformers/inline_enum_extractor.py +285 -0
  54. pyopenapi_gen/core/parsing/transformers/inline_object_promoter.py +120 -0
  55. pyopenapi_gen/core/parsing/unified_cycle_detection.py +293 -0
  56. pyopenapi_gen/core/postprocess_manager.py +260 -0
  57. pyopenapi_gen/core/spec_fetcher.py +148 -0
  58. pyopenapi_gen/core/streaming_helpers.py +84 -0
  59. pyopenapi_gen/core/telemetry.py +69 -0
  60. pyopenapi_gen/core/utils.py +456 -0
  61. pyopenapi_gen/core/warning_collector.py +83 -0
  62. pyopenapi_gen/core/writers/code_writer.py +135 -0
  63. pyopenapi_gen/core/writers/documentation_writer.py +222 -0
  64. pyopenapi_gen/core/writers/line_writer.py +217 -0
  65. pyopenapi_gen/core/writers/python_construct_renderer.py +321 -0
  66. pyopenapi_gen/core_package_template/README.md +21 -0
  67. pyopenapi_gen/emit/models_emitter.py +143 -0
  68. pyopenapi_gen/emitters/CLAUDE.md +286 -0
  69. pyopenapi_gen/emitters/client_emitter.py +51 -0
  70. pyopenapi_gen/emitters/core_emitter.py +181 -0
  71. pyopenapi_gen/emitters/docs_emitter.py +44 -0
  72. pyopenapi_gen/emitters/endpoints_emitter.py +247 -0
  73. pyopenapi_gen/emitters/exceptions_emitter.py +187 -0
  74. pyopenapi_gen/emitters/mocks_emitter.py +185 -0
  75. pyopenapi_gen/emitters/models_emitter.py +426 -0
  76. pyopenapi_gen/generator/CLAUDE.md +352 -0
  77. pyopenapi_gen/generator/client_generator.py +567 -0
  78. pyopenapi_gen/generator/exceptions.py +7 -0
  79. pyopenapi_gen/helpers/CLAUDE.md +325 -0
  80. pyopenapi_gen/helpers/__init__.py +1 -0
  81. pyopenapi_gen/helpers/endpoint_utils.py +532 -0
  82. pyopenapi_gen/helpers/type_cleaner.py +334 -0
  83. pyopenapi_gen/helpers/type_helper.py +112 -0
  84. pyopenapi_gen/helpers/type_resolution/__init__.py +1 -0
  85. pyopenapi_gen/helpers/type_resolution/array_resolver.py +57 -0
  86. pyopenapi_gen/helpers/type_resolution/composition_resolver.py +79 -0
  87. pyopenapi_gen/helpers/type_resolution/finalizer.py +105 -0
  88. pyopenapi_gen/helpers/type_resolution/named_resolver.py +172 -0
  89. pyopenapi_gen/helpers/type_resolution/object_resolver.py +216 -0
  90. pyopenapi_gen/helpers/type_resolution/primitive_resolver.py +109 -0
  91. pyopenapi_gen/helpers/type_resolution/resolver.py +47 -0
  92. pyopenapi_gen/helpers/url_utils.py +14 -0
  93. pyopenapi_gen/http_types.py +20 -0
  94. pyopenapi_gen/ir.py +165 -0
  95. pyopenapi_gen/py.typed +1 -0
  96. pyopenapi_gen/types/CLAUDE.md +140 -0
  97. pyopenapi_gen/types/__init__.py +11 -0
  98. pyopenapi_gen/types/contracts/__init__.py +13 -0
  99. pyopenapi_gen/types/contracts/protocols.py +106 -0
  100. pyopenapi_gen/types/contracts/types.py +28 -0
  101. pyopenapi_gen/types/resolvers/__init__.py +7 -0
  102. pyopenapi_gen/types/resolvers/reference_resolver.py +71 -0
  103. pyopenapi_gen/types/resolvers/response_resolver.py +177 -0
  104. pyopenapi_gen/types/resolvers/schema_resolver.py +498 -0
  105. pyopenapi_gen/types/services/__init__.py +5 -0
  106. pyopenapi_gen/types/services/type_service.py +165 -0
  107. pyopenapi_gen/types/strategies/__init__.py +5 -0
  108. pyopenapi_gen/types/strategies/response_strategy.py +310 -0
  109. pyopenapi_gen/visit/CLAUDE.md +272 -0
  110. pyopenapi_gen/visit/client_visitor.py +477 -0
  111. pyopenapi_gen/visit/docs_visitor.py +38 -0
  112. pyopenapi_gen/visit/endpoint/__init__.py +1 -0
  113. pyopenapi_gen/visit/endpoint/endpoint_visitor.py +292 -0
  114. pyopenapi_gen/visit/endpoint/generators/__init__.py +1 -0
  115. pyopenapi_gen/visit/endpoint/generators/docstring_generator.py +123 -0
  116. pyopenapi_gen/visit/endpoint/generators/endpoint_method_generator.py +222 -0
  117. pyopenapi_gen/visit/endpoint/generators/mock_generator.py +140 -0
  118. pyopenapi_gen/visit/endpoint/generators/overload_generator.py +252 -0
  119. pyopenapi_gen/visit/endpoint/generators/request_generator.py +103 -0
  120. pyopenapi_gen/visit/endpoint/generators/response_handler_generator.py +705 -0
  121. pyopenapi_gen/visit/endpoint/generators/signature_generator.py +83 -0
  122. pyopenapi_gen/visit/endpoint/generators/url_args_generator.py +207 -0
  123. pyopenapi_gen/visit/endpoint/processors/__init__.py +1 -0
  124. pyopenapi_gen/visit/endpoint/processors/import_analyzer.py +78 -0
  125. pyopenapi_gen/visit/endpoint/processors/parameter_processor.py +171 -0
  126. pyopenapi_gen/visit/exception_visitor.py +90 -0
  127. pyopenapi_gen/visit/model/__init__.py +0 -0
  128. pyopenapi_gen/visit/model/alias_generator.py +93 -0
  129. pyopenapi_gen/visit/model/dataclass_generator.py +553 -0
  130. pyopenapi_gen/visit/model/enum_generator.py +212 -0
  131. pyopenapi_gen/visit/model/model_visitor.py +198 -0
  132. pyopenapi_gen/visit/visitor.py +97 -0
  133. pyopenapi_gen-2.7.2.dist-info/METADATA +1169 -0
  134. pyopenapi_gen-2.7.2.dist-info/RECORD +137 -0
  135. pyopenapi_gen-2.7.2.dist-info/WHEEL +4 -0
  136. pyopenapi_gen-2.7.2.dist-info/entry_points.txt +2 -0
  137. pyopenapi_gen-2.7.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,705 @@
1
+ """
2
+ Helper class for generating response handling logic for an endpoint method.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from typing import TYPE_CHECKING, Any, TypedDict
9
+
10
+ from pyopenapi_gen.core.http_status_codes import get_exception_class_name
11
+ from pyopenapi_gen.core.writers.code_writer import CodeWriter
12
+ from pyopenapi_gen.helpers.endpoint_utils import (
13
+ _get_primary_response,
14
+ )
15
+ from pyopenapi_gen.types.services.type_service import UnifiedTypeService
16
+ from pyopenapi_gen.types.strategies.response_strategy import ResponseStrategy
17
+
18
+ if TYPE_CHECKING:
19
+ from pyopenapi_gen import IROperation, IRResponse, IRSchema
20
+ from pyopenapi_gen.context.render_context import RenderContext
21
+ else:
22
+ # For runtime, we need to import for TypedDict
23
+ from pyopenapi_gen import IRResponse, IRSchema
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class StatusCase(TypedDict):
29
+ """Type definition for status code case data."""
30
+
31
+ status_code: int
32
+ type: str # 'primary_success', 'success', or 'error'
33
+ return_type: str
34
+ response_ir: IRResponse
35
+
36
+
37
+ class DefaultCase(TypedDict):
38
+ """Type definition for default case data."""
39
+
40
+ response_ir: IRResponse
41
+ return_type: str
42
+
43
+
44
+ class EndpointResponseHandlerGenerator:
45
+ """Generates the response handling logic for an endpoint method."""
46
+
47
+ def __init__(self, schemas: dict[str, Any] | None = None) -> None:
48
+ self.schemas: dict[str, Any] = schemas or {}
49
+
50
+ def _register_cattrs_import(self, context: RenderContext) -> None:
51
+ """Register the cattrs structure_from_dict import."""
52
+ context.add_import(f"{context.core_package_name}.cattrs_converter", "structure_from_dict")
53
+
54
+ def _is_type_alias_to_array(self, type_name: str) -> bool:
55
+ """
56
+ Check if a type name corresponds to a type alias that resolves to a List/array type.
57
+
58
+ This helps distinguish between:
59
+ - Type aliases: AgentHistoryListResponse = List[AgentHistory] (should use array deserialization)
60
+ - Dataclasses: class AgentHistoryListResponse: ... (should use structure_from_dict())
61
+
62
+ Args:
63
+ type_name: The Python type name (e.g., "AgentHistoryListResponse")
64
+
65
+ Returns:
66
+ True if this is a type alias that resolves to List[SomeType]
67
+ """
68
+ # Extract base type name without generics
69
+ base_type = type_name
70
+ if "[" in base_type:
71
+ base_type = base_type[: base_type.find("[")]
72
+
73
+ # Look up the schema for this type name
74
+ if base_type in self.schemas:
75
+ schema = self.schemas[base_type]
76
+ # Check if it's a type alias using ModelVisitor's logic:
77
+ # - Has a name
78
+ # - No properties (not an object with fields)
79
+ # - Not an enum
80
+ # - Type is not "object" (which would be a dataclass)
81
+ # - Type is "array" (indicating it's an array type alias)
82
+ is_type_alias = bool(
83
+ getattr(schema, "name", None)
84
+ and not getattr(schema, "properties", None)
85
+ and not getattr(schema, "enum", None)
86
+ and getattr(schema, "type", None) != "object"
87
+ )
88
+ is_array_type = getattr(schema, "type", None) == "array"
89
+ return is_type_alias and is_array_type
90
+
91
+ return False
92
+
93
+ def _is_type_alias_to_primitive(self, type_name: str) -> bool:
94
+ """
95
+ Check if a type name corresponds to a type alias that resolves to a primitive type.
96
+
97
+ This helps distinguish between:
98
+ - Type aliases: StringAlias = str (should use cast())
99
+ - Dataclasses: class MyModel: ... (should use .from_dict())
100
+
101
+ Args:
102
+ type_name: The Python type name (e.g., "StringAlias")
103
+
104
+ Returns:
105
+ True if this is a type alias that resolves to a primitive type (str, int, float, bool)
106
+ """
107
+ # Extract base type name without generics
108
+ base_type = type_name
109
+ if "[" in base_type:
110
+ base_type = base_type[: base_type.find("[")]
111
+
112
+ # Look up the schema for this type name
113
+ if base_type in self.schemas:
114
+ schema = self.schemas[base_type]
115
+ # Check if it's a type alias using ModelVisitor's logic:
116
+ # - Has a name
117
+ # - No properties (not an object with fields)
118
+ # - Not an enum
119
+ # - Type is not "object" (which would be a dataclass)
120
+ # - Type is a primitive (string, integer, number, boolean)
121
+ is_type_alias = bool(
122
+ getattr(schema, "name", None)
123
+ and not getattr(schema, "properties", None)
124
+ and not getattr(schema, "enum", None)
125
+ and getattr(schema, "type", None) != "object"
126
+ )
127
+ is_primitive_type = getattr(schema, "type", None) in ("string", "integer", "number", "boolean")
128
+ return is_type_alias and is_primitive_type
129
+
130
+ return False
131
+
132
+ def _extract_array_item_type(self, type_name: str) -> str:
133
+ """
134
+ Extract the item type from an array type or type alias.
135
+
136
+ Args:
137
+ type_name: Type name like "List[ItemType]" or "AgentListResponse" (alias to List[Item])
138
+
139
+ Returns:
140
+ The item type as a string (e.g., "ItemType", "AgentListResponseItem")
141
+ """
142
+ # If it's already in List[X] format, extract X
143
+ if type_name.startswith("List[") or type_name.startswith("list["):
144
+ start_bracket = type_name.find("[")
145
+ end_bracket = type_name.rfind("]")
146
+ return type_name[start_bracket + 1 : end_bracket].strip()
147
+
148
+ # If it's a type alias, look up the schema and get the items type
149
+ base_type = type_name
150
+ if "[" in base_type:
151
+ base_type = base_type[: base_type.find("[")]
152
+
153
+ if base_type in self.schemas:
154
+ schema = self.schemas[base_type]
155
+ if getattr(schema, "type", None) == "array" and getattr(schema, "items", None):
156
+ # Get items schema and resolve its type
157
+ items_schema = schema.items
158
+ # Check if items is a reference or inline schema
159
+ if hasattr(items_schema, "name") and items_schema.name:
160
+ return str(items_schema.name)
161
+ elif hasattr(items_schema, "type"):
162
+ # Inline schema - map to Python type
163
+ return str(items_schema.type)
164
+
165
+ # Fallback: return the original type name (might be primitive List[str])
166
+ return type_name
167
+
168
+ def _is_dataclass_type(self, type_name: str) -> bool:
169
+ """
170
+ Check if a type name refers to a dataclass (not a primitive or type alias).
171
+
172
+ This is used to determine if a type needs structure_from_dict() deserialisation.
173
+
174
+ Args:
175
+ type_name: The Python type name (e.g., "User", "AgentListResponseItem")
176
+
177
+ Returns:
178
+ True if the type is a dataclass (has properties or type="object")
179
+ """
180
+ # Skip obvious primitives and built-ins
181
+ if type_name in {
182
+ "str",
183
+ "int",
184
+ "float",
185
+ "bool",
186
+ "bytes",
187
+ "None",
188
+ "Any",
189
+ "Dict",
190
+ "List",
191
+ "dict",
192
+ "list",
193
+ "tuple",
194
+ }:
195
+ return False
196
+
197
+ # Extract base type name without generics
198
+ base_type = type_name
199
+ if "[" in base_type:
200
+ base_type = base_type[: base_type.find("[")]
201
+
202
+ # Look up in schemas
203
+ if base_type in self.schemas:
204
+ schema = self.schemas[base_type]
205
+ # Dataclasses have properties or are object type
206
+ return getattr(schema, "type", None) == "object" or bool(getattr(schema, "properties", None))
207
+
208
+ # Heuristic: uppercase names are likely models (not primitives)
209
+ return base_type[0].isupper() and base_type not in {"Dict", "List", "Union", "Tuple", "Optional"}
210
+
211
+ def _should_use_cattrs_structure(self, type_name: str) -> bool:
212
+ """
213
+ Determine if a type should use cattrs deserialization.
214
+
215
+ Args:
216
+ type_name: The Python type name (e.g., "User", "List[User]", "User | None")
217
+
218
+ Returns:
219
+ True if the type should use structure_from_dict() deserialization
220
+ """
221
+ # Extract the base type name from complex types
222
+ base_type = type_name
223
+
224
+ # Handle List[Type], Type | None, etc.
225
+ if "[" in base_type and "]" in base_type:
226
+ # Extract the inner type from List[Type], Type | None, etc.
227
+ start_bracket = base_type.find("[")
228
+ end_bracket = base_type.rfind("]")
229
+ inner_type = base_type[start_bracket + 1 : end_bracket]
230
+
231
+ # For Union types like User | None -> Union[User, None], take the first type
232
+ if ", " in inner_type:
233
+ inner_type = inner_type.split(", ")[0]
234
+
235
+ base_type = inner_type.strip()
236
+
237
+ # Skip primitive types and built-ins (both uppercase and lowercase)
238
+ if base_type in {
239
+ "str",
240
+ "int",
241
+ "float",
242
+ "bool",
243
+ "bytes",
244
+ "None",
245
+ "Any",
246
+ "Dict",
247
+ "List",
248
+ "dict",
249
+ "list",
250
+ "tuple",
251
+ }:
252
+ return False
253
+
254
+ # Skip typing constructs
255
+ # Note: Modern Python 3.10+ uses | None instead of Optional[X]
256
+ if base_type.startswith(("dict[", "List[", "Union[", "Tuple[", "dict[", "list[", "tuple[")):
257
+ return False
258
+
259
+ # Check if this is a primitive type alias - these should use cast()
260
+ if self._is_type_alias_to_primitive(type_name):
261
+ return False
262
+
263
+ # NEW LOGIC: For array type aliases, check if the item type needs deserialisation
264
+ if self._is_type_alias_to_array(type_name):
265
+ # Extract item type from List[ItemType] or from the type alias schema
266
+ item_type = self._extract_array_item_type(type_name)
267
+ # Check if item type is a dataclass that needs .from_dict()
268
+ return self._is_dataclass_type(item_type)
269
+
270
+ # All custom model types use cattrs via Meta class for automatic field mapping
271
+ # Check if it's a model type (contains a dot indicating it's from models package)
272
+ # or if it's a simple class name that's likely a generated model (starts with uppercase)
273
+ return "." in base_type or (
274
+ base_type[0].isupper() and base_type not in {"Dict", "List", "Union", "Tuple", "dict", "list", "tuple"}
275
+ )
276
+
277
+ def _register_imports_for_type(self, return_type: str, context: RenderContext) -> None:
278
+ """
279
+ Register necessary imports for a return type, including item types for arrays.
280
+
281
+ Args:
282
+ return_type: The return type (e.g., "AgentListResponse", "List[User]", "User")
283
+ context: Render context for import registration
284
+ """
285
+ # Register the type itself
286
+ context.add_typing_imports_for_type(return_type)
287
+
288
+ # For array type aliases, also register the item type
289
+ if self._is_type_alias_to_array(return_type):
290
+ item_type = self._extract_array_item_type(return_type)
291
+ context.add_typing_imports_for_type(item_type)
292
+
293
+ def _get_cattrs_deserialization_code(self, return_type: str, data_expr: str) -> str:
294
+ """
295
+ Generate cattrs deserialization code for a given type.
296
+
297
+ Args:
298
+ return_type: The return type (e.g., "User", "List[User]", "list[User]", "AgentListResponse")
299
+ data_expr: The expression containing the raw data to deserialize
300
+
301
+ Returns:
302
+ Code string for deserializing the data using structure_from_dict()
303
+ """
304
+ # Check if this is an array type alias (e.g., AgentListResponse = List[AgentListResponseItem])
305
+ if self._is_type_alias_to_array(return_type):
306
+ # Use the type alias directly - cattrs handles List[Type] natively
307
+ return f"structure_from_dict({data_expr}, {return_type})"
308
+
309
+ if return_type.startswith("List[") or return_type.startswith("list["):
310
+ # Handle List[Model] or list[Model] types - cattrs handles this natively
311
+ return f"structure_from_dict({data_expr}, {return_type})"
312
+ elif return_type.startswith("Optional["):
313
+ # SANITY CHECK: Unified type system should never produce Optional[X]
314
+ logger.error(
315
+ f"❌ ARCHITECTURE VIOLATION: Received legacy Optional[X] type in response handler: {return_type}. "
316
+ f"Unified type system must generate X | None directly."
317
+ )
318
+ # Defensive conversion (but this indicates a serious bug upstream)
319
+ inner_type = return_type[9:-1] # Remove 'Optional[' and ']'
320
+ logger.warning(f"⚠️ Converting to modern syntax internally for: {inner_type} | None")
321
+
322
+ # Check if inner type is also a list
323
+ if inner_type.startswith("List[") or inner_type.startswith("list["):
324
+ list_code = self._get_cattrs_deserialization_code(inner_type, data_expr)
325
+ return f"{list_code} if {data_expr} is not None else None"
326
+ else:
327
+ return f"structure_from_dict({data_expr}, {inner_type}) if {data_expr} is not None else None"
328
+ elif " | None" in return_type or return_type.endswith("| None"):
329
+ # Handle Model | None types (modern Python 3.10+ syntax)
330
+ # Extract base type from "X | None" pattern
331
+ if " | None" in return_type:
332
+ inner_type = return_type.replace(" | None", "").strip()
333
+ else:
334
+ inner_type = return_type.replace("| None", "").strip()
335
+
336
+ # Check if inner type is also a list
337
+ if inner_type.startswith("List[") or inner_type.startswith("list["):
338
+ list_code = self._get_cattrs_deserialization_code(inner_type, data_expr)
339
+ return f"{list_code} if {data_expr} is not None else None"
340
+ else:
341
+ return f"structure_from_dict({data_expr}, {inner_type}) if {data_expr} is not None else None"
342
+ else:
343
+ # Handle simple Model types only - this should not be called for list types
344
+ if "[" in return_type and "]" in return_type:
345
+ # This is a complex type that we missed - should not happen
346
+ raise ValueError(f"Unsupported complex type for cattrs deserialization: {return_type}")
347
+
348
+ # Safety check: catch the specific issue we're debugging
349
+ if return_type.startswith("list[") or return_type.startswith("List["):
350
+ raise ValueError(
351
+ f"CRITICAL BUG: List type {return_type} reached simple type handler! This should never happen."
352
+ )
353
+
354
+ return f"structure_from_dict({data_expr}, {return_type})"
355
+
356
+ def _get_extraction_code(
357
+ self,
358
+ return_type: str,
359
+ context: RenderContext,
360
+ op: IROperation,
361
+ response_ir: IRResponse | None = None,
362
+ ) -> str:
363
+ """Determines the code snippet to extract/transform the response body."""
364
+ # Handle None, StreamingResponse, Iterator, etc.
365
+ if return_type is None or return_type == "None":
366
+ return "None" # This will be directly used in the return statement
367
+
368
+ # Handle streaming responses
369
+ if return_type.startswith("AsyncIterator["):
370
+ # Check if it's a bytes stream or other type of stream
371
+ if return_type == "AsyncIterator[bytes]":
372
+ context.add_import(f"{context.core_package_name}.streaming_helpers", "iter_bytes")
373
+ return "iter_bytes(response)"
374
+ elif "dict[str, Any]" in return_type or "dict" in return_type.lower():
375
+ # For event streams that return Dict objects
376
+ context.add_import(f"{context.core_package_name}.streaming_helpers", "iter_sse_events_text")
377
+ return "sse_json_stream_marker" # Special marker handled by _write_parsed_return
378
+ else:
379
+ # Model streaming - likely an SSE model stream
380
+ # Extract the model type and check if content type is text/event-stream
381
+ model_type = return_type[13:-1] # Remove 'AsyncIterator[' and ']'
382
+ if response_ir and "text/event-stream" in response_ir.content:
383
+ context.add_import(f"{context.core_package_name}.streaming_helpers", "iter_sse_events_text")
384
+ return "sse_json_stream_marker" # Special marker for SSE streaming
385
+
386
+ # Default to bytes streaming for other types
387
+ context.add_import(f"{context.core_package_name}.streaming_helpers", "iter_bytes")
388
+ return "iter_bytes(response)"
389
+
390
+ # Special case for "data: Any" unwrapping when the actual schema has no fields/properties
391
+ if return_type in {"dict[str, Any]", "dict[str, object]", "object", "Any"}:
392
+ context.add_import("typing", "Dict")
393
+ context.add_import("typing", "Any")
394
+
395
+ if return_type == "str":
396
+ return "response.text"
397
+ elif return_type == "bytes":
398
+ return "response.content"
399
+ elif return_type == "Any":
400
+ context.add_import("typing", "Any")
401
+ return "response.json() # Type is Any"
402
+ elif return_type == "None":
403
+ return "None" # This will be handled by generate_response_handling directly
404
+ else: # Includes schema-defined models, List[], dict[], Optional[]
405
+ context.add_typing_imports_for_type(return_type) # Ensure model itself is imported
406
+
407
+ # Check if we should use cattrs deserialization instead of cast()
408
+ use_base_schema = self._should_use_cattrs_structure(return_type)
409
+
410
+ if not use_base_schema:
411
+ # Fallback to cast() for non-dataclass types
412
+ context.add_import("typing", "cast")
413
+
414
+ # Direct deserialization using schemas as-is (no unwrapping)
415
+ if use_base_schema:
416
+ deserialization_code = self._get_cattrs_deserialization_code(return_type, "response.json()")
417
+ return deserialization_code
418
+ else:
419
+ return f"cast({return_type}, response.json())"
420
+
421
+ def generate_response_handling(
422
+ self,
423
+ writer: CodeWriter,
424
+ op: IROperation,
425
+ context: RenderContext,
426
+ strategy: ResponseStrategy,
427
+ ) -> None:
428
+ """Writes the response parsing and return logic to the CodeWriter, using the unified response strategy."""
429
+ writer.write_line("# Check response status code and handle accordingly")
430
+
431
+ # Generate the match statement for status codes
432
+ writer.write_line("match response.status_code:")
433
+ writer.indent()
434
+
435
+ # Handle the primary success response first
436
+ primary_success_ir = _get_primary_response(op)
437
+ processed_primary_success = False
438
+ if (
439
+ primary_success_ir
440
+ and primary_success_ir.status_code.isdigit()
441
+ and primary_success_ir.status_code.startswith("2")
442
+ ):
443
+ status_code_val = int(primary_success_ir.status_code)
444
+ writer.write_line(f"case {status_code_val}:")
445
+ writer.indent()
446
+
447
+ if strategy.return_type == "None":
448
+ writer.write_line("return None")
449
+ else:
450
+ self._write_strategy_based_return(writer, strategy, context)
451
+
452
+ writer.dedent()
453
+ processed_primary_success = True
454
+
455
+ # Handle other responses (exclude primary only if it was actually processed)
456
+ other_responses = [r for r in op.responses if not (processed_primary_success and r == primary_success_ir)]
457
+ for resp_ir in other_responses:
458
+ if resp_ir.status_code.isdigit():
459
+ status_code_val = int(resp_ir.status_code)
460
+ writer.write_line(f"case {status_code_val}:")
461
+ writer.indent()
462
+
463
+ if resp_ir.status_code.startswith("2"):
464
+ # Other 2xx success responses - resolve each response individually
465
+ if not resp_ir.content:
466
+ writer.write_line("return None")
467
+ else:
468
+ # Resolve the specific return type for this response
469
+ resp_schema = self._get_response_schema(resp_ir)
470
+ if resp_schema:
471
+ # Use response.json() directly - no automatic unwrapping
472
+ data_expr = "response.json()"
473
+
474
+ type_service = UnifiedTypeService(self.schemas)
475
+ response_type = type_service.resolve_schema_type(resp_schema, context)
476
+ if self._should_use_cattrs_structure(response_type):
477
+ deserialization_code = self._get_cattrs_deserialization_code(response_type, data_expr)
478
+ writer.write_line(f"return {deserialization_code}")
479
+ self._register_imports_for_type(response_type, context)
480
+ else:
481
+ context.add_import("typing", "cast")
482
+ writer.write_line(f"return cast({response_type}, {data_expr})")
483
+ else:
484
+ writer.write_line("return None")
485
+ else:
486
+ # Error responses - use human-readable exception names
487
+ error_class_name = get_exception_class_name(status_code_val)
488
+ context.add_import(f"{context.core_package_name}", error_class_name)
489
+ writer.write_line(f"raise {error_class_name}(response=response)")
490
+
491
+ writer.dedent()
492
+
493
+ # Handle default case
494
+ default_response = next((r for r in op.responses if r.status_code == "default"), None)
495
+ if default_response:
496
+ writer.write_line("case _: # Default response")
497
+ writer.indent()
498
+ if default_response.content and strategy.return_type != "None":
499
+ self._write_strategy_based_return(writer, strategy, context)
500
+ else:
501
+ context.add_import(f"{context.core_package_name}.exceptions", "HTTPError")
502
+ writer.write_line(
503
+ 'raise HTTPError(response=response, message="Default error", status_code=response.status_code)'
504
+ )
505
+ writer.dedent()
506
+ else:
507
+ # Final catch-all
508
+ writer.write_line("case _:")
509
+ writer.indent()
510
+ context.add_import(f"{context.core_package_name}.exceptions", "HTTPError")
511
+ writer.write_line(
512
+ 'raise HTTPError(response=response, message="Unhandled status code", status_code=response.status_code)'
513
+ )
514
+ writer.dedent()
515
+
516
+ writer.dedent() # End of match statement
517
+
518
+ # All code paths should be covered by the match statement above
519
+ writer.write_line("# All paths above should return or raise - this should never execute")
520
+ context.add_import("typing", "NoReturn")
521
+ writer.write_line("raise RuntimeError('Unexpected code path') # pragma: no cover")
522
+ writer.write_line("") # Add a blank line for readability
523
+
524
+ def _write_strategy_based_return(
525
+ self,
526
+ writer: CodeWriter,
527
+ strategy: ResponseStrategy,
528
+ context: RenderContext,
529
+ ) -> None:
530
+ """Write the return statement based on the response strategy.
531
+
532
+ This method implements the strategy pattern for response handling,
533
+ ensuring consistent behavior between signature and implementation.
534
+ """
535
+ if strategy.is_streaming:
536
+ # Handle streaming responses
537
+ if "AsyncIterator[bytes]" in strategy.return_type:
538
+ context.add_import(f"{context.core_package_name}.streaming_helpers", "iter_bytes")
539
+ writer.write_line("async for chunk in iter_bytes(response):")
540
+ writer.indent()
541
+ writer.write_line("yield chunk")
542
+ writer.dedent()
543
+ writer.write_line("return # Explicit return for async generator")
544
+ else:
545
+ # Handle other streaming types
546
+ context.add_plain_import("json")
547
+ context.add_import(f"{context.core_package_name}.streaming_helpers", "iter_sse_events_text")
548
+ writer.write_line("async for chunk in iter_sse_events_text(response):")
549
+ writer.indent()
550
+ writer.write_line("yield json.loads(chunk)")
551
+ writer.dedent()
552
+ writer.write_line("return # Explicit return for async generator")
553
+ return
554
+
555
+ # Use response.json() directly - no automatic unwrapping
556
+ data_expr = "response.json()"
557
+
558
+ # Handle responses using the schema
559
+ if strategy.return_type.startswith("Union["):
560
+ # Check if this is a multi-content-type Union (has content_type_mapping)
561
+ if strategy.content_type_mapping:
562
+ # Generate Content-Type header checking code
563
+ self._write_content_type_conditional_handling(writer, context, strategy)
564
+ else:
565
+ # Traditional Union handling with try/except fallback
566
+ self._write_union_response_handling(writer, context, strategy.return_type, data_expr)
567
+ elif self._should_use_cattrs_structure(strategy.return_type):
568
+ # Register cattrs import
569
+ context.add_import(f"{context.core_package_name}.cattrs_converter", "structure_from_dict")
570
+ deserialization_code = self._get_cattrs_deserialization_code(strategy.return_type, data_expr)
571
+ writer.write_line(f"return {deserialization_code}")
572
+ self._register_imports_for_type(strategy.return_type, context)
573
+ else:
574
+ context.add_import("typing", "cast")
575
+ writer.write_line(f"return cast({strategy.return_type}, {data_expr})")
576
+
577
+ def _get_response_schema(self, response_ir: IRResponse) -> IRSchema | None:
578
+ """Extract the schema from a response IR."""
579
+ if not response_ir.content:
580
+ return None
581
+
582
+ # Prefer application/json, then first available content type
583
+ content_types = list(response_ir.content.keys())
584
+ preferred_content_type = next((ct for ct in content_types if ct == "application/json"), None)
585
+ if not preferred_content_type:
586
+ preferred_content_type = content_types[0] if content_types else None
587
+
588
+ if preferred_content_type:
589
+ return response_ir.content.get(preferred_content_type)
590
+
591
+ return None
592
+
593
+ def _write_union_response_handling(
594
+ self, writer: CodeWriter, context: RenderContext, return_type: str, data_expr: str
595
+ ) -> None:
596
+ """Write try/except logic for Union types."""
597
+ # Parse Union[TypeA, TypeB] to extract the types
598
+ if not return_type.startswith("Union[") or not return_type.endswith("]"):
599
+ raise ValueError(f"Invalid Union type format: {return_type}")
600
+
601
+ union_content = return_type[6:-1] # Remove 'Union[' and ']'
602
+ types = [t.strip() for t in union_content.split(",")]
603
+
604
+ if len(types) < 2:
605
+ raise ValueError(f"Union type must have at least 2 types: {return_type}")
606
+
607
+ # Add Union import
608
+ context.add_import("typing", "Union")
609
+
610
+ # Generate try/except blocks for each type
611
+ first_type = types[0]
612
+ remaining_types = types[1:]
613
+
614
+ # Try the first type
615
+ writer.write_line("try:")
616
+ writer.indent()
617
+ if self._should_use_cattrs_structure(first_type):
618
+ context.add_typing_imports_for_type(first_type)
619
+ deserialization_code = self._get_cattrs_deserialization_code(first_type, data_expr)
620
+ writer.write_line(f"return {deserialization_code}")
621
+ else:
622
+ context.add_import("typing", "cast")
623
+ writer.write_line(f"return cast({first_type}, {data_expr})")
624
+ writer.dedent()
625
+
626
+ # Add except blocks for remaining types
627
+ for i, type_name in enumerate(remaining_types):
628
+ is_last = i == len(remaining_types) - 1
629
+ if is_last:
630
+ writer.write_line("except Exception: # Attempt to parse as the final type")
631
+ else:
632
+ writer.write_line("except Exception: # Attempt to parse as the next type")
633
+ writer.indent()
634
+ if self._should_use_cattrs_structure(type_name):
635
+ context.add_typing_imports_for_type(type_name)
636
+ deserialization_code = self._get_cattrs_deserialization_code(type_name, data_expr)
637
+ if is_last:
638
+ writer.write_line(f"return {deserialization_code}")
639
+ else:
640
+ writer.write_line("try:")
641
+ writer.indent()
642
+ writer.write_line(f"return {deserialization_code}")
643
+ writer.dedent()
644
+ else:
645
+ context.add_import("typing", "cast")
646
+ writer.write_line(f"return cast({type_name}, {data_expr})")
647
+ writer.dedent()
648
+
649
+ def _write_content_type_conditional_handling(
650
+ self, writer: CodeWriter, context: RenderContext, strategy: ResponseStrategy
651
+ ) -> None:
652
+ """Write Content-Type header checking code for multi-content-type responses.
653
+
654
+ When a response has multiple content types, generate conditional code that checks
655
+ the Content-Type header and returns the appropriate type.
656
+
657
+ Args:
658
+ writer: Code writer for output
659
+ context: Render context for imports
660
+ strategy: Response strategy with content_type_mapping
661
+ """
662
+ if not strategy.content_type_mapping:
663
+ raise ValueError("content_type_mapping is required for Content-Type conditional handling")
664
+
665
+ # Extract content type without parameters and normalize to lowercase for case-insensitive comparison
666
+ # (e.g., "Application/JSON; charset=utf-8" -> "application/json")
667
+ # RFC 7230: HTTP header field names are case-insensitive
668
+ writer.write_line('content_type = response.headers.get("content-type", "").split(";")[0].strip().lower()')
669
+ writer.write_line("")
670
+
671
+ # Generate if/elif/else chain for each content type
672
+ content_type_items = list(strategy.content_type_mapping.items())
673
+
674
+ for i, (content_type, python_type) in enumerate(content_type_items):
675
+ is_first = i == 0
676
+ is_last = i == len(content_type_items) - 1
677
+
678
+ # Write conditional statement with lowercase content-type (case-insensitive comparison)
679
+ content_type_lower = content_type.lower()
680
+ if is_first:
681
+ writer.write_line(f'if content_type == "{content_type_lower}":')
682
+ elif not is_last:
683
+ writer.write_line(f'elif content_type == "{content_type_lower}":')
684
+ else:
685
+ # Last item - use else for fallback
686
+ writer.write_line("else: # Default/fallback content type")
687
+
688
+ writer.indent()
689
+
690
+ # Generate return statement based on python_type
691
+ if python_type == "bytes":
692
+ writer.write_line("return response.content")
693
+ elif python_type == "str":
694
+ writer.write_line("return response.text")
695
+ elif self._should_use_cattrs_structure(python_type):
696
+ # Complex type - use cattrs deserialization
697
+ context.add_typing_imports_for_type(python_type)
698
+ deserialization_code = self._get_cattrs_deserialization_code(python_type, "response.json()")
699
+ writer.write_line(f"return {deserialization_code}")
700
+ else:
701
+ # Simple type - use cast
702
+ context.add_import("typing", "cast")
703
+ writer.write_line(f"return cast({python_type}, response.json())")
704
+
705
+ writer.dedent()