pyopenapi-gen 0.8.3__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 (122) hide show
  1. pyopenapi_gen/__init__.py +114 -0
  2. pyopenapi_gen/__main__.py +6 -0
  3. pyopenapi_gen/cli.py +86 -0
  4. pyopenapi_gen/context/file_manager.py +52 -0
  5. pyopenapi_gen/context/import_collector.py +382 -0
  6. pyopenapi_gen/context/render_context.py +630 -0
  7. pyopenapi_gen/core/__init__.py +0 -0
  8. pyopenapi_gen/core/auth/base.py +22 -0
  9. pyopenapi_gen/core/auth/plugins.py +89 -0
  10. pyopenapi_gen/core/exceptions.py +25 -0
  11. pyopenapi_gen/core/http_transport.py +219 -0
  12. pyopenapi_gen/core/loader/__init__.py +12 -0
  13. pyopenapi_gen/core/loader/loader.py +158 -0
  14. pyopenapi_gen/core/loader/operations/__init__.py +12 -0
  15. pyopenapi_gen/core/loader/operations/parser.py +155 -0
  16. pyopenapi_gen/core/loader/operations/post_processor.py +60 -0
  17. pyopenapi_gen/core/loader/operations/request_body.py +85 -0
  18. pyopenapi_gen/core/loader/parameters/__init__.py +10 -0
  19. pyopenapi_gen/core/loader/parameters/parser.py +121 -0
  20. pyopenapi_gen/core/loader/responses/__init__.py +10 -0
  21. pyopenapi_gen/core/loader/responses/parser.py +104 -0
  22. pyopenapi_gen/core/loader/schemas/__init__.py +11 -0
  23. pyopenapi_gen/core/loader/schemas/extractor.py +184 -0
  24. pyopenapi_gen/core/pagination.py +64 -0
  25. pyopenapi_gen/core/parsing/__init__.py +13 -0
  26. pyopenapi_gen/core/parsing/common/__init__.py +1 -0
  27. pyopenapi_gen/core/parsing/common/ref_resolution/__init__.py +9 -0
  28. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/__init__.py +0 -0
  29. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/cyclic_properties.py +66 -0
  30. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/direct_cycle.py +33 -0
  31. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/existing_schema.py +22 -0
  32. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/list_response.py +54 -0
  33. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/missing_ref.py +52 -0
  34. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/new_schema.py +50 -0
  35. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/stripped_suffix.py +51 -0
  36. pyopenapi_gen/core/parsing/common/ref_resolution/resolve_schema_ref.py +86 -0
  37. pyopenapi_gen/core/parsing/common/type_parser.py +74 -0
  38. pyopenapi_gen/core/parsing/context.py +184 -0
  39. pyopenapi_gen/core/parsing/cycle_helpers.py +123 -0
  40. pyopenapi_gen/core/parsing/keywords/__init__.py +1 -0
  41. pyopenapi_gen/core/parsing/keywords/all_of_parser.py +77 -0
  42. pyopenapi_gen/core/parsing/keywords/any_of_parser.py +79 -0
  43. pyopenapi_gen/core/parsing/keywords/array_items_parser.py +69 -0
  44. pyopenapi_gen/core/parsing/keywords/one_of_parser.py +72 -0
  45. pyopenapi_gen/core/parsing/keywords/properties_parser.py +98 -0
  46. pyopenapi_gen/core/parsing/schema_finalizer.py +166 -0
  47. pyopenapi_gen/core/parsing/schema_parser.py +610 -0
  48. pyopenapi_gen/core/parsing/transformers/__init__.py +0 -0
  49. pyopenapi_gen/core/parsing/transformers/inline_enum_extractor.py +285 -0
  50. pyopenapi_gen/core/parsing/transformers/inline_object_promoter.py +117 -0
  51. pyopenapi_gen/core/parsing/unified_cycle_detection.py +293 -0
  52. pyopenapi_gen/core/postprocess_manager.py +161 -0
  53. pyopenapi_gen/core/schemas.py +40 -0
  54. pyopenapi_gen/core/streaming_helpers.py +86 -0
  55. pyopenapi_gen/core/telemetry.py +67 -0
  56. pyopenapi_gen/core/utils.py +409 -0
  57. pyopenapi_gen/core/warning_collector.py +83 -0
  58. pyopenapi_gen/core/writers/code_writer.py +135 -0
  59. pyopenapi_gen/core/writers/documentation_writer.py +222 -0
  60. pyopenapi_gen/core/writers/line_writer.py +217 -0
  61. pyopenapi_gen/core/writers/python_construct_renderer.py +274 -0
  62. pyopenapi_gen/core_package_template/README.md +21 -0
  63. pyopenapi_gen/emit/models_emitter.py +143 -0
  64. pyopenapi_gen/emitters/client_emitter.py +51 -0
  65. pyopenapi_gen/emitters/core_emitter.py +181 -0
  66. pyopenapi_gen/emitters/docs_emitter.py +44 -0
  67. pyopenapi_gen/emitters/endpoints_emitter.py +223 -0
  68. pyopenapi_gen/emitters/exceptions_emitter.py +52 -0
  69. pyopenapi_gen/emitters/models_emitter.py +428 -0
  70. pyopenapi_gen/generator/client_generator.py +562 -0
  71. pyopenapi_gen/helpers/__init__.py +1 -0
  72. pyopenapi_gen/helpers/endpoint_utils.py +552 -0
  73. pyopenapi_gen/helpers/type_cleaner.py +341 -0
  74. pyopenapi_gen/helpers/type_helper.py +112 -0
  75. pyopenapi_gen/helpers/type_resolution/__init__.py +1 -0
  76. pyopenapi_gen/helpers/type_resolution/array_resolver.py +57 -0
  77. pyopenapi_gen/helpers/type_resolution/composition_resolver.py +79 -0
  78. pyopenapi_gen/helpers/type_resolution/finalizer.py +89 -0
  79. pyopenapi_gen/helpers/type_resolution/named_resolver.py +174 -0
  80. pyopenapi_gen/helpers/type_resolution/object_resolver.py +212 -0
  81. pyopenapi_gen/helpers/type_resolution/primitive_resolver.py +57 -0
  82. pyopenapi_gen/helpers/type_resolution/resolver.py +48 -0
  83. pyopenapi_gen/helpers/url_utils.py +14 -0
  84. pyopenapi_gen/http_types.py +20 -0
  85. pyopenapi_gen/ir.py +167 -0
  86. pyopenapi_gen/py.typed +1 -0
  87. pyopenapi_gen/types/__init__.py +11 -0
  88. pyopenapi_gen/types/contracts/__init__.py +13 -0
  89. pyopenapi_gen/types/contracts/protocols.py +106 -0
  90. pyopenapi_gen/types/contracts/types.py +30 -0
  91. pyopenapi_gen/types/resolvers/__init__.py +7 -0
  92. pyopenapi_gen/types/resolvers/reference_resolver.py +71 -0
  93. pyopenapi_gen/types/resolvers/response_resolver.py +203 -0
  94. pyopenapi_gen/types/resolvers/schema_resolver.py +367 -0
  95. pyopenapi_gen/types/services/__init__.py +5 -0
  96. pyopenapi_gen/types/services/type_service.py +133 -0
  97. pyopenapi_gen/visit/client_visitor.py +228 -0
  98. pyopenapi_gen/visit/docs_visitor.py +38 -0
  99. pyopenapi_gen/visit/endpoint/__init__.py +1 -0
  100. pyopenapi_gen/visit/endpoint/endpoint_visitor.py +103 -0
  101. pyopenapi_gen/visit/endpoint/generators/__init__.py +1 -0
  102. pyopenapi_gen/visit/endpoint/generators/docstring_generator.py +121 -0
  103. pyopenapi_gen/visit/endpoint/generators/endpoint_method_generator.py +87 -0
  104. pyopenapi_gen/visit/endpoint/generators/request_generator.py +103 -0
  105. pyopenapi_gen/visit/endpoint/generators/response_handler_generator.py +497 -0
  106. pyopenapi_gen/visit/endpoint/generators/signature_generator.py +88 -0
  107. pyopenapi_gen/visit/endpoint/generators/url_args_generator.py +183 -0
  108. pyopenapi_gen/visit/endpoint/processors/__init__.py +1 -0
  109. pyopenapi_gen/visit/endpoint/processors/import_analyzer.py +76 -0
  110. pyopenapi_gen/visit/endpoint/processors/parameter_processor.py +171 -0
  111. pyopenapi_gen/visit/exception_visitor.py +52 -0
  112. pyopenapi_gen/visit/model/__init__.py +0 -0
  113. pyopenapi_gen/visit/model/alias_generator.py +89 -0
  114. pyopenapi_gen/visit/model/dataclass_generator.py +197 -0
  115. pyopenapi_gen/visit/model/enum_generator.py +200 -0
  116. pyopenapi_gen/visit/model/model_visitor.py +197 -0
  117. pyopenapi_gen/visit/visitor.py +97 -0
  118. pyopenapi_gen-0.8.3.dist-info/METADATA +224 -0
  119. pyopenapi_gen-0.8.3.dist-info/RECORD +122 -0
  120. pyopenapi_gen-0.8.3.dist-info/WHEEL +4 -0
  121. pyopenapi_gen-0.8.3.dist-info/entry_points.txt +2 -0
  122. pyopenapi_gen-0.8.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,610 @@
