pyopenapi-gen 2.7.2__py3-none-any.whl → 3.0.1__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.
- pyopenapi_gen/__init__.py +1 -1
- pyopenapi_gen/core/cattrs_converter.py +86 -8
- pyopenapi_gen/core/loader/schemas/extractor.py +35 -4
- pyopenapi_gen/core/parsing/schema_parser.py +162 -40
- pyopenapi_gen/core/utils.py +30 -5
- pyopenapi_gen/core/writers/python_construct_renderer.py +75 -2
- pyopenapi_gen/emitters/models_emitter.py +43 -6
- pyopenapi_gen/ir.py +26 -0
- pyopenapi_gen/visit/model/alias_generator.py +4 -0
- pyopenapi_gen/visit/model/model_visitor.py +10 -1
- {pyopenapi_gen-2.7.2.dist-info → pyopenapi_gen-3.0.1.dist-info}/METADATA +1 -1
- {pyopenapi_gen-2.7.2.dist-info → pyopenapi_gen-3.0.1.dist-info}/RECORD +15 -15
- {pyopenapi_gen-2.7.2.dist-info → pyopenapi_gen-3.0.1.dist-info}/WHEEL +0 -0
- {pyopenapi_gen-2.7.2.dist-info → pyopenapi_gen-3.0.1.dist-info}/entry_points.txt +0 -0
- {pyopenapi_gen-2.7.2.dist-info → pyopenapi_gen-3.0.1.dist-info}/licenses/LICENSE +0 -0
pyopenapi_gen/__init__.py
CHANGED
|
@@ -50,7 +50,7 @@ __all__ = [
|
|
|
50
50
|
]
|
|
51
51
|
|
|
52
52
|
# Semantic version of the generator core – automatically managed by semantic-release.
|
|
53
|
-
__version__: str = "
|
|
53
|
+
__version__: str = "3.0.1"
|
|
54
54
|
|
|
55
55
|
# ---------------------------------------------------------------------------
|
|
56
56
|
# Lazy-loading and autocompletion support (This part remains)
|
|
@@ -307,17 +307,34 @@ converter.register_unstructure_hook(date, unstructure_date)
|
|
|
307
307
|
|
|
308
308
|
def _is_union_type(t: Any) -> bool:
|
|
309
309
|
"""
|
|
310
|
-
Check if a type is a Union type.
|
|
310
|
+
Check if a type is a Union type (including Annotated[Union, ...]).
|
|
311
311
|
|
|
312
312
|
Scenario:
|
|
313
|
-
Detect Union types including both typing.Union and Python 3.10+ X | Y syntax
|
|
313
|
+
Detect Union types including both typing.Union and Python 3.10+ X | Y syntax,
|
|
314
|
+
as well as Annotated[Union, ...] for discriminated unions.
|
|
314
315
|
|
|
315
316
|
Expected Outcome:
|
|
316
317
|
Returns True for Union types, False otherwise.
|
|
317
318
|
"""
|
|
318
319
|
origin = get_origin(t)
|
|
319
320
|
# Handle both typing.Union and types.UnionType (Python 3.10+ X | Y syntax)
|
|
320
|
-
|
|
321
|
+
if origin is Union or (hasattr(types, "UnionType") and isinstance(t, types.UnionType)):
|
|
322
|
+
return True
|
|
323
|
+
|
|
324
|
+
# Also handle Annotated[Union, ...] for discriminated unions
|
|
325
|
+
# Import Annotated here to avoid module-level import
|
|
326
|
+
try:
|
|
327
|
+
from typing import Annotated
|
|
328
|
+
|
|
329
|
+
if origin is Annotated:
|
|
330
|
+
# Check if the first arg is a Union
|
|
331
|
+
args = get_args(t)
|
|
332
|
+
if args and _is_union_type(args[0]):
|
|
333
|
+
return True
|
|
334
|
+
except ImportError:
|
|
335
|
+
pass
|
|
336
|
+
|
|
337
|
+
return False
|
|
321
338
|
|
|
322
339
|
|
|
323
340
|
def _truncate_data_repr(data: Any, max_length: int = 200) -> str:
|
|
@@ -353,10 +370,11 @@ def _structure_union(data: Any, union_type: type) -> Any:
|
|
|
353
370
|
|
|
354
371
|
Strategy:
|
|
355
372
|
1. If data is None and NoneType is in the union, return None
|
|
356
|
-
2.
|
|
357
|
-
3.
|
|
358
|
-
4.
|
|
359
|
-
5.
|
|
373
|
+
2. Check for discriminator metadata for O(1) variant lookup
|
|
374
|
+
3. If data is a dict, try each dataclass variant
|
|
375
|
+
4. Try structuring with other variants (generic types, registered hooks)
|
|
376
|
+
5. Fall back to dict[str, Any] if present
|
|
377
|
+
6. Raise error if no variant matches
|
|
360
378
|
|
|
361
379
|
Args:
|
|
362
380
|
data: The raw data to structure
|
|
@@ -369,7 +387,24 @@ def _structure_union(data: Any, union_type: type) -> Any:
|
|
|
369
387
|
TypeError: If data is None but NoneType not in union
|
|
370
388
|
ValueError: If no Union variant matches the data
|
|
371
389
|
"""
|
|
372
|
-
|
|
390
|
+
# If this is Annotated[Union[...], metadata], extract the Union and metadata
|
|
391
|
+
origin = get_origin(union_type)
|
|
392
|
+
try:
|
|
393
|
+
from typing import Annotated
|
|
394
|
+
|
|
395
|
+
if origin is Annotated:
|
|
396
|
+
# Extract Union type and metadata from Annotated
|
|
397
|
+
annotated_args = get_args(union_type)
|
|
398
|
+
if annotated_args:
|
|
399
|
+
# First arg is the actual Union, rest are metadata
|
|
400
|
+
actual_union = annotated_args[0]
|
|
401
|
+
args = get_args(actual_union)
|
|
402
|
+
else:
|
|
403
|
+
args = get_args(union_type)
|
|
404
|
+
else:
|
|
405
|
+
args = get_args(union_type)
|
|
406
|
+
except ImportError:
|
|
407
|
+
args = get_args(union_type)
|
|
373
408
|
|
|
374
409
|
# Handle None explicitly
|
|
375
410
|
if data is None:
|
|
@@ -377,6 +412,49 @@ def _structure_union(data: Any, union_type: type) -> Any:
|
|
|
377
412
|
return None
|
|
378
413
|
raise TypeError(f"None is not valid for {union_type}")
|
|
379
414
|
|
|
415
|
+
# Check for discriminator metadata (from Annotated[Union[...], discriminator])
|
|
416
|
+
# This enables O(1) lookup instead of O(n) sequential tries
|
|
417
|
+
if hasattr(union_type, "__metadata__"):
|
|
418
|
+
for metadata in union_type.__metadata__:
|
|
419
|
+
# Check if this is discriminator metadata (has property_name and get_mapping method)
|
|
420
|
+
if hasattr(metadata, "property_name") and hasattr(metadata, "get_mapping"):
|
|
421
|
+
if isinstance(data, dict) and metadata.property_name in data:
|
|
422
|
+
discriminator_value = data[metadata.property_name]
|
|
423
|
+
|
|
424
|
+
# Get the mapping using the get_mapping method
|
|
425
|
+
mapping = metadata.get_mapping()
|
|
426
|
+
|
|
427
|
+
if mapping and discriminator_value in mapping:
|
|
428
|
+
variant = mapping[discriminator_value]
|
|
429
|
+
|
|
430
|
+
# Handle forward reference strings
|
|
431
|
+
if isinstance(variant, str):
|
|
432
|
+
# Try to find the actual type from union args
|
|
433
|
+
for arg in args:
|
|
434
|
+
if hasattr(arg, "__name__") and arg.__name__ == variant:
|
|
435
|
+
variant = arg
|
|
436
|
+
break
|
|
437
|
+
|
|
438
|
+
# Attempt to structure with the discriminated variant
|
|
439
|
+
try:
|
|
440
|
+
_register_structure_hooks_recursively(variant)
|
|
441
|
+
return converter.structure(data, variant)
|
|
442
|
+
except Exception as e:
|
|
443
|
+
# Provide clear error message for discriminated variant failure
|
|
444
|
+
raise ValueError(
|
|
445
|
+
f"Failed to deserialize as {variant.__name__} "
|
|
446
|
+
f"(discriminator {metadata.property_name}={discriminator_value!r}): {e}"
|
|
447
|
+
) from e
|
|
448
|
+
elif mapping:
|
|
449
|
+
# Unknown discriminator value
|
|
450
|
+
valid_values = list(mapping.keys())
|
|
451
|
+
raise ValueError(
|
|
452
|
+
f"Unknown discriminator value {discriminator_value!r} "
|
|
453
|
+
f"for field {metadata.property_name!r}. "
|
|
454
|
+
f"Expected one of: {valid_values}"
|
|
455
|
+
)
|
|
456
|
+
# If discriminator property not in data, fall through to sequential try
|
|
457
|
+
|
|
380
458
|
# Separate variants by type category
|
|
381
459
|
dataclass_variants: list[type[Any]] = []
|
|
382
460
|
dict_any_fallback = False
|
|
@@ -37,12 +37,16 @@ def build_schemas(raw_schemas: dict[str, Mapping[str, Any]], raw_components: Map
|
|
|
37
37
|
|
|
38
38
|
# Build initial IR for all schemas found in components
|
|
39
39
|
for n, nd in raw_schemas.items():
|
|
40
|
-
if
|
|
40
|
+
# Check if schema is already registered (either by original name or sanitized name)
|
|
41
|
+
sanitized_n = NameSanitizer.sanitize_class_name(n)
|
|
42
|
+
if n not in context.parsed_schemas and sanitized_n not in context.parsed_schemas:
|
|
41
43
|
_parse_schema(n, nd, context, allow_self_reference=True)
|
|
42
44
|
|
|
43
|
-
# Post-condition check
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
# Post-condition check: each raw schema must be registered under either its original or sanitized name
|
|
46
|
+
for n in raw_schemas:
|
|
47
|
+
sanitized_n = NameSanitizer.sanitize_class_name(n)
|
|
48
|
+
if n not in context.parsed_schemas and sanitized_n not in context.parsed_schemas:
|
|
49
|
+
raise RuntimeError(f"Schema '{n}' (sanitized: '{sanitized_n}') was not parsed")
|
|
46
50
|
|
|
47
51
|
return context
|
|
48
52
|
|
|
@@ -192,6 +196,13 @@ def extract_inline_enums(schemas: dict[str, IRSchema]) -> dict[str, IRSchema]:
|
|
|
192
196
|
|
|
193
197
|
# Extract inline enums from properties
|
|
194
198
|
for prop_name, prop_schema in list(schema.properties.items()):
|
|
199
|
+
# Debug: log property state
|
|
200
|
+
logger.debug(
|
|
201
|
+
f"Processing {schema_name}.{prop_name}: "
|
|
202
|
+
f"type={prop_schema.type}, enum={prop_schema.enum}, "
|
|
203
|
+
f"name={prop_schema.name}, generation_name={prop_schema.generation_name}"
|
|
204
|
+
)
|
|
205
|
+
|
|
195
206
|
# Check if this property has an inline enum that needs extraction
|
|
196
207
|
# An inline enum needs extraction if:
|
|
197
208
|
# 1. It has enum values defined
|
|
@@ -204,6 +215,7 @@ def extract_inline_enums(schemas: dict[str, IRSchema]) -> dict[str, IRSchema]:
|
|
|
204
215
|
# Check if the enum was already extracted or is a named reference
|
|
205
216
|
# Case 1: generation_name exists in schemas dict (already extracted)
|
|
206
217
|
# Case 2: property name itself is a schema reference (e.g., ExistingStatusEnum)
|
|
218
|
+
# Case 3: property.type is already an enum name (created during parsing)
|
|
207
219
|
enum_already_extracted = (
|
|
208
220
|
(
|
|
209
221
|
prop_schema.generation_name
|
|
@@ -224,9 +236,28 @@ def extract_inline_enums(schemas: dict[str, IRSchema]) -> dict[str, IRSchema]:
|
|
|
224
236
|
and "_" not in prop_schema.name
|
|
225
237
|
and prop_schema.name != prop_name # Name differs from property key
|
|
226
238
|
)
|
|
239
|
+
or (
|
|
240
|
+
# Property.type is already an enum schema name (created during parsing)
|
|
241
|
+
prop_schema.type
|
|
242
|
+
and prop_schema.type in schemas
|
|
243
|
+
and schemas[prop_schema.type].enum
|
|
244
|
+
)
|
|
227
245
|
)
|
|
228
246
|
|
|
229
247
|
if has_inline_enum and not enum_already_extracted:
|
|
248
|
+
# Check if this enum was already created during parsing
|
|
249
|
+
# If prop_schema.type is already an enum name (not a primitive type), reuse it
|
|
250
|
+
if prop_schema.type and prop_schema.type in schemas and schemas[prop_schema.type].enum:
|
|
251
|
+
# Enum already exists - just ensure property references it correctly
|
|
252
|
+
enum_name = prop_schema.type
|
|
253
|
+
logger.debug(f"Reusing existing enum {enum_name} for {schema_name}.{prop_name}")
|
|
254
|
+
# Don't create a new enum, just update property references
|
|
255
|
+
prop_schema.name = enum_name
|
|
256
|
+
prop_schema.generation_name = schemas[enum_name].generation_name or enum_name
|
|
257
|
+
prop_schema.final_module_stem = schemas[enum_name].final_module_stem
|
|
258
|
+
prop_schema.enum = None # Clear inline enum
|
|
259
|
+
continue # Skip creating new enum
|
|
260
|
+
|
|
230
261
|
# Use property's existing generation_name if set, otherwise create a new name
|
|
231
262
|
# This keeps naming consistent with what the type resolver already assigned
|
|
232
263
|
if prop_schema.generation_name:
|
|
@@ -10,6 +10,7 @@ from typing import Any, Callable, List, Mapping, Set, Tuple
|
|
|
10
10
|
|
|
11
11
|
from pyopenapi_gen import IRSchema
|
|
12
12
|
from pyopenapi_gen.core.utils import NameSanitizer
|
|
13
|
+
from pyopenapi_gen.ir import IRDiscriminator
|
|
13
14
|
|
|
14
15
|
from .context import ParsingContext
|
|
15
16
|
from .keywords.all_of_parser import _process_all_of
|
|
@@ -86,8 +87,16 @@ def _parse_composition_keywords(
|
|
|
86
87
|
context: ParsingContext,
|
|
87
88
|
max_depth: int,
|
|
88
89
|
parse_fn: Callable[[str | None, Mapping[str, Any] | None, ParsingContext, int | None], IRSchema],
|
|
89
|
-
) -> Tuple[
|
|
90
|
-
|
|
90
|
+
) -> Tuple[
|
|
91
|
+
List[IRSchema] | None,
|
|
92
|
+
List[IRSchema] | None,
|
|
93
|
+
List[IRSchema] | None,
|
|
94
|
+
dict[str, IRSchema],
|
|
95
|
+
Set[str],
|
|
96
|
+
bool,
|
|
97
|
+
IRDiscriminator | None,
|
|
98
|
+
]:
|
|
99
|
+
"""Parse composition keywords (anyOf, oneOf, allOf) and discriminator from a schema node.
|
|
91
100
|
|
|
92
101
|
Contracts:
|
|
93
102
|
Pre-conditions:
|
|
@@ -97,7 +106,7 @@ def _parse_composition_keywords(
|
|
|
97
106
|
- parse_fn is a callable for parsing schemas
|
|
98
107
|
Post-conditions:
|
|
99
108
|
- Returns a tuple of (any_of_schemas, one_of_schemas, all_of_components,
|
|
100
|
-
properties, required_fields, is_nullable)
|
|
109
|
+
properties, required_fields, is_nullable, discriminator)
|
|
101
110
|
"""
|
|
102
111
|
any_of_schemas: List[IRSchema] | None = None
|
|
103
112
|
one_of_schemas: List[IRSchema] | None = None
|
|
@@ -105,6 +114,7 @@ def _parse_composition_keywords(
|
|
|
105
114
|
merged_properties: dict[str, IRSchema] = {}
|
|
106
115
|
merged_required_set: Set[str] = set()
|
|
107
116
|
is_nullable: bool = False
|
|
117
|
+
discriminator: IRDiscriminator | None = None
|
|
108
118
|
|
|
109
119
|
if "anyOf" in node:
|
|
110
120
|
parsed_sub_schemas, nullable_from_sub, _ = _parse_any_of_schemas(node["anyOf"], context, max_depth, parse_fn)
|
|
@@ -116,6 +126,18 @@ def _parse_composition_keywords(
|
|
|
116
126
|
one_of_schemas = parsed_sub_schemas
|
|
117
127
|
is_nullable = is_nullable or nullable_from_sub
|
|
118
128
|
|
|
129
|
+
# Extract discriminator if present (applies to oneOf or anyOf)
|
|
130
|
+
if "discriminator" in node and isinstance(node["discriminator"], Mapping):
|
|
131
|
+
disc_node = node["discriminator"]
|
|
132
|
+
property_name = disc_node.get("propertyName")
|
|
133
|
+
if property_name:
|
|
134
|
+
# Extract mapping if present
|
|
135
|
+
mapping = None
|
|
136
|
+
if "mapping" in disc_node and isinstance(disc_node["mapping"], Mapping):
|
|
137
|
+
mapping = dict(disc_node["mapping"])
|
|
138
|
+
|
|
139
|
+
discriminator = IRDiscriminator(property_name=property_name, mapping=mapping)
|
|
140
|
+
|
|
119
141
|
if "allOf" in node:
|
|
120
142
|
merged_properties, merged_required_set, parsed_all_of_components = _process_all_of(
|
|
121
143
|
node, name, context, parse_fn, max_depth=max_depth
|
|
@@ -123,7 +145,15 @@ def _parse_composition_keywords(
|
|
|
123
145
|
else:
|
|
124
146
|
merged_required_set = set(node.get("required", []))
|
|
125
147
|
|
|
126
|
-
return
|
|
148
|
+
return (
|
|
149
|
+
any_of_schemas,
|
|
150
|
+
one_of_schemas,
|
|
151
|
+
parsed_all_of_components,
|
|
152
|
+
merged_properties,
|
|
153
|
+
merged_required_set,
|
|
154
|
+
is_nullable,
|
|
155
|
+
discriminator,
|
|
156
|
+
)
|
|
127
157
|
|
|
128
158
|
|
|
129
159
|
def _parse_properties(
|
|
@@ -190,7 +220,21 @@ def _parse_properties(
|
|
|
190
220
|
and not promoted_ir_schema._max_depth_exceeded_marker
|
|
191
221
|
and not promoted_ir_schema._is_circular_ref
|
|
192
222
|
):
|
|
193
|
-
|
|
223
|
+
# CRITICAL: Register using the schema's sanitized name to prevent duplicate keys
|
|
224
|
+
registration_key = promoted_ir_schema.name if promoted_ir_schema.name else promoted_schema_name
|
|
225
|
+
|
|
226
|
+
# Check for collision
|
|
227
|
+
if registration_key in context.parsed_schemas:
|
|
228
|
+
existing_schema = context.parsed_schemas[registration_key]
|
|
229
|
+
if existing_schema is not promoted_ir_schema:
|
|
230
|
+
# Collision - use original name
|
|
231
|
+
registration_key = promoted_schema_name
|
|
232
|
+
logger.debug(
|
|
233
|
+
f"Promoted schema collision: {promoted_ir_schema.name!r} already registered. "
|
|
234
|
+
f"Using original name {promoted_schema_name!r}."
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
context.parsed_schemas[registration_key] = promoted_ir_schema
|
|
194
238
|
else:
|
|
195
239
|
# Directly parse other inline types (string, number, array of simple types, etc.)
|
|
196
240
|
# or objects that are not being promoted (e.g. if parent_schema_name is None)
|
|
@@ -248,8 +292,18 @@ def _parse_properties(
|
|
|
248
292
|
)
|
|
249
293
|
)
|
|
250
294
|
|
|
251
|
-
# Use a sanitized version of prop_name
|
|
252
|
-
|
|
295
|
+
# Use a sanitized version of prop_name combined with parent schema name for unique context
|
|
296
|
+
# This ensures properties with the same name in different schemas get unique enum names
|
|
297
|
+
if parent_schema_name:
|
|
298
|
+
sanitized_prop_name = NameSanitizer.sanitize_class_name(prop_name)
|
|
299
|
+
# Avoid redundant prefixing if the property name already starts with the parent schema name
|
|
300
|
+
# e.g., Entry + entry_specific_role -> EntrySpecificRole (not EntryEntrySpecificRole)
|
|
301
|
+
if sanitized_prop_name.lower().startswith(parent_schema_name.lower()):
|
|
302
|
+
prop_context_name = sanitized_prop_name
|
|
303
|
+
else:
|
|
304
|
+
prop_context_name = f"{parent_schema_name}{sanitized_prop_name}"
|
|
305
|
+
else:
|
|
306
|
+
prop_context_name = NameSanitizer.sanitize_class_name(prop_name)
|
|
253
307
|
|
|
254
308
|
# For simple primitives and simple arrays, avoid creating separate schemas
|
|
255
309
|
if (is_simple_primitive or is_simple_array) and prop_context_name in context.parsed_schemas:
|
|
@@ -301,7 +355,7 @@ def _parse_properties(
|
|
|
301
355
|
prop_is_nullable = True
|
|
302
356
|
|
|
303
357
|
property_holder_ir = IRSchema(
|
|
304
|
-
name=
|
|
358
|
+
name=None, # Property name is the dict key, not stored in the schema object
|
|
305
359
|
# Type is the name of the (potentially registered) anonymous schema
|
|
306
360
|
type=parsed_prop_schema_ir.name,
|
|
307
361
|
description=prop_schema_node.get("description", parsed_prop_schema_ir.description),
|
|
@@ -317,27 +371,63 @@ def _parse_properties(
|
|
|
317
371
|
else:
|
|
318
372
|
# Simpler type, or error placeholder. Assign directly but ensure original prop_name is used.
|
|
319
373
|
# 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
374
|
|
|
340
|
-
|
|
375
|
+
# CRITICAL: If the schema was registered as a standalone schema (e.g., inline enum with parent context name),
|
|
376
|
+
# we must NOT modify its name attribute. Instead, create a reference holder.
|
|
377
|
+
schema_was_registered_standalone = (
|
|
378
|
+
schema_name_for_parsing is not None
|
|
379
|
+
and parsed_prop_schema_ir.name == schema_name_for_parsing
|
|
380
|
+
and context.is_schema_parsed(schema_name_for_parsing)
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
if schema_was_registered_standalone:
|
|
384
|
+
# Schema is registered - create a reference holder instead of modifying the schema
|
|
385
|
+
# CRITICAL: Do NOT set 'name' field for property holders - the property name is the dict key.
|
|
386
|
+
# Setting 'name' would cause __post_init__ to sanitize it, changing 'sender_role' to 'SenderRole'.
|
|
387
|
+
prop_is_nullable = False
|
|
388
|
+
if isinstance(prop_schema_node, Mapping):
|
|
389
|
+
if "nullable" in prop_schema_node:
|
|
390
|
+
prop_is_nullable = prop_schema_node["nullable"]
|
|
391
|
+
elif isinstance(prop_schema_node.get("type"), list) and "null" in prop_schema_node["type"]:
|
|
392
|
+
prop_is_nullable = True
|
|
393
|
+
elif parsed_prop_schema_ir.is_nullable:
|
|
394
|
+
prop_is_nullable = True
|
|
395
|
+
|
|
396
|
+
property_holder_ir = IRSchema(
|
|
397
|
+
name=None, # Property name is the dict key, not stored in the schema object
|
|
398
|
+
type=parsed_prop_schema_ir.name, # Reference to the registered schema
|
|
399
|
+
description=prop_schema_node.get("description", parsed_prop_schema_ir.description),
|
|
400
|
+
is_nullable=prop_is_nullable,
|
|
401
|
+
default=prop_schema_node.get("default"),
|
|
402
|
+
example=prop_schema_node.get("example"),
|
|
403
|
+
enum=prop_schema_node.get("enum") if not parsed_prop_schema_ir.enum else None,
|
|
404
|
+
format=parsed_prop_schema_ir.format,
|
|
405
|
+
_refers_to_schema=parsed_prop_schema_ir,
|
|
406
|
+
)
|
|
407
|
+
parsed_props[prop_name] = property_holder_ir
|
|
408
|
+
else:
|
|
409
|
+
# Schema was not registered - safe to modify its name
|
|
410
|
+
final_prop_ir = parsed_prop_schema_ir
|
|
411
|
+
# Always assign the property name - this is the property's name in the parent object
|
|
412
|
+
final_prop_ir.name = prop_name
|
|
413
|
+
if isinstance(prop_schema_node, Mapping):
|
|
414
|
+
final_prop_ir.description = prop_schema_node.get(
|
|
415
|
+
"description", parsed_prop_schema_ir.description
|
|
416
|
+
)
|
|
417
|
+
final_prop_ir.default = prop_schema_node.get("default", parsed_prop_schema_ir.default)
|
|
418
|
+
final_prop_ir.example = prop_schema_node.get("example", parsed_prop_schema_ir.example)
|
|
419
|
+
current_prop_node_nullable = prop_schema_node.get("nullable", False)
|
|
420
|
+
type_list_nullable = (
|
|
421
|
+
isinstance(prop_schema_node.get("type"), list) and "null" in prop_schema_node["type"]
|
|
422
|
+
)
|
|
423
|
+
final_prop_ir.is_nullable = (
|
|
424
|
+
final_prop_ir.is_nullable or current_prop_node_nullable or type_list_nullable
|
|
425
|
+
)
|
|
426
|
+
# If the sub-parse didn't pick up an enum (e.g. for simple types), take it from prop_schema_node
|
|
427
|
+
if not final_prop_ir.enum and "enum" in prop_schema_node:
|
|
428
|
+
final_prop_ir.enum = prop_schema_node["enum"]
|
|
429
|
+
|
|
430
|
+
parsed_props[prop_name] = final_prop_ir
|
|
341
431
|
return parsed_props
|
|
342
432
|
|
|
343
433
|
|
|
@@ -400,11 +490,15 @@ def _parse_schema(
|
|
|
400
490
|
|
|
401
491
|
# If we reach here, detection_result.action == CycleAction.CONTINUE_PARSING
|
|
402
492
|
|
|
493
|
+
# Sanitize schema name early for consistent use throughout parsing
|
|
494
|
+
# This ensures property enums get named correctly with the sanitized parent name
|
|
495
|
+
sanitized_schema_name = NameSanitizer.sanitize_class_name(schema_name) if schema_name else None
|
|
496
|
+
|
|
403
497
|
try: # Ensure exit_schema is called
|
|
404
498
|
if schema_node is None:
|
|
405
499
|
# Create empty schema for null schema nodes
|
|
406
500
|
# Do NOT set generation_name - null schemas should resolve to Any inline, not generate separate files
|
|
407
|
-
return IRSchema(name=
|
|
501
|
+
return IRSchema(name=sanitized_schema_name)
|
|
408
502
|
|
|
409
503
|
if not isinstance(schema_node, Mapping):
|
|
410
504
|
raise TypeError(
|
|
@@ -473,14 +567,20 @@ def _parse_schema(
|
|
|
473
567
|
elif is_nullable_from_type_field:
|
|
474
568
|
extracted_type = "null"
|
|
475
569
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
570
|
+
(
|
|
571
|
+
any_of_irs,
|
|
572
|
+
one_of_irs,
|
|
573
|
+
all_of_components_irs,
|
|
574
|
+
props_from_comp,
|
|
575
|
+
req_from_comp,
|
|
576
|
+
nullable_from_comp,
|
|
577
|
+
discriminator_ir,
|
|
578
|
+
) = _parse_composition_keywords(
|
|
579
|
+
schema_node,
|
|
580
|
+
schema_name,
|
|
581
|
+
context,
|
|
582
|
+
ENV_MAX_DEPTH,
|
|
583
|
+
lambda n, sn, c, md: _parse_schema(n, sn, c, md, allow_self_reference),
|
|
484
584
|
)
|
|
485
585
|
|
|
486
586
|
# Check for direct nullable field (OpenAPI 3.0 Swagger extension)
|
|
@@ -526,7 +626,7 @@ def _parse_schema(
|
|
|
526
626
|
if "properties" in schema_node:
|
|
527
627
|
final_properties_for_ir = _parse_properties(
|
|
528
628
|
schema_node["properties"],
|
|
529
|
-
|
|
629
|
+
sanitized_schema_name, # Use sanitized name for consistent enum naming
|
|
530
630
|
props_from_comp, # these are from allOf merge
|
|
531
631
|
context,
|
|
532
632
|
max_depth_override,
|
|
@@ -641,7 +741,8 @@ def _parse_schema(
|
|
|
641
741
|
)
|
|
642
742
|
items_ir = IRSchema(type="object")
|
|
643
743
|
|
|
644
|
-
|
|
744
|
+
# Use the already-sanitized schema name
|
|
745
|
+
schema_ir_name_attr = sanitized_schema_name
|
|
645
746
|
|
|
646
747
|
# Parse additionalProperties field
|
|
647
748
|
additional_properties_value: bool | IRSchema | None = None
|
|
@@ -666,6 +767,7 @@ def _parse_schema(
|
|
|
666
767
|
any_of=any_of_irs,
|
|
667
768
|
one_of=one_of_irs,
|
|
668
769
|
all_of=all_of_components_irs,
|
|
770
|
+
discriminator=discriminator_ir,
|
|
669
771
|
required=sorted(list(final_required_fields_set)),
|
|
670
772
|
description=schema_node.get("description"),
|
|
671
773
|
format=schema_node.get("format") if isinstance(schema_node.get("format"), str) else None,
|
|
@@ -762,7 +864,27 @@ def _parse_schema(
|
|
|
762
864
|
and not is_synthetic_primitive
|
|
763
865
|
)
|
|
764
866
|
if should_register and schema_name:
|
|
765
|
-
|
|
867
|
+
# CRITICAL: Register using the schema's sanitized name (schema_ir.name) to prevent duplicate keys.
|
|
868
|
+
# The schema object's name attribute is already sanitized, so we use that as the key.
|
|
869
|
+
# This prevents the post-processor from registering the same schema again under a different key.
|
|
870
|
+
# However, if multiple raw schemas sanitize to the same name (e.g., "Foo-Bar" and "FooBar" both -> "FooBar"),
|
|
871
|
+
# we need to detect collisions and use the original raw schema name as a fallback.
|
|
872
|
+
registration_key = schema_ir.name if schema_ir.name else schema_name
|
|
873
|
+
|
|
874
|
+
# Check if this is a collision between different raw schemas
|
|
875
|
+
if registration_key in context.parsed_schemas:
|
|
876
|
+
existing_schema = context.parsed_schemas[registration_key]
|
|
877
|
+
# If it's the same schema object (e.g., from cycle detection returning existing), don't re-register
|
|
878
|
+
if existing_schema is not schema_ir:
|
|
879
|
+
# Collision detected - use the original raw schema name as key to keep both schemas
|
|
880
|
+
# This preserves backward compatibility with the collision handling in ModelsEmitter
|
|
881
|
+
registration_key = schema_name
|
|
882
|
+
logger.debug(
|
|
883
|
+
f"Schema name collision detected: {schema_ir.name!r} already registered. "
|
|
884
|
+
f"Using original raw name {schema_name!r} as key."
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
context.parsed_schemas[registration_key] = schema_ir
|
|
766
888
|
|
|
767
889
|
# Set generation_name and final_module_stem for schemas that will be generated as separate files
|
|
768
890
|
# Skip for synthetic primitives (inline types) - they should remain without these attributes
|
pyopenapi_gen/core/utils.py
CHANGED
|
@@ -125,7 +125,8 @@ class NameSanitizer:
|
|
|
125
125
|
|
|
126
126
|
@staticmethod
|
|
127
127
|
def sanitize_module_name(name: str) -> str:
|
|
128
|
-
"""Convert a raw name into a valid Python module name in snake_case,
|
|
128
|
+
"""Convert a raw name into a valid Python module name in snake_case,
|
|
129
|
+
splitting camel case and PascalCase."""
|
|
129
130
|
# # <<< Add Check for problematic input >>>
|
|
130
131
|
# if '[' in name or ']' in name or ',' in name:
|
|
131
132
|
# logger.error(f"sanitize_module_name received potentially invalid input: '{name}'")
|
|
@@ -195,7 +196,8 @@ class NameSanitizer:
|
|
|
195
196
|
|
|
196
197
|
@staticmethod
|
|
197
198
|
def sanitize_method_name(name: str) -> str:
|
|
198
|
-
"""Convert a raw name into a valid Python method name in snake_case,
|
|
199
|
+
"""Convert a raw name into a valid Python method name in snake_case,
|
|
200
|
+
splitting camelCase and PascalCase."""
|
|
199
201
|
# Remove curly braces
|
|
200
202
|
name = re.sub(r"[{}]", "", name)
|
|
201
203
|
# Split camelCase and PascalCase to snake_case
|
|
@@ -261,7 +263,8 @@ class KwargsBuilder:
|
|
|
261
263
|
|
|
262
264
|
|
|
263
265
|
class Formatter:
|
|
264
|
-
"""Helper to format code using Black, falling back to unformatted content if
|
|
266
|
+
"""Helper to format code using Black, falling back to unformatted content if
|
|
267
|
+
Black is unavailable or errors."""
|
|
265
268
|
|
|
266
269
|
def __init__(self) -> None:
|
|
267
270
|
from typing import Any, Callable
|
|
@@ -329,7 +332,8 @@ class DataclassSerializer:
|
|
|
329
332
|
The serialized object with dataclasses converted to dictionaries.
|
|
330
333
|
|
|
331
334
|
Handles:
|
|
332
|
-
- Dataclass instances with Meta.key_transform_with_dump: Applies field
|
|
335
|
+
- Dataclass instances with Meta.key_transform_with_dump: Applies field
|
|
336
|
+
name mapping (snake_case → camelCase)
|
|
333
337
|
- Legacy BaseSchema instances: Falls back to to_dict() if present (backward compatibility)
|
|
334
338
|
- Regular dataclass instances: Converted to dictionaries using field names
|
|
335
339
|
- Lists: Recursively serialize each item
|
|
@@ -389,7 +393,28 @@ class DataclassSerializer:
|
|
|
389
393
|
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
390
394
|
visited.add(obj_id)
|
|
391
395
|
try:
|
|
392
|
-
#
|
|
396
|
+
# Check if there's a custom cattrs unstructure hook for this type
|
|
397
|
+
try:
|
|
398
|
+
from pyopenapi_gen.core.cattrs_converter import converter
|
|
399
|
+
|
|
400
|
+
obj_type = type(obj)
|
|
401
|
+
# Get the unstructure hook for this type
|
|
402
|
+
hook = converter.get_unstructure_hook(obj_type)
|
|
403
|
+
|
|
404
|
+
# Check if it's a custom hook (not auto-generated by cattrs)
|
|
405
|
+
# Auto-generated hooks have names like "unstructure_ClassName"
|
|
406
|
+
expected_auto_gen_name = f"unstructure_{obj_type.__name__}"
|
|
407
|
+
is_custom_hook = hook.__name__ != expected_auto_gen_name
|
|
408
|
+
if is_custom_hook:
|
|
409
|
+
# Custom unstructure hook exists - use it
|
|
410
|
+
unstructured = converter.unstructure(obj)
|
|
411
|
+
# Recursively serialize the result
|
|
412
|
+
return DataclassSerializer._serialize_with_tracking(unstructured, visited)
|
|
413
|
+
except Exception: # nosec B110
|
|
414
|
+
# cattrs_converter not available or check failed - fall through to manual iteration
|
|
415
|
+
pass
|
|
416
|
+
|
|
417
|
+
# Manual field iteration (for dataclasses without custom hooks or when cattrs fails)
|
|
393
418
|
data: dict[str, Any] = {}
|
|
394
419
|
for field in dataclasses.fields(obj):
|
|
395
420
|
value = getattr(obj, field.name)
|
|
@@ -31,12 +31,24 @@ class PythonConstructRenderer:
|
|
|
31
31
|
- Generic classes (with bases, docstrings, and body)
|
|
32
32
|
"""
|
|
33
33
|
|
|
34
|
+
@staticmethod
|
|
35
|
+
def _to_module_name(class_name: str) -> str:
|
|
36
|
+
"""Convert PascalCase class name to snake_case module name."""
|
|
37
|
+
import re
|
|
38
|
+
|
|
39
|
+
# Insert underscore before uppercase letters (except first)
|
|
40
|
+
s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", class_name)
|
|
41
|
+
# Insert underscore before uppercase letters followed by lowercase
|
|
42
|
+
s2 = re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1)
|
|
43
|
+
return s2.lower()
|
|
44
|
+
|
|
34
45
|
def render_alias(
|
|
35
46
|
self,
|
|
36
47
|
alias_name: str,
|
|
37
48
|
target_type: str,
|
|
38
49
|
description: str | None,
|
|
39
50
|
context: RenderContext,
|
|
51
|
+
discriminator: "IRDiscriminator | None" = None, # type: ignore[name-defined]
|
|
40
52
|
) -> str:
|
|
41
53
|
"""
|
|
42
54
|
Render a type alias assignment as Python code.
|
|
@@ -46,6 +58,7 @@ class PythonConstructRenderer:
|
|
|
46
58
|
target_type: The target type expression
|
|
47
59
|
description: Optional description for the docstring
|
|
48
60
|
context: The rendering context for import registration
|
|
61
|
+
discriminator: Optional discriminator metadata for Union types
|
|
49
62
|
|
|
50
63
|
Returns:
|
|
51
64
|
Formatted Python code for the type alias
|
|
@@ -64,10 +77,70 @@ class PythonConstructRenderer:
|
|
|
64
77
|
context.add_typing_imports_for_type(target_type)
|
|
65
78
|
|
|
66
79
|
# Add __all__ export
|
|
67
|
-
|
|
80
|
+
exports = [alias_name]
|
|
81
|
+
if discriminator:
|
|
82
|
+
# Also export the discriminator metadata class
|
|
83
|
+
exports.append(f"{alias_name}Discriminator")
|
|
84
|
+
writer.write_line(f"__all__ = {exports!r}")
|
|
68
85
|
writer.write_line("") # Add a blank line for separation
|
|
69
86
|
|
|
70
|
-
|
|
87
|
+
# If there's a discriminator, generate the metadata class and use Annotated
|
|
88
|
+
if discriminator and target_type.startswith("Union["):
|
|
89
|
+
# Extract the union variants
|
|
90
|
+
context.add_import("typing", "Annotated")
|
|
91
|
+
context.add_import("dataclasses", "dataclass")
|
|
92
|
+
context.add_import("dataclasses", "field")
|
|
93
|
+
|
|
94
|
+
# Generate discriminator metadata class (frozen for hashability in Annotated)
|
|
95
|
+
writer.write_line("@dataclass(frozen=True)")
|
|
96
|
+
writer.write_line(f"class {alias_name}Discriminator:")
|
|
97
|
+
writer.write_line(f' """Discriminator metadata for {alias_name} union."""')
|
|
98
|
+
writer.write_line("")
|
|
99
|
+
writer.write_line(f' property_name: str = "{discriminator.property_name}"')
|
|
100
|
+
writer.write_line(f' """The discriminator property name"""')
|
|
101
|
+
writer.write_line("")
|
|
102
|
+
|
|
103
|
+
# Store mapping as immutable tuple for frozen dataclass, convert to dict at runtime
|
|
104
|
+
if discriminator.mapping:
|
|
105
|
+
# Store discriminator mapping as tuple of tuples (immutable for frozen dataclass)
|
|
106
|
+
writer.write_line(" # Mapping stored as tuple for frozen dataclass compatibility")
|
|
107
|
+
writer.write_line(" _mapping_data: tuple[tuple[str, str], ...] = (")
|
|
108
|
+
for disc_value, schema_ref in discriminator.mapping.items():
|
|
109
|
+
schema_name = schema_ref.split("/")[-1]
|
|
110
|
+
writer.write_line(f' ("{disc_value}", "{schema_name}"),')
|
|
111
|
+
writer.write_line(" )")
|
|
112
|
+
writer.write_line("")
|
|
113
|
+
writer.write_line(" def get_mapping(self) -> dict[str, type]:")
|
|
114
|
+
writer.write_line(' """Get discriminator mapping with actual type references."""')
|
|
115
|
+
# Import types locally
|
|
116
|
+
for disc_value, schema_ref in discriminator.mapping.items():
|
|
117
|
+
schema_name = schema_ref.split("/")[-1]
|
|
118
|
+
module_name = self._to_module_name(schema_name)
|
|
119
|
+
writer.write_line(f" from .{module_name} import {schema_name}")
|
|
120
|
+
writer.write_line(" return {")
|
|
121
|
+
for disc_value, schema_ref in discriminator.mapping.items():
|
|
122
|
+
schema_name = schema_ref.split("/")[-1]
|
|
123
|
+
writer.write_line(f' "{disc_value}": {schema_name},')
|
|
124
|
+
writer.write_line(" }")
|
|
125
|
+
else:
|
|
126
|
+
writer.write_line(" _mapping_data: tuple[tuple[str, str], ...] | None = None")
|
|
127
|
+
writer.write_line("")
|
|
128
|
+
writer.write_line(" def get_mapping(self) -> dict[str, type] | None:")
|
|
129
|
+
writer.write_line(' """Get discriminator mapping."""')
|
|
130
|
+
writer.write_line(" return None")
|
|
131
|
+
|
|
132
|
+
writer.write_line("")
|
|
133
|
+
writer.write_line("")
|
|
134
|
+
|
|
135
|
+
# Use Annotated to attach discriminator metadata
|
|
136
|
+
writer.write_line(f"{alias_name}: TypeAlias = Annotated[")
|
|
137
|
+
writer.write_line(f" {target_type},")
|
|
138
|
+
writer.write_line(f" {alias_name}Discriminator()")
|
|
139
|
+
writer.write_line("]")
|
|
140
|
+
else:
|
|
141
|
+
# No discriminator, use plain type alias
|
|
142
|
+
writer.write_line(f"{alias_name}: TypeAlias = {target_type}")
|
|
143
|
+
|
|
71
144
|
if description:
|
|
72
145
|
# Sanitize description for use within a triple-double-quoted string for the actual docstring
|
|
73
146
|
safe_desc_content = description.replace("\\", "\\\\") # Escape backslashes first
|
|
@@ -278,15 +278,52 @@ class ModelsEmitter:
|
|
|
278
278
|
# Update the main reference to use filtered schemas
|
|
279
279
|
all_schemas_for_generation = filtered_schemas_for_generation
|
|
280
280
|
|
|
281
|
+
# Collect all nested schemas from oneOf/anyOf/allOf that need naming
|
|
282
|
+
def collect_nested_schemas(schema: IRSchema, collected: dict[int, IRSchema], visited_ids: Set[int]) -> None:
|
|
283
|
+
"""Recursively collect all nested schemas that need generation_name."""
|
|
284
|
+
schema_id = id(schema)
|
|
285
|
+
if schema_id in visited_ids:
|
|
286
|
+
return # Avoid infinite loops
|
|
287
|
+
|
|
288
|
+
visited_ids.add(schema_id)
|
|
289
|
+
if schema.name: # Only collect schemas with names
|
|
290
|
+
collected[schema_id] = schema
|
|
291
|
+
|
|
292
|
+
# Process oneOf variants
|
|
293
|
+
if schema.one_of:
|
|
294
|
+
for variant in schema.one_of:
|
|
295
|
+
if variant.name and id(variant) not in visited_ids:
|
|
296
|
+
collect_nested_schemas(variant, collected, visited_ids)
|
|
297
|
+
|
|
298
|
+
# Process anyOf variants
|
|
299
|
+
if schema.any_of:
|
|
300
|
+
for variant in schema.any_of:
|
|
301
|
+
if variant.name and id(variant) not in visited_ids:
|
|
302
|
+
collect_nested_schemas(variant, collected, visited_ids)
|
|
303
|
+
|
|
304
|
+
# Process allOf components (though they typically merge, not union)
|
|
305
|
+
if schema.all_of:
|
|
306
|
+
for component in schema.all_of:
|
|
307
|
+
if component.name and id(component) not in visited_ids:
|
|
308
|
+
collect_nested_schemas(component, collected, visited_ids)
|
|
309
|
+
|
|
310
|
+
# Collect all schemas including nested ones
|
|
311
|
+
# Deduplicate schemas by ID first - same schema object may appear under multiple keys
|
|
312
|
+
unique_schemas_by_id = {id(s): s for s in all_schemas_for_generation.values()}
|
|
313
|
+
all_schemas_including_nested: dict[int, IRSchema] = {}
|
|
314
|
+
visited_schema_ids: Set[int] = set()
|
|
315
|
+
for schema in unique_schemas_by_id.values():
|
|
316
|
+
collect_nested_schemas(schema, all_schemas_including_nested, visited_schema_ids)
|
|
317
|
+
|
|
281
318
|
schemas_to_name_decollision = sorted(
|
|
282
|
-
|
|
283
|
-
key=lambda s: s.name
|
|
319
|
+
list(all_schemas_including_nested.values()),
|
|
320
|
+
key=lambda s: s.name if s.name else "",
|
|
284
321
|
)
|
|
285
322
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
323
|
+
logger.debug(
|
|
324
|
+
f"ModelsEmitter: Schemas to actually de-collide (post-filter by s.name): "
|
|
325
|
+
f"{[(s.name, id(s)) for s in schemas_to_name_decollision]}"
|
|
326
|
+
)
|
|
290
327
|
|
|
291
328
|
for schema_for_naming in schemas_to_name_decollision: # Use the comprehensive list
|
|
292
329
|
original_schema_name = schema_for_naming.name
|
pyopenapi_gen/ir.py
CHANGED
|
@@ -14,6 +14,27 @@ from .http_types import HTTPMethod
|
|
|
14
14
|
# pass
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
@dataclass
|
|
18
|
+
class IRDiscriminator:
|
|
19
|
+
"""
|
|
20
|
+
Discriminator metadata for oneOf/anyOf unions.
|
|
21
|
+
|
|
22
|
+
OpenAPI discriminator enables efficient runtime type resolution by specifying
|
|
23
|
+
which property identifies the variant and the mapping from property values to schema types.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
property_name: str
|
|
27
|
+
"""The name of the property used to discriminate between variants (e.g., 'type')"""
|
|
28
|
+
|
|
29
|
+
mapping: dict[str, str] | None = None
|
|
30
|
+
"""Optional mapping from discriminator values to schema references.
|
|
31
|
+
|
|
32
|
+
Example: {'agent': '#/components/schemas/WorkflowAgentNode', 'end': '#/components/schemas/WorkflowEndNode'}
|
|
33
|
+
|
|
34
|
+
If None, the discriminator property value directly identifies the schema name.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
17
38
|
@dataclass
|
|
18
39
|
class IRSchema:
|
|
19
40
|
name: str | None = None
|
|
@@ -31,6 +52,7 @@ class IRSchema:
|
|
|
31
52
|
any_of: List[IRSchema] | None = None
|
|
32
53
|
one_of: List[IRSchema] | None = None
|
|
33
54
|
all_of: List[IRSchema] | None = None # Store the list of IRSchema objects from allOf
|
|
55
|
+
discriminator: IRDiscriminator | None = None # Discriminator for oneOf/anyOf unions
|
|
34
56
|
title: str | None = None # Added title
|
|
35
57
|
is_data_wrapper: bool = False # True if schema is a simple {{ "data": OtherSchema }} wrapper
|
|
36
58
|
|
|
@@ -94,6 +116,10 @@ class IRSchema:
|
|
|
94
116
|
if isinstance(self.additional_properties, dict):
|
|
95
117
|
self.additional_properties = IRSchema(**self.additional_properties)
|
|
96
118
|
|
|
119
|
+
# Ensure discriminator is an IRDiscriminator instance
|
|
120
|
+
if isinstance(self.discriminator, dict):
|
|
121
|
+
self.discriminator = IRDiscriminator(**self.discriminator)
|
|
122
|
+
|
|
97
123
|
for comp_list_attr in ["any_of", "one_of", "all_of"]:
|
|
98
124
|
comp_list = getattr(self, comp_list_attr)
|
|
99
125
|
if isinstance(comp_list, list):
|
|
@@ -71,11 +71,15 @@ class AliasGenerator:
|
|
|
71
71
|
|
|
72
72
|
# logger.debug(f"AliasGenerator: Rendering alias '{alias_name}' for target type '{target_type}'.")
|
|
73
73
|
|
|
74
|
+
# Pass discriminator metadata if this is a union type
|
|
75
|
+
discriminator = schema.discriminator if hasattr(schema, "discriminator") else None
|
|
76
|
+
|
|
74
77
|
rendered_code = self.renderer.render_alias(
|
|
75
78
|
alias_name=alias_name,
|
|
76
79
|
target_type=target_type,
|
|
77
80
|
description=schema.description,
|
|
78
81
|
context=context,
|
|
82
|
+
discriminator=discriminator,
|
|
79
83
|
)
|
|
80
84
|
|
|
81
85
|
# Post-condition
|
|
@@ -77,7 +77,16 @@ class ModelVisitor(Visitor[IRSchema, str]):
|
|
|
77
77
|
# --- Model Type Detection Logic ---
|
|
78
78
|
is_enum = bool(schema.name and schema.enum and schema.type in ("string", "integer"))
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
# Check for discriminated union types (oneOf, anyOf) which should be Union type aliases
|
|
81
|
+
# Note: allOf is for schema composition/merging, not discriminated unions
|
|
82
|
+
is_union_type = bool(schema.name and (schema.one_of or schema.any_of))
|
|
83
|
+
|
|
84
|
+
is_type_alias = bool(
|
|
85
|
+
schema.name
|
|
86
|
+
and not schema.properties
|
|
87
|
+
and not is_enum
|
|
88
|
+
and (schema.type != "object" or is_union_type) # Allow object types if they're union compositions
|
|
89
|
+
)
|
|
81
90
|
|
|
82
91
|
if schema.type == "array" and schema.items and schema.items.type == "object" and schema.items.name is None:
|
|
83
92
|
if is_type_alias:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyopenapi-gen
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.1
|
|
4
4
|
Summary: Modern, async-first Python client generator for OpenAPI specifications with advanced cycle detection and unified type resolution
|
|
5
5
|
Project-URL: Homepage, https://github.com/your-org/pyopenapi-gen
|
|
6
6
|
Project-URL: Documentation, https://github.com/your-org/pyopenapi-gen/blob/main/README.md
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
pyopenapi_gen/__init__.py,sha256=
|
|
1
|
+
pyopenapi_gen/__init__.py,sha256=Df2VrSmifyuu_8NbGBjzN9JRvXFoLv9FlqKk4x-_qnE,7632
|
|
2
2
|
pyopenapi_gen/__main__.py,sha256=4-SCaCNhBd7rtyRK58uoDbdl93J0KhUeajP_b0CPpLE,110
|
|
3
3
|
pyopenapi_gen/cli.py,sha256=TA3bpe8kmUuyA4lZqAHHx5YkWQvrNq81WuEDAtPO6JQ,2147
|
|
4
4
|
pyopenapi_gen/http_types.py,sha256=EMMYZBt8PNVZKPFu77TQija-JI-nOKyXvpiQP9-VSWE,467
|
|
5
|
-
pyopenapi_gen/ir.py,sha256=
|
|
5
|
+
pyopenapi_gen/ir.py,sha256=VDA7ZFCm5-EU5r_k4H0zkKKDNp-cljg44xPhywN0wBw,8229
|
|
6
6
|
pyopenapi_gen/py.typed,sha256=Nqnn8clbgv-5l0PgxcTOldg8mkMKrFn4TvPL-rYUUGg,1
|
|
7
7
|
pyopenapi_gen/context/CLAUDE.md,sha256=eUPvSY2ADQK21i52bWfzyBcDPVvvepErMiQrq6ndwlU,9004
|
|
8
8
|
pyopenapi_gen/context/file_manager.py,sha256=vpbRByO5SH6COdjb6C-pXkdSIRu7QFqrXxi69VLKBnM,1691
|
|
@@ -10,7 +10,7 @@ pyopenapi_gen/context/import_collector.py,sha256=5WzQ5fXxARHJFZmYZ_GjPG5YjjzRLZT
|
|
|
10
10
|
pyopenapi_gen/context/render_context.py,sha256=fAq0u3rDMZ0_OEHegrRUd98ySxs390XJbdypqQX8bwI,34894
|
|
11
11
|
pyopenapi_gen/core/CLAUDE.md,sha256=bz48K-PSrhxCq5ScmiLiU9kfpVVzSWRKOA9RdKk_pbg,6482
|
|
12
12
|
pyopenapi_gen/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
-
pyopenapi_gen/core/cattrs_converter.py,sha256=
|
|
13
|
+
pyopenapi_gen/core/cattrs_converter.py,sha256=k-2rcUUKc6AnSbnTNhqvEQYMIeZRl89czlr52bPceBo,31460
|
|
14
14
|
pyopenapi_gen/core/exceptions.py,sha256=HYFiYdmzsZUl46vB8M3B6Vpp6m8iqjUcKDWdL4yEKHo,498
|
|
15
15
|
pyopenapi_gen/core/http_status_codes.py,sha256=nn8QdXmkv5BQA9C-HTn9wmAVWyEyB9bAHpHve6EZX4M,6249
|
|
16
16
|
pyopenapi_gen/core/http_transport.py,sha256=JWTka-p8VUKS9vL7NciZUaiZqpjchBYxzx_MrdCyVYo,9836
|
|
@@ -19,7 +19,7 @@ pyopenapi_gen/core/postprocess_manager.py,sha256=Ia4H47lInhVxkHnDB46t4U-6BLpLXzh
|
|
|
19
19
|
pyopenapi_gen/core/spec_fetcher.py,sha256=kAEj5XgYvV-IA0UHKn8_bglWxtscQwvJXZncC4hzmTM,4601
|
|
20
20
|
pyopenapi_gen/core/streaming_helpers.py,sha256=5fkzH9xsgzZWOTKFrZpmje07S7n7CcOpjteb5dig7ds,2664
|
|
21
21
|
pyopenapi_gen/core/telemetry.py,sha256=LNMrlrUNVcp591w9cX4uvzlFrPB6ZZoGRIuCOHlDCqA,2296
|
|
22
|
-
pyopenapi_gen/core/utils.py,sha256=
|
|
22
|
+
pyopenapi_gen/core/utils.py,sha256=oeZ3GIZGuODEQS5EIQ8uOZs88fDVOV-4mjo5HXxqCp0,17638
|
|
23
23
|
pyopenapi_gen/core/warning_collector.py,sha256=DYl9D7eZYs04mDU84KeonS-5-d0aM7hNqraXTex31ss,2799
|
|
24
24
|
pyopenapi_gen/core/auth/base.py,sha256=E2KUerA_mYv9D7xulUm-lenIxqZHqanjA4oRKpof2ZE,792
|
|
25
25
|
pyopenapi_gen/core/auth/plugins.py,sha256=u4GZTykoxGwGaWAQyFeTdPKi-pSK7Dp0DCeg5RsrSw4,3446
|
|
@@ -34,12 +34,12 @@ pyopenapi_gen/core/loader/parameters/parser.py,sha256=TROPMq6iyL9Somh7K1ADjqyDGX
|
|
|
34
34
|
pyopenapi_gen/core/loader/responses/__init__.py,sha256=6APWoH3IdNkgVmI0KsgZoZ6knDaG-S-pnUCa6gkzT8E,216
|
|
35
35
|
pyopenapi_gen/core/loader/responses/parser.py,sha256=6dZ86WvyBxPq68UQjF_F8F2FtmGtMm5ZB8UjKXZTu9Y,4063
|
|
36
36
|
pyopenapi_gen/core/loader/schemas/__init__.py,sha256=rlhujYfw_IzWgzhVhYMJ3eIhE6C5Vi1Ylba-BHEVqOg,296
|
|
37
|
-
pyopenapi_gen/core/loader/schemas/extractor.py,sha256=
|
|
37
|
+
pyopenapi_gen/core/loader/schemas/extractor.py,sha256=bx7EG0f4fDOT8H9E0tiZAjs-jtfJmY9cwHmdHgZrAfY,15249
|
|
38
38
|
pyopenapi_gen/core/parsing/__init__.py,sha256=RJsIR6cHaNoI4tBcpMlAa0JsY64vsHb9sPxPg6rd8FQ,486
|
|
39
39
|
pyopenapi_gen/core/parsing/context.py,sha256=oahabtftjYiyeKNHwc3fWjSGrIoCeNnKN-KhWofiOmk,7968
|
|
40
40
|
pyopenapi_gen/core/parsing/cycle_helpers.py,sha256=HyVQBWU78PbypyppHq34yxr6BG4W5Bt4ev5kkGUyvkg,5969
|
|
41
41
|
pyopenapi_gen/core/parsing/schema_finalizer.py,sha256=BMx7nlg5tECbKDfv6XEwumNNPkzkL5qRPdYrRI8qrxo,6856
|
|
42
|
-
pyopenapi_gen/core/parsing/schema_parser.py,sha256=
|
|
42
|
+
pyopenapi_gen/core/parsing/schema_parser.py,sha256=3XlzkDOdv-DVJhHy_34bJt4SLkrxb7LaSbwipQny08M,48838
|
|
43
43
|
pyopenapi_gen/core/parsing/unified_cycle_detection.py,sha256=MXghh1Ip6P1etfjuzPiRiDHBdFdQUb9nFUQqhexcvfc,10836
|
|
44
44
|
pyopenapi_gen/core/parsing/common/__init__.py,sha256=U3sHMO-l6S3Cm04CVOYmBCpqLEZvCylUI7yQfcTwxYU,27
|
|
45
45
|
pyopenapi_gen/core/parsing/common/type_parser.py,sha256=h9pg0KWFezjNYvo-A1Dx_ADA7dp4THeMb_JKmZRJk1Q,2542
|
|
@@ -65,7 +65,7 @@ pyopenapi_gen/core/parsing/transformers/inline_object_promoter.py,sha256=d9DZ1CV
|
|
|
65
65
|
pyopenapi_gen/core/writers/code_writer.py,sha256=5rWMPRdj5dWu3NRER6do4NEJEIa9qL68Hc7zhJ8jSdg,4763
|
|
66
66
|
pyopenapi_gen/core/writers/documentation_writer.py,sha256=ZnXJsRc9y9bUMsQR7g0qoBJZbBo2MCxHH_5KgxrPtyA,8645
|
|
67
67
|
pyopenapi_gen/core/writers/line_writer.py,sha256=-K2FaMtQa6hdzGZLcjQrT2ItEmfE-kquTCrn6R3I_QA,7743
|
|
68
|
-
pyopenapi_gen/core/writers/python_construct_renderer.py,sha256=
|
|
68
|
+
pyopenapi_gen/core/writers/python_construct_renderer.py,sha256=ILR7wdCgjk8VWZn_WYCzMolSJhTrwyqByfvOjq3cZ48,16406
|
|
69
69
|
pyopenapi_gen/core_package_template/README.md,sha256=8YP-MS0KxphRbCGBf7kV3dYIFLU9piOJ3IMm3K_0hcI,1488
|
|
70
70
|
pyopenapi_gen/emit/models_emitter.py,sha256=MBRq71UjtlZHrpf9QhDN0wXk_X-oGeGd6TAq3RKG7ko,7180
|
|
71
71
|
pyopenapi_gen/emitters/CLAUDE.md,sha256=iZYEZq1a1h033rxuh97cMpsKUElv72ysvTm3-QQUvrs,9323
|
|
@@ -75,7 +75,7 @@ pyopenapi_gen/emitters/docs_emitter.py,sha256=aouKqhRdtVvYfGVsye_uqM80nONRy0SqN0
|
|
|
75
75
|
pyopenapi_gen/emitters/endpoints_emitter.py,sha256=m4Kts3-hxflNyCXcKtakyeHLELKLqVjcKLl4_F_1rhQ,10716
|
|
76
76
|
pyopenapi_gen/emitters/exceptions_emitter.py,sha256=PfbDQX7dfgg2htvxEh40t7FR7b3BrK8jeRd5INu_kjk,7547
|
|
77
77
|
pyopenapi_gen/emitters/mocks_emitter.py,sha256=D8fjD8KbgG-yFiqCEEAe2rKE2B439pW6bRLvoqQ6SdI,7431
|
|
78
|
-
pyopenapi_gen/emitters/models_emitter.py,sha256=
|
|
78
|
+
pyopenapi_gen/emitters/models_emitter.py,sha256=OU8UNg18QdYop1lUZ2aAi4Co-hm_71ZRT6PKnG4oEYw,24107
|
|
79
79
|
pyopenapi_gen/generator/CLAUDE.md,sha256=BS9KkmLvk2WD-Io-_apoWjGNeMU4q4LKy4UOxYF9WxM,10870
|
|
80
80
|
pyopenapi_gen/generator/client_generator.py,sha256=ZtMTNXBbsLkqsVdFPepMhB2jsmV4poNKAR7pM-Wyvpo,29229
|
|
81
81
|
pyopenapi_gen/generator/exceptions.py,sha256=6mgO_3pk1U61AVyRpv00UTPY5UYMUzY3MmfgmMv-4mM,168
|
|
@@ -126,12 +126,12 @@ pyopenapi_gen/visit/endpoint/processors/__init__.py,sha256=_6RqpOdDuDheArqDBi3yk
|
|
|
126
126
|
pyopenapi_gen/visit/endpoint/processors/import_analyzer.py,sha256=ou5pl3S6PXvHrhKeBQraRfK9MTOQE1WUGLeieAVUXRM,3364
|
|
127
127
|
pyopenapi_gen/visit/endpoint/processors/parameter_processor.py,sha256=E0VoygkhU8iCsvH0U-tA6ZZg2Nm3rfr4e17vxqQLH7c,7666
|
|
128
128
|
pyopenapi_gen/visit/model/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
129
|
-
pyopenapi_gen/visit/model/alias_generator.py,sha256=
|
|
129
|
+
pyopenapi_gen/visit/model/alias_generator.py,sha256=NPFEMWzhrSHFCOdYpT_G-9QJTuyq7bxvIfT3a-fVggg,3758
|
|
130
130
|
pyopenapi_gen/visit/model/dataclass_generator.py,sha256=NVQY3PNptVJn31zJv3pysPT8skLLoPObcQ906NvJ6gs,21869
|
|
131
131
|
pyopenapi_gen/visit/model/enum_generator.py,sha256=AXqKUFuWUUjUF_6_HqBKY8vB5GYu35Pb2C2WPFrOw1k,10061
|
|
132
|
-
pyopenapi_gen/visit/model/model_visitor.py,sha256=
|
|
133
|
-
pyopenapi_gen-
|
|
134
|
-
pyopenapi_gen-
|
|
135
|
-
pyopenapi_gen-
|
|
136
|
-
pyopenapi_gen-
|
|
137
|
-
pyopenapi_gen-
|
|
132
|
+
pyopenapi_gen/visit/model/model_visitor.py,sha256=Xrlk0Re5PN7XhaO3R3q2G4Qf0p8kkZeWlBkyCuaLS98,9857
|
|
133
|
+
pyopenapi_gen-3.0.1.dist-info/METADATA,sha256=pNnWkFqEHuNgW5PYiKRgEDKkAsEA4wamleh4wsJ8pYM,39057
|
|
134
|
+
pyopenapi_gen-3.0.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
135
|
+
pyopenapi_gen-3.0.1.dist-info/entry_points.txt,sha256=gxSlNiwom50T3OEZnlocA6qRjGdV0bn6hN_Xr-Ub5wA,56
|
|
136
|
+
pyopenapi_gen-3.0.1.dist-info/licenses/LICENSE,sha256=UFAyTWKa4w10-QerlJaHJeep7G2gcwpf-JmvI2dS2Gc,1088
|
|
137
|
+
pyopenapi_gen-3.0.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|