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,804 @@
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, List, Mapping, 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
+ logger = logging.getLogger(__name__)
21
+
22
+ # Environment variables for configurable limits, with defaults
23
+ try:
24
+ MAX_CYCLES = int(os.environ.get("PYOPENAPI_MAX_CYCLES", "0")) # Default 0 means no explicit cycle count limit
25
+ except ValueError:
26
+ MAX_CYCLES = 0
27
+ try:
28
+ ENV_MAX_DEPTH = int(os.environ.get("PYOPENAPI_MAX_DEPTH", "150")) # Default 150
29
+ except ValueError:
30
+ ENV_MAX_DEPTH = 150 # Fallback to 150 if env var is invalid
31
+
32
+
33
+ def _resolve_ref(
34
+ ref_path_str: str,
35
+ parent_schema_name: str | None, # Name of the schema containing this $ref
36
+ context: ParsingContext,
37
+ max_depth_override: int | None, # 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: str | None,
86
+ context: ParsingContext,
87
+ max_depth: int,
88
+ parse_fn: Callable[[str | None, Mapping[str, Any] | None, ParsingContext, int | None], IRSchema],
89
+ ) -> Tuple[List[IRSchema] | None, List[IRSchema] | None, List[IRSchema] | None, dict[str, IRSchema], Set[str], bool]:
90
+ """Parse composition keywords (anyOf, oneOf, allOf) from a schema node.
91
+
92
+ Contracts:
93
+ Pre-conditions:
94
+ - node is a valid Mapping
95
+ - context is a valid ParsingContext instance
96
+ - max_depth >= 0
97
+ - parse_fn is a callable for parsing schemas
98
+ Post-conditions:
99
+ - Returns a tuple of (any_of_schemas, one_of_schemas, all_of_components,
100
+ properties, required_fields, is_nullable)
101
+ """
102
+ any_of_schemas: List[IRSchema] | None = None
103
+ one_of_schemas: List[IRSchema] | None = None
104
+ parsed_all_of_components: List[IRSchema] | None = None
105
+ merged_properties: dict[str, IRSchema] = {}
106
+ merged_required_set: Set[str] = set()
107
+ is_nullable: bool = False
108
+
109
+ if "anyOf" in node:
110
+ parsed_sub_schemas, nullable_from_sub, _ = _parse_any_of_schemas(node["anyOf"], context, max_depth, parse_fn)
111
+ any_of_schemas = parsed_sub_schemas
112
+ is_nullable = is_nullable or nullable_from_sub
113
+
114
+ if "oneOf" in node:
115
+ parsed_sub_schemas, nullable_from_sub, _ = _parse_one_of_schemas(node["oneOf"], context, max_depth, parse_fn)
116
+ one_of_schemas = parsed_sub_schemas
117
+ is_nullable = is_nullable or nullable_from_sub
118
+
119
+ if "allOf" in node:
120
+ merged_properties, merged_required_set, parsed_all_of_components = _process_all_of(
121
+ node, name, context, parse_fn, max_depth=max_depth
122
+ )
123
+ else:
124
+ merged_required_set = set(node.get("required", []))
125
+
126
+ return any_of_schemas, one_of_schemas, parsed_all_of_components, merged_properties, merged_required_set, is_nullable
127
+
128
+
129
+ def _parse_properties(
130
+ properties_node: Mapping[str, Any],
131
+ parent_schema_name: str | None,
132
+ existing_properties: dict[str, IRSchema], # Properties already merged, e.g., from allOf
133
+ context: ParsingContext,
134
+ max_depth_override: int | None,
135
+ allow_self_reference: bool,
136
+ ) -> dict[str, IRSchema]:
137
+ """Parses the 'properties' block of a schema node."""
138
+ parsed_props: dict[str, IRSchema] = existing_properties.copy()
139
+
140
+ for prop_name, prop_schema_node in properties_node.items():
141
+ if not isinstance(prop_name, str) or not prop_name:
142
+ logger.warning(
143
+ f"Skipping property with invalid name '{prop_name}' in schema '{parent_schema_name or 'anonymous'}'."
144
+ )
145
+ continue
146
+
147
+ if prop_name in parsed_props: # Already handled by allOf or a previous definition, skip
148
+ continue
149
+
150
+ if isinstance(prop_schema_node, Mapping) and "$ref" in prop_schema_node:
151
+ parsed_props[prop_name] = _resolve_ref(
152
+ prop_schema_node["$ref"], parent_schema_name, context, max_depth_override, allow_self_reference
153
+ )
154
+ else:
155
+ # Inline object promotion or direct parsing of property schema
156
+ is_inline_object_node = (
157
+ isinstance(prop_schema_node, Mapping)
158
+ and prop_schema_node.get("type") == "object"
159
+ and "$ref" not in prop_schema_node
160
+ and (
161
+ "properties" in prop_schema_node or "description" in prop_schema_node
162
+ ) # Heuristic for actual object def
163
+ )
164
+
165
+ if is_inline_object_node and parent_schema_name:
166
+ # Promote inline object to its own schema
167
+ promoted_schema_name = f"{parent_schema_name}{NameSanitizer.sanitize_class_name(prop_name)}"
168
+ promoted_ir_schema = _parse_schema(
169
+ promoted_schema_name,
170
+ prop_schema_node,
171
+ context,
172
+ max_depth_override,
173
+ allow_self_reference,
174
+ )
175
+ # The property itself becomes a reference to this new schema
176
+ property_ref_ir = IRSchema(
177
+ name=prop_name, # The actual property name
178
+ type=promoted_ir_schema.name, # Type is the name of the promoted schema
179
+ description=promoted_ir_schema.description or prop_schema_node.get("description"),
180
+ is_nullable=(prop_schema_node.get("nullable", False) or promoted_ir_schema.is_nullable),
181
+ _refers_to_schema=promoted_ir_schema,
182
+ default=prop_schema_node.get("default"),
183
+ example=prop_schema_node.get("example"),
184
+ )
185
+ parsed_props[prop_name] = property_ref_ir
186
+ # Add the newly created promoted schema to parsed_schemas if it's not a placeholder from error/cycle
187
+ if (
188
+ promoted_schema_name
189
+ and not promoted_ir_schema._from_unresolved_ref
190
+ and not promoted_ir_schema._max_depth_exceeded_marker
191
+ and not promoted_ir_schema._is_circular_ref
192
+ ):
193
+ context.parsed_schemas[promoted_schema_name] = promoted_ir_schema
194
+ else:
195
+ # Directly parse other inline types (string, number, array of simple types, etc.)
196
+ # or objects that are not being promoted (e.g. if parent_schema_name is None)
197
+
198
+ # Check if this is a simple primitive type that should NOT be promoted to a separate schema
199
+ is_simple_primitive = (
200
+ isinstance(prop_schema_node, Mapping)
201
+ and prop_schema_node.get("type") in ["string", "integer", "number", "boolean"]
202
+ and "$ref" not in prop_schema_node
203
+ and "properties" not in prop_schema_node
204
+ and "allOf" not in prop_schema_node
205
+ and "anyOf" not in prop_schema_node
206
+ and "oneOf" not in prop_schema_node
207
+ and "items" not in prop_schema_node
208
+ and "enum" not in prop_schema_node # Enums should still be promoted
209
+ )
210
+
211
+ # Check if this is a simple array that should NOT be promoted to a separate schema
212
+ # This includes:
213
+ # 1. Arrays of referenced types (items has $ref)
214
+ # 2. Arrays of primitive types (items has type: string, integer, number, boolean)
215
+ items_node = prop_schema_node.get("items", {}) if isinstance(prop_schema_node, Mapping) else {}
216
+ is_primitive_items = (
217
+ isinstance(items_node, Mapping)
218
+ and items_node.get("type") in ["string", "integer", "number", "boolean"]
219
+ and "$ref" not in items_node
220
+ and "properties" not in items_node
221
+ and "allOf" not in items_node
222
+ and "anyOf" not in items_node
223
+ and "oneOf" not in items_node
224
+ )
225
+ # Items with no type/ref/composition (malformed schema) should resolve to Any
226
+ is_typeless_items = (
227
+ isinstance(items_node, Mapping)
228
+ and not items_node.get("type")
229
+ and "$ref" not in items_node
230
+ and "properties" not in items_node
231
+ and "allOf" not in items_node
232
+ and "anyOf" not in items_node
233
+ and "oneOf" not in items_node
234
+ )
235
+ is_simple_array = (
236
+ isinstance(prop_schema_node, Mapping)
237
+ and prop_schema_node.get("type") == "array"
238
+ and "$ref" not in prop_schema_node
239
+ and "properties" not in prop_schema_node
240
+ and "allOf" not in prop_schema_node
241
+ and "anyOf" not in prop_schema_node
242
+ and "oneOf" not in prop_schema_node
243
+ and isinstance(prop_schema_node.get("items"), Mapping)
244
+ and (
245
+ "$ref" in items_node # Array of referenced types
246
+ or is_primitive_items # Array of primitive types
247
+ or is_typeless_items # Array with malformed items (no type) - resolves to List[Any]
248
+ )
249
+ )
250
+
251
+ # Use a sanitized version of prop_name as context name for this sub-parse
252
+ prop_context_name = NameSanitizer.sanitize_class_name(prop_name)
253
+
254
+ # For simple primitives and simple arrays, avoid creating separate schemas
255
+ if (is_simple_primitive or is_simple_array) and prop_context_name in context.parsed_schemas:
256
+ # There's a naming conflict - use a unique name to avoid confusion
257
+ prop_context_name = f"_primitive_{prop_name}_{id(prop_schema_node)}"
258
+
259
+ # For simple primitives and simple arrays, don't assign names to prevent
260
+ # them from being registered as standalone schemas
261
+ # Note: Inline enums DO get names so they're registered properly
262
+ schema_name_for_parsing = None if (is_simple_primitive or is_simple_array) else prop_context_name
263
+
264
+ parsed_prop_schema_ir = _parse_schema(
265
+ schema_name_for_parsing, # Use None for simple types to prevent standalone registration
266
+ prop_schema_node,
267
+ context,
268
+ max_depth_override,
269
+ allow_self_reference,
270
+ )
271
+ # If the parsed schema retained the contextual name and it was registered,
272
+ # it implies it might be a complex anonymous type that got registered.
273
+ # In such cases, the property should *refer* to it.
274
+ # Otherwise, the parsed_prop_schema_ir *is* the property's schema directly.
275
+ #
276
+ # However, we should NOT create references for simple primitives or simple arrays
277
+ # as they should remain inline to avoid unnecessary schema proliferation.
278
+ should_create_reference = (
279
+ schema_name_for_parsing is not None # Only if we assigned a name
280
+ and parsed_prop_schema_ir.name == schema_name_for_parsing
281
+ and context.is_schema_parsed(schema_name_for_parsing)
282
+ and context.get_parsed_schema(schema_name_for_parsing) is parsed_prop_schema_ir
283
+ and (
284
+ parsed_prop_schema_ir.type == "object"
285
+ or (parsed_prop_schema_ir.type == "array" and not is_simple_array)
286
+ )
287
+ and not parsed_prop_schema_ir._from_unresolved_ref
288
+ and not parsed_prop_schema_ir._max_depth_exceeded_marker
289
+ and not parsed_prop_schema_ir._is_circular_ref
290
+ and not is_simple_primitive
291
+ )
292
+
293
+ if should_create_reference:
294
+ prop_is_nullable = False
295
+ if isinstance(prop_schema_node, Mapping):
296
+ if "nullable" in prop_schema_node:
297
+ prop_is_nullable = prop_schema_node["nullable"]
298
+ elif isinstance(prop_schema_node.get("type"), list) and "null" in prop_schema_node["type"]:
299
+ prop_is_nullable = True
300
+ elif parsed_prop_schema_ir.is_nullable:
301
+ prop_is_nullable = True
302
+
303
+ property_holder_ir = IRSchema(
304
+ name=prop_name, # The actual property name
305
+ # Type is the name of the (potentially registered) anonymous schema
306
+ type=parsed_prop_schema_ir.name,
307
+ description=prop_schema_node.get("description", parsed_prop_schema_ir.description),
308
+ is_nullable=prop_is_nullable,
309
+ default=prop_schema_node.get("default"),
310
+ example=prop_schema_node.get("example"),
311
+ enum=prop_schema_node.get("enum") if not parsed_prop_schema_ir.enum else None,
312
+ items=parsed_prop_schema_ir.items if parsed_prop_schema_ir.type == "array" else None,
313
+ format=parsed_prop_schema_ir.format,
314
+ _refers_to_schema=parsed_prop_schema_ir,
315
+ )
316
+ parsed_props[prop_name] = property_holder_ir
317
+ else:
318
+ # Simpler type, or error placeholder. Assign directly but ensure original prop_name is used.
319
+ # Also, try to respect original node's description, default, example, nullable if available.
320
+ final_prop_ir = parsed_prop_schema_ir
321
+ # Always assign the property name - this is the property's name in the parent object
322
+ final_prop_ir.name = prop_name
323
+ if isinstance(prop_schema_node, Mapping):
324
+ final_prop_ir.description = prop_schema_node.get(
325
+ "description", parsed_prop_schema_ir.description
326
+ )
327
+ final_prop_ir.default = prop_schema_node.get("default", parsed_prop_schema_ir.default)
328
+ final_prop_ir.example = prop_schema_node.get("example", parsed_prop_schema_ir.example)
329
+ current_prop_node_nullable = prop_schema_node.get("nullable", False)
330
+ type_list_nullable = (
331
+ isinstance(prop_schema_node.get("type"), list) and "null" in prop_schema_node["type"]
332
+ )
333
+ final_prop_ir.is_nullable = (
334
+ final_prop_ir.is_nullable or current_prop_node_nullable or type_list_nullable
335
+ )
336
+ # If the sub-parse didn't pick up an enum (e.g. for simple types), take it from prop_schema_node
337
+ if not final_prop_ir.enum and "enum" in prop_schema_node:
338
+ final_prop_ir.enum = prop_schema_node["enum"]
339
+
340
+ parsed_props[prop_name] = final_prop_ir
341
+ return parsed_props
342
+
343
+
344
+ def _parse_schema(
345
+ schema_name: str | None,
346
+ schema_node: Mapping[str, Any] | None,
347
+ context: ParsingContext,
348
+ max_depth_override: int | None = None,
349
+ allow_self_reference: bool = False,
350
+ ) -> IRSchema:
351
+ """
352
+ Parse a schema node and return an IRSchema object.
353
+ """
354
+ # Pre-conditions
355
+ if context is None:
356
+ raise ValueError("Context cannot be None for _parse_schema")
357
+
358
+ # Set allow_self_reference flag on unified context
359
+ context.unified_cycle_context.allow_self_reference = allow_self_reference
360
+
361
+ # Use unified cycle detection system
362
+ detection_result = context.unified_enter_schema(schema_name)
363
+
364
+ # Handle different detection results
365
+ if detection_result.action == CycleAction.RETURN_EXISTING:
366
+ # Schema already completed - return existing
367
+ context.unified_exit_schema(schema_name) # Balance the enter call
368
+ if schema_name:
369
+ existing_schema = context.unified_cycle_context.parsed_schemas.get(schema_name)
370
+ if existing_schema:
371
+ return existing_schema
372
+ # Fallback to legacy parsed_schemas if not in unified context
373
+ existing_schema = context.parsed_schemas.get(schema_name)
374
+ if existing_schema:
375
+ return existing_schema
376
+ # If schema marked as existing but not found anywhere, it might be a state management issue
377
+ # Reset the state and continue with normal parsing
378
+ from .unified_cycle_detection import SchemaState
379
+
380
+ context.unified_cycle_context.schema_states[schema_name] = SchemaState.NOT_STARTED
381
+ # Don't call unified_exit_schema again, continue to normal parsing
382
+
383
+ elif detection_result.action == CycleAction.RETURN_PLACEHOLDER:
384
+ # Schema is already a placeholder - return it
385
+ context.unified_exit_schema(schema_name) # Balance the enter call
386
+ if detection_result.placeholder_schema:
387
+ placeholder: IRSchema = detection_result.placeholder_schema
388
+ return placeholder
389
+ elif schema_name and schema_name in context.parsed_schemas:
390
+ return context.parsed_schemas[schema_name]
391
+ else:
392
+ # Fallback to empty schema
393
+ return IRSchema(name=NameSanitizer.sanitize_class_name(schema_name) if schema_name else None)
394
+
395
+ elif detection_result.action == CycleAction.CREATE_PLACEHOLDER:
396
+ # Cycle or depth limit detected - return the created placeholder
397
+ context.unified_exit_schema(schema_name) # Balance the enter call
398
+ created_placeholder: IRSchema = detection_result.placeholder_schema
399
+ return created_placeholder
400
+
401
+ # If we reach here, detection_result.action == CycleAction.CONTINUE_PARSING
402
+
403
+ try: # Ensure exit_schema is called
404
+ if schema_node is None:
405
+ # Create empty schema for null schema nodes
406
+ # Do NOT set generation_name - null schemas should resolve to Any inline, not generate separate files
407
+ return IRSchema(name=NameSanitizer.sanitize_class_name(schema_name) if schema_name else None)
408
+
409
+ if not isinstance(schema_node, Mapping):
410
+ raise TypeError(
411
+ f"Schema node for '{schema_name or 'anonymous'}' must be a Mapping (e.g., dict), got {type(schema_node)}"
412
+ )
413
+
414
+ # If the current schema_node itself is a $ref, resolve it.
415
+ if "$ref" in schema_node:
416
+ # schema_name is the original name we are trying to parse (e.g., 'Pet')
417
+ # schema_node is {"$ref": "#/components/schemas/ActualPet"}
418
+ # We want to resolve "ActualPet", but the resulting IRSchema should ideally
419
+ # retain the name 'Pet' if appropriate,
420
+ # or _resolve_ref handles naming if ActualPet itself is parsed.
421
+ # The `parent_schema_name` for _resolve_ref here is `schema_name` itself.
422
+ resolved_schema = _resolve_ref(
423
+ schema_node["$ref"], schema_name, context, max_depth_override, allow_self_reference
424
+ )
425
+
426
+ # Store the resolved schema under the current schema_name if it has a name
427
+ # This ensures that synthetic names like "ChildrenItem" are properly stored
428
+ # However, don't create duplicate entries for pure references to existing schemas
429
+ if (
430
+ schema_name
431
+ and resolved_schema
432
+ and resolved_schema.name # Resolved schema has a real name
433
+ and resolved_schema.name != schema_name # Different from synthetic name
434
+ and resolved_schema.name in context.parsed_schemas
435
+ ): # Already exists in context
436
+ # This is a pure reference to an existing schema, don't create duplicate
437
+ pass
438
+ elif schema_name and resolved_schema and schema_name not in context.parsed_schemas:
439
+ context.parsed_schemas[schema_name] = resolved_schema
440
+
441
+ return resolved_schema
442
+
443
+ extracted_type: str | None = None
444
+ is_nullable_from_type_field = False
445
+ raw_type_field = schema_node.get("type")
446
+
447
+ if isinstance(raw_type_field, str):
448
+ # Handle non-standard type values that might appear
449
+ if raw_type_field in ["Any", "any"]:
450
+ # Convert 'Any' to None - will be handled as object later
451
+ extracted_type = None
452
+ logger.warning(
453
+ f"Schema{f' {schema_name}' if schema_name else ''} uses non-standard type 'Any'. "
454
+ "Converting to 'object'. Use standard OpenAPI types: string, number, integer, boolean, array, object."
455
+ )
456
+ elif raw_type_field == "None":
457
+ # Convert 'None' string to null handling
458
+ extracted_type = "null"
459
+ logger.warning(
460
+ f"Schema{f' {schema_name}' if schema_name else ''} uses type 'None'. "
461
+ 'Converting to nullable object. Use \'type: ["object", "null"]\' for nullable types.'
462
+ )
463
+ else:
464
+ extracted_type = raw_type_field
465
+ elif isinstance(raw_type_field, list):
466
+ if "null" in raw_type_field:
467
+ is_nullable_from_type_field = True
468
+ non_null_types = [t for t in raw_type_field if t != "null"]
469
+ if non_null_types:
470
+ extracted_type = non_null_types[0]
471
+ if len(non_null_types) > 1:
472
+ pass
473
+ elif is_nullable_from_type_field:
474
+ extracted_type = "null"
475
+
476
+ any_of_irs, one_of_irs, all_of_components_irs, props_from_comp, req_from_comp, nullable_from_comp = (
477
+ _parse_composition_keywords(
478
+ schema_node,
479
+ schema_name,
480
+ context,
481
+ ENV_MAX_DEPTH,
482
+ lambda n, sn, c, md: _parse_schema(n, sn, c, md, allow_self_reference),
483
+ )
484
+ )
485
+
486
+ # Check for direct nullable field (OpenAPI 3.0 Swagger extension)
487
+ is_nullable_from_node = schema_node.get("nullable", False)
488
+ is_nullable_overall = is_nullable_from_type_field or nullable_from_comp or is_nullable_from_node
489
+ final_properties_for_ir: dict[str, IRSchema] = {}
490
+ current_final_type = extracted_type
491
+ if not current_final_type:
492
+ if props_from_comp or "allOf" in schema_node or "properties" in schema_node:
493
+ current_final_type = "object"
494
+ elif any_of_irs or one_of_irs:
495
+ # Keep None for composition types - they'll be handled by resolver
496
+ current_final_type = None
497
+ elif "enum" in schema_node:
498
+ # Enum without explicit type - infer from enum values
499
+ enum_values = schema_node.get("enum", [])
500
+ if enum_values:
501
+ first_val = enum_values[0]
502
+ if isinstance(first_val, str):
503
+ current_final_type = "string"
504
+ elif isinstance(first_val, (int, float)):
505
+ current_final_type = "number"
506
+ elif isinstance(first_val, bool):
507
+ current_final_type = "boolean"
508
+ else:
509
+ # Fallback to object for complex enum values
510
+ current_final_type = "object"
511
+ else:
512
+ current_final_type = "string" # Default for empty enums
513
+ else:
514
+ # No type specified and no clear indicators - default to object
515
+ # This is safer than 'Any' and matches OpenAPI spec defaults
516
+ current_final_type = "object"
517
+
518
+ if current_final_type == "null":
519
+ # Explicit null type - mark as nullable but use object type
520
+ is_nullable_overall = True
521
+ current_final_type = "object"
522
+
523
+ if current_final_type == "object":
524
+ # Properties from allOf have already been handled by _parse_composition_keywords
525
+ # and are in props_from_comp. We pass these as existing_properties.
526
+ if "properties" in schema_node:
527
+ final_properties_for_ir = _parse_properties(
528
+ schema_node["properties"],
529
+ schema_name,
530
+ props_from_comp, # these are from allOf merge
531
+ context,
532
+ max_depth_override,
533
+ allow_self_reference,
534
+ )
535
+ else:
536
+ final_properties_for_ir = props_from_comp.copy() # No direct properties, only from allOf
537
+
538
+ # final_required_set: Set[str] = set() # This was old logic, req_from_comp covers allOf
539
+ # if "properties" in schema_node: # This loop is now inside _parse_properties effectively
540
+ # ... existing property parsing loop removed ...
541
+ final_required_fields_set = req_from_comp.copy()
542
+ if "required" in schema_node and isinstance(schema_node["required"], list):
543
+ final_required_fields_set.update(schema_node["required"])
544
+
545
+ items_ir: IRSchema | None = None
546
+ if current_final_type == "array":
547
+ items_node = schema_node.get("items")
548
+ if items_node:
549
+ # Avoid generating synthetic names for $ref items - let the ref resolve naturally
550
+ # This prevents false cycle detection when AgentListResponse -> $ref: AgentListResponseItem
551
+ if isinstance(items_node, Mapping) and "$ref" in items_node:
552
+ # For $ref items, pass None as schema_name to let _resolve_ref handle the naming
553
+ item_schema_name_for_recursive_parse = None
554
+ elif isinstance(items_node, Mapping) and items_node.get("type") in [
555
+ "string",
556
+ "integer",
557
+ "number",
558
+ "boolean",
559
+ ]:
560
+ # Primitive items should NOT get names - they should remain inline as List[str] etc.
561
+ item_schema_name_for_recursive_parse = None
562
+ elif (
563
+ isinstance(items_node, Mapping)
564
+ and not items_node.get("type")
565
+ and not items_node.get("$ref")
566
+ and not items_node.get("allOf")
567
+ and not items_node.get("anyOf")
568
+ and not items_node.get("oneOf")
569
+ and not items_node.get("properties")
570
+ ):
571
+ # Items with no type, $ref, or composition (e.g., just {nullable: true}) - treat as Any
572
+ # This is a malformed schema pattern, log a warning and use null type (resolves to Any)
573
+ nullable_only = items_node.get("nullable", False) and len(items_node) == 1
574
+ if nullable_only:
575
+ logger.warning(
576
+ f"Array items with only 'nullable: true' and no type "
577
+ f"found{f' in {schema_name}' if schema_name else ''}. "
578
+ "This is likely a malformed OpenAPI schema. Using 'Any' as item type. "
579
+ "Consider adding 'type: string' or appropriate type to your OpenAPI spec."
580
+ )
581
+ else:
582
+ logger.warning(
583
+ f"Array items with no 'type' field found{f' in {schema_name}' if schema_name else ''}. "
584
+ "Using 'Any' as item type. "
585
+ "Consider adding 'type' to your OpenAPI spec for better type safety."
586
+ )
587
+ # Create a null-type schema that will resolve to Any
588
+ items_ir = IRSchema(type="null", is_nullable=items_node.get("nullable", False))
589
+ elif isinstance(items_node, Mapping) and items_node.get("type") == "null":
590
+ # Explicit null type for items - resolve to Any | None
591
+ items_ir = IRSchema(type="null", is_nullable=True)
592
+ else:
593
+ # For inline items, generate a synthetic name
594
+ base_name_for_item = schema_name or "AnonymousArray"
595
+ item_schema_name_for_recursive_parse = NameSanitizer.sanitize_class_name(
596
+ f"{base_name_for_item}Item"
597
+ )
598
+
599
+ # Ensure unique names for anonymous array items to avoid schema overwrites
600
+ # Only check for collision if this specific name already exists
601
+ if not schema_name and item_schema_name_for_recursive_parse in context.parsed_schemas:
602
+ counter = 2 # Start from 2 since original is 1
603
+ original_name = item_schema_name_for_recursive_parse
604
+ while item_schema_name_for_recursive_parse in context.parsed_schemas:
605
+ item_schema_name_for_recursive_parse = f"{original_name}{counter}"
606
+ counter += 1
607
+
608
+ # Only parse items if items_ir wasn't directly set above (for null/Any cases)
609
+ if items_ir is None:
610
+ actual_item_ir = _parse_schema(
611
+ item_schema_name_for_recursive_parse,
612
+ items_node,
613
+ context,
614
+ max_depth_override,
615
+ allow_self_reference,
616
+ )
617
+
618
+ is_promoted_inline_object = (
619
+ isinstance(items_node, Mapping)
620
+ and items_node.get("type") == "object"
621
+ and "$ref" not in items_node
622
+ and actual_item_ir.name == item_schema_name_for_recursive_parse
623
+ )
624
+
625
+ if is_promoted_inline_object:
626
+ ref_holder_ir = IRSchema(
627
+ name=None,
628
+ type=actual_item_ir.name,
629
+ description=actual_item_ir.description or items_node.get("description"),
630
+ )
631
+ ref_holder_ir._refers_to_schema = actual_item_ir
632
+ items_ir = ref_holder_ir
633
+ else:
634
+ items_ir = actual_item_ir
635
+ else:
636
+ # Array without items specification - use object as safer default
637
+ # Log warning to help developers fix their specs
638
+ logger.warning(
639
+ f"Array type without 'items' specification found{f' in {schema_name}' if schema_name else ''}. "
640
+ "Using 'object' as item type. Consider adding 'items' to your OpenAPI spec for better type safety."
641
+ )
642
+ items_ir = IRSchema(type="object")
643
+
644
+ schema_ir_name_attr = NameSanitizer.sanitize_class_name(schema_name) if schema_name else None
645
+
646
+ # Parse additionalProperties field
647
+ additional_properties_value: bool | IRSchema | None = None
648
+ if "additionalProperties" in schema_node:
649
+ additional_props_node = schema_node["additionalProperties"]
650
+ if isinstance(additional_props_node, bool):
651
+ additional_properties_value = additional_props_node
652
+ elif isinstance(additional_props_node, dict):
653
+ # Parse the additionalProperties schema
654
+ additional_properties_value = _parse_schema(
655
+ None, # No name for additional properties schema
656
+ additional_props_node,
657
+ context,
658
+ max_depth_override,
659
+ allow_self_reference,
660
+ )
661
+
662
+ schema_ir = IRSchema(
663
+ name=schema_ir_name_attr,
664
+ type=current_final_type,
665
+ properties=final_properties_for_ir,
666
+ any_of=any_of_irs,
667
+ one_of=one_of_irs,
668
+ all_of=all_of_components_irs,
669
+ required=sorted(list(final_required_fields_set)),
670
+ description=schema_node.get("description"),
671
+ format=schema_node.get("format") if isinstance(schema_node.get("format"), str) else None,
672
+ enum=schema_node.get("enum") if isinstance(schema_node.get("enum"), list) else None,
673
+ default=schema_node.get("default"),
674
+ example=schema_node.get("example"),
675
+ is_nullable=is_nullable_overall,
676
+ items=items_ir,
677
+ additional_properties=additional_properties_value,
678
+ )
679
+
680
+ # Re-parse items for complex array types that need special handling
681
+ # Skip if items was already set to null type (typeless items that resolve to Any)
682
+ if (
683
+ schema_ir.type == "array"
684
+ and isinstance(schema_node.get("items"), Mapping)
685
+ and not (schema_ir.items and schema_ir.items.type == "null") # Skip if already set to null/Any
686
+ ):
687
+ raw_items_node = schema_node["items"]
688
+ item_schema_context_name_for_reparse: str | None
689
+
690
+ # Avoid generating synthetic names for $ref items - let the ref resolve naturally
691
+ if "$ref" in raw_items_node:
692
+ item_schema_context_name_for_reparse = None
693
+ elif raw_items_node.get("type") in ["string", "integer", "number", "boolean"]:
694
+ # Primitive items should NOT get names - they should remain inline as List[str] etc.
695
+ item_schema_context_name_for_reparse = None
696
+ elif (
697
+ not raw_items_node.get("type")
698
+ and "$ref" not in raw_items_node
699
+ and "allOf" not in raw_items_node
700
+ and "anyOf" not in raw_items_node
701
+ and "oneOf" not in raw_items_node
702
+ and "properties" not in raw_items_node
703
+ ):
704
+ # Typeless items (e.g., just {nullable: true}) - don't create synthetic name
705
+ # These will resolve to Any type - but this case should be caught above
706
+ item_schema_context_name_for_reparse = None
707
+ else:
708
+ base_name_for_reparse_item = schema_name or "AnonymousArray"
709
+ item_schema_context_name_for_reparse = NameSanitizer.sanitize_class_name(
710
+ f"{base_name_for_reparse_item}Item"
711
+ )
712
+
713
+ # Ensure unique names for anonymous array items to avoid schema overwrites
714
+ # Only check for collision if this specific name already exists
715
+ if not schema_name and item_schema_context_name_for_reparse in context.parsed_schemas:
716
+ counter = 2 # Start from 2 since original is 1
717
+ original_name = item_schema_context_name_for_reparse
718
+ while item_schema_context_name_for_reparse in context.parsed_schemas:
719
+ item_schema_context_name_for_reparse = f"{original_name}{counter}"
720
+ counter += 1
721
+
722
+ direct_reparsed_item_ir = _parse_schema(
723
+ item_schema_context_name_for_reparse, raw_items_node, context, max_depth_override, allow_self_reference
724
+ )
725
+
726
+ is_promoted_inline_object_in_reparse_block = (
727
+ isinstance(raw_items_node, Mapping)
728
+ and raw_items_node.get("type") == "object"
729
+ and "$ref" not in raw_items_node
730
+ and direct_reparsed_item_ir.name == item_schema_context_name_for_reparse
731
+ )
732
+
733
+ if is_promoted_inline_object_in_reparse_block:
734
+ ref_holder_for_reparse_ir = IRSchema(
735
+ name=None,
736
+ type=direct_reparsed_item_ir.name,
737
+ description=direct_reparsed_item_ir.description or raw_items_node.get("description"),
738
+ )
739
+ ref_holder_for_reparse_ir._refers_to_schema = direct_reparsed_item_ir
740
+ schema_ir.items = ref_holder_for_reparse_ir
741
+ else:
742
+ schema_ir.items = direct_reparsed_item_ir
743
+
744
+ if schema_name and schema_name in context.parsed_schemas:
745
+ existing_in_context = context.parsed_schemas[schema_name]
746
+
747
+ if existing_in_context._is_circular_ref and existing_in_context is not schema_ir:
748
+ return existing_in_context
749
+
750
+ # Don't register synthetic primitive type schemas as standalone schemas
751
+ # They should remain inline to avoid unnecessary schema proliferation
752
+ # BUT: Top-level schemas defined in components/schemas should always be registered,
753
+ # even if they are primitive types
754
+ # NOTE: Schemas with enums are NOT primitives - they need to be generated as enum classes
755
+ is_primitive_schema = schema_ir.type in ["string", "integer", "number", "boolean"] and not schema_ir.enum
756
+ is_top_level_schema = schema_name and schema_name in context.raw_spec_schemas
757
+ is_synthetic_primitive = is_primitive_schema and not is_top_level_schema
758
+ should_register = (
759
+ schema_name
760
+ and not schema_ir._from_unresolved_ref
761
+ and not schema_ir._max_depth_exceeded_marker
762
+ and not is_synthetic_primitive
763
+ )
764
+ if should_register and schema_name:
765
+ context.parsed_schemas[schema_name] = schema_ir
766
+
767
+ # Set generation_name and final_module_stem for schemas that will be generated as separate files
768
+ # Skip for synthetic primitives (inline types) - they should remain without these attributes
769
+ # so the type resolver doesn't try to import them as separate modules
770
+ # Note: Boolean enums are handled inline as Literal[True/False] and should not be extracted
771
+ should_set_generation_names = (
772
+ schema_ir.name
773
+ and not is_synthetic_primitive
774
+ and not (schema_ir.type == "boolean" and schema_ir.enum) # Boolean enums are inline Literal types
775
+ )
776
+ if should_set_generation_names and schema_ir.name: # Extra check to help mypy narrow the type
777
+ schema_ir.generation_name = NameSanitizer.sanitize_class_name(schema_ir.name)
778
+ schema_ir.final_module_stem = NameSanitizer.sanitize_module_name(schema_ir.name)
779
+
780
+ # Check if this schema was involved in any detected cycles and mark it accordingly
781
+ # This must happen before returning the schema
782
+ if schema_name:
783
+ for cycle_info in context.unified_cycle_context.detected_cycles:
784
+ if (
785
+ cycle_info.cycle_path
786
+ and cycle_info.cycle_path[0] == schema_name
787
+ and cycle_info.cycle_path[-1] == schema_name
788
+ ):
789
+ # This schema is the start and end of a cycle
790
+ # Only mark as circular if it's a direct self-reference (via immediate intermediate schema)
791
+ # or if the test case specifically expects this behavior (array items)
792
+ is_direct_self_ref = len(cycle_info.cycle_path) == 2
793
+ is_array_item_self_ref = len(cycle_info.cycle_path) == 3 and "Item" in cycle_info.cycle_path[1]
794
+
795
+ if is_direct_self_ref or is_array_item_self_ref:
796
+ schema_ir._is_circular_ref = True
797
+ schema_ir._from_unresolved_ref = True
798
+ schema_ir._circular_ref_path = " -> ".join(cycle_info.cycle_path)
799
+ break
800
+
801
+ return schema_ir
802
+
803
+ finally:
804
+ context.unified_exit_schema(schema_name)