1
+ """
2
+ Core schema parsing logic, transforming a schema node into an IRSchema object.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import os
9
+ from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple
10
+
11
+ from pyopenapi_gen import IRSchema
12
+ from pyopenapi_gen.core.utils import NameSanitizer
13
+
14
+ from .context import ParsingContext
15
+ from .keywords.all_of_parser import _process_all_of
16
+ from .keywords.any_of_parser import _parse_any_of_schemas
17
+ from .keywords.one_of_parser import _parse_one_of_schemas
18
+ from .unified_cycle_detection import CycleAction
19
+
20
+ # Environment variables for configurable limits, with defaults
21
+ try:
22
+ MAX_CYCLES = int(os.environ.get("PYOPENAPI_MAX_CYCLES", "0")) # Default 0 means no explicit cycle count limit
23
+ except ValueError:
24
+ MAX_CYCLES = 0
25
+ try:
26
+ ENV_MAX_DEPTH = int(os.environ.get("PYOPENAPI_MAX_DEPTH", "150")) # Default 150
27
+ except ValueError:
28
+ ENV_MAX_DEPTH = 150 # Fallback to 150 if env var is invalid
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ def _resolve_ref(
34
+ ref_path_str: str,
35
+ parent_schema_name: Optional[str], # Name of the schema containing this $ref
36
+ context: ParsingContext,
37
+ max_depth_override: Optional[int], # Propagated from the main _parse_schema call
38
+ allow_self_reference_for_parent: bool,
39
+ ) -> IRSchema:
40
+ """Resolves a $ref string, handling cycles and depth for the referenced schema."""
41
+ ref_name_parts = ref_path_str.split("/")
42
+ if not (ref_name_parts and ref_name_parts[-1]):
43
+ logger.warning(
44
+ f"Malformed $ref path '{ref_path_str}' encountered while parsing "
45
+ f"parent '{parent_schema_name or 'anonymous'}'."
46
+ )
47
+ return IRSchema(
48
+ name=None, # Anonymous placeholder for a bad ref
49
+ description=f"Malformed $ref: {ref_path_str}",
50
+ _from_unresolved_ref=True,
51
+ )
52
+ ref_name = ref_name_parts[-1]
53
+
54
+ # 1. Check if already parsed (fully or as a placeholder)
55
+ if ref_name in context.parsed_schemas and not context.parsed_schemas[ref_name]._max_depth_exceeded_marker:
56
+ # Re-using already parsed schema from context
57
+ return context.parsed_schemas[ref_name]
58
+
59
+ # 2. Get the raw schema node for the reference
60
+ ref_node = context.raw_spec_schemas.get(ref_name)
61
+ if ref_node is None:
62
+ logger.warning(
63
+ f"Cannot resolve $ref '{ref_path_str}' for parent '{parent_schema_name or 'anonymous'}'. "
64
+ f"Target '{ref_name}' not in raw_spec_schemas. Returning placeholder."
65
+ )
66
+ return IRSchema(
67
+ name=NameSanitizer.sanitize_class_name(ref_name),
68
+ _from_unresolved_ref=True,
69
+ description=f"Unresolved $ref: {ref_path_str} (target not found)",
70
+ )
71
+
72
+ # Delegate all cycle detection and context management to _parse_schema
73
+ # The unified system in _parse_schema will handle all cycle detection, depth limits, and context management
74
+ return _parse_schema(
75
+ ref_name,
76
+ ref_node,
77
+ context,
78
+ max_depth_override,
79
+ allow_self_reference=allow_self_reference_for_parent,
80
+ )
81
+
82
+
83
+ def _parse_composition_keywords(
84
+ node: Mapping[str, Any],
85
+ name: Optional[str],
86
+ context: ParsingContext,
87
+ max_depth: int,
88
+ parse_fn: Callable[[Optional[str], Optional[Mapping[str, Any]], ParsingContext, Optional[int]], IRSchema],
89
+ ) -> Tuple[
90
+ Optional[List[IRSchema]], Optional[List[IRSchema]], Optional[List[IRSchema]], Dict[str, IRSchema], Set[str], bool
91
+ ]:
92
+ """Parse composition keywords (anyOf, oneOf, allOf) from a schema node.
93
+
94
+ Contracts:
95
+ Pre-conditions:
96
+ - node is a valid Mapping
97
+ - context is a valid ParsingContext instance
98
+ - max_depth >= 0
99
+ - parse_fn is a callable for parsing schemas
100
+ Post-conditions:
101
+ - Returns a tuple of (any_of_schemas, one_of_schemas, all_of_components,
102
+ properties, required_fields, is_nullable)
103
+ """
104
+ any_of_schemas: Optional[List[IRSchema]] = None
105
+ one_of_schemas: Optional[List[IRSchema]] = None
106
+ parsed_all_of_components: Optional[List[IRSchema]] = None
107
+ merged_properties: Dict[str, IRSchema] = {}
108
+ merged_required_set: Set[str] = set()
109
+ is_nullable: bool = False
110
+
111
+ if "anyOf" in node:
112
+ parsed_sub_schemas, nullable_from_sub, _ = _parse_any_of_schemas(node["anyOf"], context, max_depth, parse_fn)
113
+ any_of_schemas = parsed_sub_schemas
114
+ is_nullable = is_nullable or nullable_from_sub
115
+
116
+ if "oneOf" in node:
117
+ parsed_sub_schemas, nullable_from_sub, _ = _parse_one_of_schemas(node["oneOf"], context, max_depth, parse_fn)
118
+ one_of_schemas = parsed_sub_schemas
119
+ is_nullable = is_nullable or nullable_from_sub
120
+
121
+ if "allOf" in node:
122
+ merged_properties, merged_required_set, parsed_all_of_components = _process_all_of(
123
+ node, name, context, parse_fn, max_depth=max_depth
124
+ )
125
+ else:
126
+ merged_required_set = set(node.get("required", []))
127
+
128
+ return any_of_schemas, one_of_schemas, parsed_all_of_components, merged_properties, merged_required_set, is_nullable
129
+
130
+
131
+ def _parse_properties(
132
+ properties_node: Mapping[str, Any],
133
+ parent_schema_name: Optional[str],
134
+ existing_properties: Dict[str, IRSchema], # Properties already merged, e.g., from allOf
135
+ context: ParsingContext,
136
+ max_depth_override: Optional[int],
137
+ allow_self_reference: bool,
138
+ ) -> Dict[str, IRSchema]:
139
+ """Parses the 'properties' block of a schema node."""
140
+ parsed_props: Dict[str, IRSchema] = existing_properties.copy()
141
+
142
+ for prop_name, prop_schema_node in properties_node.items():
143
+ if not isinstance(prop_name, str) or not prop_name:
144
+ logger.warning(
145
+ f"Skipping property with invalid name '{prop_name}' in schema '{parent_schema_name or 'anonymous'}'."
146
+ )
147
+ continue
148
+
149
+ if prop_name in parsed_props: # Already handled by allOf or a previous definition, skip
150
+ continue
151
+
152
+ if isinstance(prop_schema_node, Mapping) and "$ref" in prop_schema_node:
153
+ parsed_props[prop_name] = _resolve_ref(
154
+ prop_schema_node["$ref"], parent_schema_name, context, max_depth_override, allow_self_reference
155
+ )
156
+ else:
157
+ # Inline object promotion or direct parsing of property schema
158
+ is_inline_object_node = (
159
+ isinstance(prop_schema_node, Mapping)
160
+ and prop_schema_node.get("type") == "object"
161
+ and "$ref" not in prop_schema_node
162
+ and (
163
+ "properties" in prop_schema_node or "description" in prop_schema_node
164
+ ) # Heuristic for actual object def
165
+ )
166
+
167
+ if is_inline_object_node and parent_schema_name:
168
+ # Promote inline object to its own schema
169
+ promoted_schema_name = f"{parent_schema_name}{NameSanitizer.sanitize_class_name(prop_name)}"
170
+ promoted_ir_schema = _parse_schema(
171
+ promoted_schema_name,
172
+ prop_schema_node,
173
+ context,
174
+ max_depth_override,
175
+ allow_self_reference,
176
+ )
177
+ # The property itself becomes a reference to this new schema
178
+ property_ref_ir = IRSchema(
179
+ name=prop_name, # The actual property name
180
+ type=promoted_ir_schema.name, # Type is the name of the promoted schema
181
+ description=promoted_ir_schema.description or prop_schema_node.get("description"),
182
+ is_nullable=(prop_schema_node.get("nullable", False) or promoted_ir_schema.is_nullable),
183
+ _refers_to_schema=promoted_ir_schema,
184
+ default=prop_schema_node.get("default"),
185
+ example=prop_schema_node.get("example"),
186
+ )
187
+ parsed_props[prop_name] = property_ref_ir
188
+ # Add the newly created promoted schema to parsed_schemas if it's not a placeholder from error/cycle
189
+ if (
190
+ promoted_schema_name
191
+ and not promoted_ir_schema._from_unresolved_ref
192
+ and not promoted_ir_schema._max_depth_exceeded_marker
193
+ and not promoted_ir_schema._is_circular_ref
194
+ ):
195
+ context.parsed_schemas[promoted_schema_name] = promoted_ir_schema
196
+ else:
197
+ # Directly parse other inline types (string, number, array of simple types, etc.)
198
+ # or objects that are not being promoted (e.g. if parent_schema_name is None)
199
+
200
+ # Check if this is a simple primitive type that should NOT be promoted to a separate schema
201
+ is_simple_primitive = (
202
+ isinstance(prop_schema_node, Mapping)
203
+ and prop_schema_node.get("type") in ["string", "integer", "number", "boolean"]
204
+ and "$ref" not in prop_schema_node
205
+ and "properties" not in prop_schema_node
206
+ and "allOf" not in prop_schema_node
207
+ and "anyOf" not in prop_schema_node
208
+ and "oneOf" not in prop_schema_node
209
+ and "items" not in prop_schema_node
210
+ and "enum" not in prop_schema_node # Enums should still be promoted
211
+ )
212
+
213
+ # Check if this is a simple array that should NOT be promoted to a separate schema
214
+ is_simple_array = (
215
+ isinstance(prop_schema_node, Mapping)
216
+ and prop_schema_node.get("type") == "array"
217
+ and "$ref" not in prop_schema_node
218
+ and "properties" not in prop_schema_node
219
+ and "allOf" not in prop_schema_node
220
+ and "anyOf" not in prop_schema_node
221
+ and "oneOf" not in prop_schema_node
222
+ and isinstance(prop_schema_node.get("items"), Mapping)
223
+ and "$ref" in prop_schema_node.get("items", {}) # Array of referenced types
224
+ )
225
+
226
+ # Use a sanitized version of prop_name as context name for this sub-parse
227
+ prop_context_name = NameSanitizer.sanitize_class_name(prop_name)
228
+
229
+ # For simple primitives and simple arrays, avoid creating separate schemas
230
+ if (is_simple_primitive or is_simple_array) and prop_context_name in context.parsed_schemas:
231
+ # There's a naming conflict - use a unique name to avoid confusion
232
+ prop_context_name = f"_primitive_{prop_name}_{id(prop_schema_node)}"
233
+
234
+ # For simple primitives and simple arrays, don't assign names to prevent
235
+ # them from being registered as standalone schemas
236
+ schema_name_for_parsing = None if (is_simple_primitive or is_simple_array) else prop_context_name
237
+
238
+ parsed_prop_schema_ir = _parse_schema(
239
+ schema_name_for_parsing, # Use None for simple types to prevent standalone registration
240
+ prop_schema_node,
241
+ context,
242
+ max_depth_override,
243
+ allow_self_reference,
244
+ )
245
+ # If the parsed schema retained the contextual name and it was registered,
246
+ # it implies it might be a complex anonymous type that got registered.
247
+ # In such cases, the property should *refer* to it.
248
+ # Otherwise, the parsed_prop_schema_ir *is* the property's schema directly.
249
+ #
250
+ # However, we should NOT create references for simple primitives or simple arrays
251
+ # as they should remain inline to avoid unnecessary schema proliferation.
252
+ should_create_reference = (
253
+ schema_name_for_parsing is not None # Only if we assigned a name
254
+ and parsed_prop_schema_ir.name == schema_name_for_parsing
255
+ and context.is_schema_parsed(schema_name_for_parsing)
256
+ and context.get_parsed_schema(schema_name_for_parsing) is parsed_prop_schema_ir
257
+ and (
258
+ parsed_prop_schema_ir.type == "object"
259
+ or (parsed_prop_schema_ir.type == "array" and not is_simple_array)
260
+ )
261
+ and not parsed_prop_schema_ir._from_unresolved_ref
262
+ and not parsed_prop_schema_ir._max_depth_exceeded_marker
263
+ and not parsed_prop_schema_ir._is_circular_ref
264
+ and not is_simple_primitive
265
+ )
266
+
267
+ if should_create_reference:
268
+ prop_is_nullable = False
269
+ if isinstance(prop_schema_node, Mapping):
270
+ if "nullable" in prop_schema_node:
271
+ prop_is_nullable = prop_schema_node["nullable"]
272
+ elif isinstance(prop_schema_node.get("type"), list) and "null" in prop_schema_node["type"]:
273
+ prop_is_nullable = True
274
+ elif parsed_prop_schema_ir.is_nullable:
275
+ prop_is_nullable = True
276
+
277
+ property_holder_ir = IRSchema(
278
+ name=prop_name, # The actual property name
279
+ # Type is the name of the (potentially registered) anonymous schema
280
+ type=parsed_prop_schema_ir.name,
281
+ description=prop_schema_node.get("description", parsed_prop_schema_ir.description),
282
+ is_nullable=prop_is_nullable,
283
+ default=prop_schema_node.get("default"),
284
+ example=prop_schema_node.get("example"),
285
+ enum=prop_schema_node.get("enum") if not parsed_prop_schema_ir.enum else None,
286
+ items=parsed_prop_schema_ir.items if parsed_prop_schema_ir.type == "array" else None,
287
+ format=parsed_prop_schema_ir.format,
288
+ _refers_to_schema=parsed_prop_schema_ir,
289
+ )
290
+ parsed_props[prop_name] = property_holder_ir
291
+ else:
292
+ # Simpler type, or error placeholder. Assign directly but ensure original prop_name is used.
293
+ # Also, try to respect original node's description, default, example, nullable if available.
294
+ final_prop_ir = parsed_prop_schema_ir
295
+ # Always assign the property name - this is the property's name in the parent object
296
+ final_prop_ir.name = prop_name
297
+ if isinstance(prop_schema_node, Mapping):
298
+ final_prop_ir.description = prop_schema_node.get(
299
+ "description", parsed_prop_schema_ir.description
300
+ )
301
+ final_prop_ir.default = prop_schema_node.get("default", parsed_prop_schema_ir.default)
302
+ final_prop_ir.example = prop_schema_node.get("example", parsed_prop_schema_ir.example)
303
+ current_prop_node_nullable = prop_schema_node.get("nullable", False)
304
+ type_list_nullable = (
305
+ isinstance(prop_schema_node.get("type"), list) and "null" in prop_schema_node["type"]
306
+ )
307
+ final_prop_ir.is_nullable = (
308
+ final_prop_ir.is_nullable or current_prop_node_nullable or type_list_nullable
309
+ )
310
+ # If the sub-parse didn't pick up an enum (e.g. for simple types), take it from prop_schema_node
311
+ if not final_prop_ir.enum and "enum" in prop_schema_node:
312
+ final_prop_ir.enum = prop_schema_node["enum"]
313
+
314
+ parsed_props[prop_name] = final_prop_ir
315
+ return parsed_props
316
+
317
+
318
+ def _parse_schema(
319
+ schema_name: Optional[str],
320
+ schema_node: Optional[Mapping[str, Any]],
321
+ context: ParsingContext,
322
+ max_depth_override: Optional[int] = None,
323
+ allow_self_reference: bool = False,
324
+ ) -> IRSchema:
325
+ """
326
+ Parse a schema node and return an IRSchema object.
327
+ """
328
+ # Pre-conditions
329
+ assert context is not None, "Context cannot be None for _parse_schema"
330
+
331
+ # Set allow_self_reference flag on unified context
332
+ context.unified_cycle_context.allow_self_reference = allow_self_reference
333
+
334
+ # Use unified cycle detection system
335
+ detection_result = context.unified_enter_schema(schema_name)
336
+
337
+ # Handle different detection results
338
+ if detection_result.action == CycleAction.RETURN_EXISTING:
339
+ # Schema already completed - return existing
340
+ context.unified_exit_schema(schema_name) # Balance the enter call
341
+ if schema_name:
342
+ existing_schema = context.unified_cycle_context.parsed_schemas.get(schema_name)
343
+ if existing_schema:
344
+ return existing_schema
345
+ # Fallback to legacy parsed_schemas if not in unified context
346
+ existing_schema = context.parsed_schemas.get(schema_name)
347
+ if existing_schema:
348
+ return existing_schema
349
+ # If schema marked as existing but not found anywhere, it might be a state management issue
350
+ # Reset the state and continue with normal parsing
351
+ from .unified_cycle_detection import SchemaState
352
+
353
+ context.unified_cycle_context.schema_states[schema_name] = SchemaState.NOT_STARTED
354
+ # Don't call unified_exit_schema again, continue to normal parsing
355
+
356
+ elif detection_result.action == CycleAction.RETURN_PLACEHOLDER:
357
+ # Schema is already a placeholder - return it
358
+ context.unified_exit_schema(schema_name) # Balance the enter call
359
+ if detection_result.placeholder_schema:
360
+ placeholder: IRSchema = detection_result.placeholder_schema
361
+ return placeholder
362
+ elif schema_name and schema_name in context.parsed_schemas:
363
+ return context.parsed_schemas[schema_name]
364
+ else:
365
+ # Fallback to empty schema
366
+ return IRSchema(name=NameSanitizer.sanitize_class_name(schema_name) if schema_name else None)
367
+
368
+ elif detection_result.action == CycleAction.CREATE_PLACEHOLDER:
369
+ # Cycle or depth limit detected - return the created placeholder
370
+ context.unified_exit_schema(schema_name) # Balance the enter call
371
+ created_placeholder: IRSchema = detection_result.placeholder_schema
372
+ return created_placeholder
373
+
374
+ # If we reach here, detection_result.action == CycleAction.CONTINUE_PARSING
375
+
376
+ try: # Ensure exit_schema is called
377
+ if schema_node is None:
378
+ return IRSchema(name=NameSanitizer.sanitize_class_name(schema_name) if schema_name else None)
379
+
380
+ assert isinstance(
381
+ schema_node, Mapping
382
+ ), f"Schema node for '{schema_name or 'anonymous'}' must be a Mapping (e.g., dict), got {type(schema_node)}"
383
+
384
+ # If the current schema_node itself is a $ref, resolve it.
385
+ if "$ref" in schema_node:
386
+ # schema_name is the original name we are trying to parse (e.g., 'Pet')
387
+ # schema_node is {"$ref": "#/components/schemas/ActualPet"}
388
+ # We want to resolve "ActualPet", but the resulting IRSchema should ideally
389
+ # retain the name 'Pet' if appropriate,
390
+ # or _resolve_ref handles naming if ActualPet itself is parsed.
391
+ # The `parent_schema_name` for _resolve_ref here is `schema_name` itself.
392
+ resolved_schema = _resolve_ref(
393
+ schema_node["$ref"], schema_name, context, max_depth_override, allow_self_reference
394
+ )
395
+
396
+ # Store the resolved schema under the current schema_name if it has a name
397
+ # This ensures that synthetic names like "ChildrenItem" are properly stored
398
+ # However, don't create duplicate entries for pure references to existing schemas
399
+ if (
400
+ schema_name
401
+ and resolved_schema
402
+ and resolved_schema.name # Resolved schema has a real name
403
+ and resolved_schema.name != schema_name # Different from synthetic name
404
+ and resolved_schema.name in context.parsed_schemas
405
+ ): # Already exists in context
406
+ # This is a pure reference to an existing schema, don't create duplicate
407
+ pass
408
+ elif schema_name and resolved_schema and schema_name not in context.parsed_schemas:
409
+ context.parsed_schemas[schema_name] = resolved_schema
410
+
411
+ return resolved_schema
412
+
413
+ extracted_type: Optional[str] = None
414
+ is_nullable_from_type_field = False
415
+ raw_type_field = schema_node.get("type")
416
+
417
+ if isinstance(raw_type_field, str):
418
+ extracted_type = raw_type_field
419
+ elif isinstance(raw_type_field, list):
420
+ if "null" in raw_type_field:
421
+ is_nullable_from_type_field = True
422
+ non_null_types = [t for t in raw_type_field if t != "null"]
423
+ if non_null_types:
424
+ extracted_type = non_null_types[0]
425
+ if len(non_null_types) > 1:
426
+ pass
427
+ elif is_nullable_from_type_field:
428
+ extracted_type = "null"
429
+
430
+ any_of_irs, one_of_irs, all_of_components_irs, props_from_comp, req_from_comp, nullable_from_comp = (
431
+ _parse_composition_keywords(
432
+ schema_node,
433
+ schema_name,
434
+ context,
435
+ ENV_MAX_DEPTH,
436
+ lambda n, sn, c, md: _parse_schema(n, sn, c, md, allow_self_reference),
437
+ )
438
+ )
439
+
440
+ is_nullable_overall = is_nullable_from_type_field or nullable_from_comp
441
+ final_properties_for_ir: Dict[str, IRSchema] = {}
442
+ current_final_type = extracted_type
443
+ if not current_final_type:
444
+ if props_from_comp or "allOf" in schema_node or "properties" in schema_node:
445
+ current_final_type = "object"
446
+ elif any_of_irs or one_of_irs:
447
+ current_final_type = None
448
+
449
+ if current_final_type == "null":
450
+ current_final_type = None
451
+
452
+ if current_final_type == "object":
453
+ # Properties from allOf have already been handled by _parse_composition_keywords
454
+ # and are in props_from_comp. We pass these as existing_properties.
455
+ if "properties" in schema_node:
456
+ final_properties_for_ir = _parse_properties(
457
+ schema_node["properties"],
458
+ schema_name,
459
+ props_from_comp, # these are from allOf merge
460
+ context,
461
+ max_depth_override,
462
+ allow_self_reference,
463
+ )
464
+ else:
465
+ final_properties_for_ir = props_from_comp.copy() # No direct properties, only from allOf
466
+
467
+ # final_required_set: Set[str] = set() # This was old logic, req_from_comp covers allOf
468
+ # if "properties" in schema_node: # This loop is now inside _parse_properties effectively
469
+ # ... existing property parsing loop removed ...
470
+ final_required_fields_set = req_from_comp.copy()
471
+ if "required" in schema_node and isinstance(schema_node["required"], list):
472
+ final_required_fields_set.update(schema_node["required"])
473
+
474
+ items_ir: Optional[IRSchema] = None
475
+ if current_final_type == "array":
476
+ items_node = schema_node.get("items")
477
+ if items_node:
478
+ base_name_for_item = schema_name or "AnonymousArray"
479
+ item_schema_name_for_recursive_parse = NameSanitizer.sanitize_class_name(f"{base_name_for_item}Item")
480
+
481
+ # Ensure unique names for anonymous array items to avoid schema overwrites
482
+ # Only check for collision if this specific name already exists
483
+ if not schema_name and item_schema_name_for_recursive_parse in context.parsed_schemas:
484
+ counter = 2 # Start from 2 since original is 1
485
+ original_name = item_schema_name_for_recursive_parse
486
+ while item_schema_name_for_recursive_parse in context.parsed_schemas:
487
+ item_schema_name_for_recursive_parse = f"{original_name}{counter}"
488
+ counter += 1
489
+
490
+ actual_item_ir = _parse_schema(
491
+ item_schema_name_for_recursive_parse, items_node, context, max_depth_override, allow_self_reference
492
+ )
493
+
494
+ is_promoted_inline_object = (
495
+ isinstance(items_node, Mapping)
496
+ and items_node.get("type") == "object"
497
+ and "$ref" not in items_node
498
+ and actual_item_ir.name == item_schema_name_for_recursive_parse
499
+ )
500
+
501
+ if is_promoted_inline_object:
502
+ ref_holder_ir = IRSchema(
503
+ name=None,
504
+ type=actual_item_ir.name,
505
+ description=actual_item_ir.description or items_node.get("description"),
506
+ )
507
+ ref_holder_ir._refers_to_schema = actual_item_ir
508
+ items_ir = ref_holder_ir
509
+ else:
510
+ items_ir = actual_item_ir
511
+ else:
512
+ items_ir = IRSchema(type="Any")
513
+
514
+ schema_ir_name_attr = NameSanitizer.sanitize_class_name(schema_name) if schema_name else None
515
+
516
+ schema_ir = IRSchema(
517
+ name=schema_ir_name_attr,
518
+ type=current_final_type,
519
+ properties=final_properties_for_ir,
520
+ any_of=any_of_irs,
521
+ one_of=one_of_irs,
522
+ all_of=all_of_components_irs,
523
+ required=sorted(list(final_required_fields_set)),
524
+ description=schema_node.get("description"),
525
+ format=schema_node.get("format") if isinstance(schema_node.get("format"), str) else None,
526
+ enum=schema_node.get("enum") if isinstance(schema_node.get("enum"), list) else None,
527
+ default=schema_node.get("default"),
528
+ example=schema_node.get("example"),
529
+ is_nullable=is_nullable_overall,
530
+ items=items_ir,
531
+ )
532
+
533
+ if schema_ir.type == "array" and isinstance(schema_node.get("items"), Mapping):
534
+ raw_items_node = schema_node["items"]
535
+ item_schema_context_name_for_reparse: Optional[str]
536
+ base_name_for_reparse_item = schema_name or "AnonymousArray"
537
+ item_schema_context_name_for_reparse = NameSanitizer.sanitize_class_name(
538
+ f"{base_name_for_reparse_item}Item"
539
+ )
540
+
541
+ # Ensure unique names for anonymous array items to avoid schema overwrites
542
+ # Only check for collision if this specific name already exists
543
+ if not schema_name and item_schema_context_name_for_reparse in context.parsed_schemas:
544
+ counter = 2 # Start from 2 since original is 1
545
+ original_name = item_schema_context_name_for_reparse
546
+ while item_schema_context_name_for_reparse in context.parsed_schemas:
547
+ item_schema_context_name_for_reparse = f"{original_name}{counter}"
548
+ counter += 1
549
+
550
+ direct_reparsed_item_ir = _parse_schema(
551
+ item_schema_context_name_for_reparse, raw_items_node, context, max_depth_override, allow_self_reference
552
+ )
553
+
554
+ is_promoted_inline_object_in_reparse_block = (
555
+ isinstance(raw_items_node, Mapping)
556
+ and raw_items_node.get("type") == "object"
557
+ and "$ref" not in raw_items_node
558
+ and direct_reparsed_item_ir.name == item_schema_context_name_for_reparse
559
+ )
560
+
561
+ if is_promoted_inline_object_in_reparse_block:
562
+ ref_holder_for_reparse_ir = IRSchema(
563
+ name=None,
564
+ type=direct_reparsed_item_ir.name,
565
+ description=direct_reparsed_item_ir.description or raw_items_node.get("description"),
566
+ )
567
+ ref_holder_for_reparse_ir._refers_to_schema = direct_reparsed_item_ir
568
+ schema_ir.items = ref_holder_for_reparse_ir
569
+ else:
570
+ schema_ir.items = direct_reparsed_item_ir
571
+
572
+ if schema_name and schema_name in context.parsed_schemas:
573
+ existing_in_context = context.parsed_schemas[schema_name]
574
+
575
+ if existing_in_context._is_circular_ref and existing_in_context is not schema_ir:
576
+ return existing_in_context
577
+
578
+ if schema_name and not schema_ir._from_unresolved_ref and not schema_ir._max_depth_exceeded_marker:
579
+ context.parsed_schemas[schema_name] = schema_ir
580
+
581
+ # Ensure generation_name and final_module_stem are set if the schema has a name
582
+ if schema_ir.name:
583
+ schema_ir.generation_name = NameSanitizer.sanitize_class_name(schema_ir.name)
584
+ schema_ir.final_module_stem = NameSanitizer.sanitize_module_name(schema_ir.name)
585
+
586
+ # Check if this schema was involved in any detected cycles and mark it accordingly
587
+ # This must happen before returning the schema
588
+ if schema_name:
589
+ for cycle_info in context.unified_cycle_context.detected_cycles:
590
+ if (
591
+ cycle_info.cycle_path
592
+ and cycle_info.cycle_path[0] == schema_name
593
+ and cycle_info.cycle_path[-1] == schema_name
594
+ ):
595
+ # This schema is the start and end of a cycle
596
+ # Only mark as circular if it's a direct self-reference (via immediate intermediate schema)
597
+ # or if the test case specifically expects this behavior (array items)
598
+ is_direct_self_ref = len(cycle_info.cycle_path) == 2
599
+ is_array_item_self_ref = len(cycle_info.cycle_path) == 3 and "Item" in cycle_info.cycle_path[1]
600
+
601
+ if is_direct_self_ref or is_array_item_self_ref:
602
+ schema_ir._is_circular_ref = True
603
+ schema_ir._from_unresolved_ref = True
604
+ schema_ir._circular_ref_path = " -> ".join(cycle_info.cycle_path)
605
+ break
606
+
607
+ return schema_ir
608
+
609
+ finally:
610
+ context.unified_exit_schema(schema_name)
File without changes