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.
- pyopenapi_gen/__init__.py +224 -0
- pyopenapi_gen/__main__.py +6 -0
- pyopenapi_gen/cli.py +62 -0
- pyopenapi_gen/context/CLAUDE.md +284 -0
- pyopenapi_gen/context/file_manager.py +52 -0
- pyopenapi_gen/context/import_collector.py +382 -0
- pyopenapi_gen/context/render_context.py +726 -0
- pyopenapi_gen/core/CLAUDE.md +224 -0
- pyopenapi_gen/core/__init__.py +0 -0
- pyopenapi_gen/core/auth/base.py +22 -0
- pyopenapi_gen/core/auth/plugins.py +89 -0
- pyopenapi_gen/core/cattrs_converter.py +810 -0
- pyopenapi_gen/core/exceptions.py +20 -0
- pyopenapi_gen/core/http_status_codes.py +218 -0
- pyopenapi_gen/core/http_transport.py +222 -0
- pyopenapi_gen/core/loader/__init__.py +12 -0
- pyopenapi_gen/core/loader/loader.py +174 -0
- pyopenapi_gen/core/loader/operations/__init__.py +12 -0
- pyopenapi_gen/core/loader/operations/parser.py +161 -0
- pyopenapi_gen/core/loader/operations/post_processor.py +62 -0
- pyopenapi_gen/core/loader/operations/request_body.py +90 -0
- pyopenapi_gen/core/loader/parameters/__init__.py +10 -0
- pyopenapi_gen/core/loader/parameters/parser.py +186 -0
- pyopenapi_gen/core/loader/responses/__init__.py +10 -0
- pyopenapi_gen/core/loader/responses/parser.py +111 -0
- pyopenapi_gen/core/loader/schemas/__init__.py +11 -0
- pyopenapi_gen/core/loader/schemas/extractor.py +275 -0
- pyopenapi_gen/core/pagination.py +64 -0
- pyopenapi_gen/core/parsing/__init__.py +13 -0
- pyopenapi_gen/core/parsing/common/__init__.py +1 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/__init__.py +9 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/__init__.py +0 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/cyclic_properties.py +66 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/direct_cycle.py +33 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/existing_schema.py +22 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/list_response.py +54 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/missing_ref.py +52 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/new_schema.py +50 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/stripped_suffix.py +51 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/resolve_schema_ref.py +86 -0
- pyopenapi_gen/core/parsing/common/type_parser.py +73 -0
- pyopenapi_gen/core/parsing/context.py +187 -0
- pyopenapi_gen/core/parsing/cycle_helpers.py +126 -0
- pyopenapi_gen/core/parsing/keywords/__init__.py +1 -0
- pyopenapi_gen/core/parsing/keywords/all_of_parser.py +81 -0
- pyopenapi_gen/core/parsing/keywords/any_of_parser.py +84 -0
- pyopenapi_gen/core/parsing/keywords/array_items_parser.py +72 -0
- pyopenapi_gen/core/parsing/keywords/one_of_parser.py +77 -0
- pyopenapi_gen/core/parsing/keywords/properties_parser.py +98 -0
- pyopenapi_gen/core/parsing/schema_finalizer.py +169 -0
- pyopenapi_gen/core/parsing/schema_parser.py +804 -0
- pyopenapi_gen/core/parsing/transformers/__init__.py +0 -0
- pyopenapi_gen/core/parsing/transformers/inline_enum_extractor.py +285 -0
- pyopenapi_gen/core/parsing/transformers/inline_object_promoter.py +120 -0
- pyopenapi_gen/core/parsing/unified_cycle_detection.py +293 -0
- pyopenapi_gen/core/postprocess_manager.py +260 -0
- pyopenapi_gen/core/spec_fetcher.py +148 -0
- pyopenapi_gen/core/streaming_helpers.py +84 -0
- pyopenapi_gen/core/telemetry.py +69 -0
- pyopenapi_gen/core/utils.py +456 -0
- pyopenapi_gen/core/warning_collector.py +83 -0
- pyopenapi_gen/core/writers/code_writer.py +135 -0
- pyopenapi_gen/core/writers/documentation_writer.py +222 -0
- pyopenapi_gen/core/writers/line_writer.py +217 -0
- pyopenapi_gen/core/writers/python_construct_renderer.py +321 -0
- pyopenapi_gen/core_package_template/README.md +21 -0
- pyopenapi_gen/emit/models_emitter.py +143 -0
- pyopenapi_gen/emitters/CLAUDE.md +286 -0
- pyopenapi_gen/emitters/client_emitter.py +51 -0
- pyopenapi_gen/emitters/core_emitter.py +181 -0
- pyopenapi_gen/emitters/docs_emitter.py +44 -0
- pyopenapi_gen/emitters/endpoints_emitter.py +247 -0
- pyopenapi_gen/emitters/exceptions_emitter.py +187 -0
- pyopenapi_gen/emitters/mocks_emitter.py +185 -0
- pyopenapi_gen/emitters/models_emitter.py +426 -0
- pyopenapi_gen/generator/CLAUDE.md +352 -0
- pyopenapi_gen/generator/client_generator.py +567 -0
- pyopenapi_gen/generator/exceptions.py +7 -0
- pyopenapi_gen/helpers/CLAUDE.md +325 -0
- pyopenapi_gen/helpers/__init__.py +1 -0
- pyopenapi_gen/helpers/endpoint_utils.py +532 -0
- pyopenapi_gen/helpers/type_cleaner.py +334 -0
- pyopenapi_gen/helpers/type_helper.py +112 -0
- pyopenapi_gen/helpers/type_resolution/__init__.py +1 -0
- pyopenapi_gen/helpers/type_resolution/array_resolver.py +57 -0
- pyopenapi_gen/helpers/type_resolution/composition_resolver.py +79 -0
- pyopenapi_gen/helpers/type_resolution/finalizer.py +105 -0
- pyopenapi_gen/helpers/type_resolution/named_resolver.py +172 -0
- pyopenapi_gen/helpers/type_resolution/object_resolver.py +216 -0
- pyopenapi_gen/helpers/type_resolution/primitive_resolver.py +109 -0
- pyopenapi_gen/helpers/type_resolution/resolver.py +47 -0
- pyopenapi_gen/helpers/url_utils.py +14 -0
- pyopenapi_gen/http_types.py +20 -0
- pyopenapi_gen/ir.py +165 -0
- pyopenapi_gen/py.typed +1 -0
- pyopenapi_gen/types/CLAUDE.md +140 -0
- pyopenapi_gen/types/__init__.py +11 -0
- pyopenapi_gen/types/contracts/__init__.py +13 -0
- pyopenapi_gen/types/contracts/protocols.py +106 -0
- pyopenapi_gen/types/contracts/types.py +28 -0
- pyopenapi_gen/types/resolvers/__init__.py +7 -0
- pyopenapi_gen/types/resolvers/reference_resolver.py +71 -0
- pyopenapi_gen/types/resolvers/response_resolver.py +177 -0
- pyopenapi_gen/types/resolvers/schema_resolver.py +498 -0
- pyopenapi_gen/types/services/__init__.py +5 -0
- pyopenapi_gen/types/services/type_service.py +165 -0
- pyopenapi_gen/types/strategies/__init__.py +5 -0
- pyopenapi_gen/types/strategies/response_strategy.py +310 -0
- pyopenapi_gen/visit/CLAUDE.md +272 -0
- pyopenapi_gen/visit/client_visitor.py +477 -0
- pyopenapi_gen/visit/docs_visitor.py +38 -0
- pyopenapi_gen/visit/endpoint/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/endpoint_visitor.py +292 -0
- pyopenapi_gen/visit/endpoint/generators/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/generators/docstring_generator.py +123 -0
- pyopenapi_gen/visit/endpoint/generators/endpoint_method_generator.py +222 -0
- pyopenapi_gen/visit/endpoint/generators/mock_generator.py +140 -0
- pyopenapi_gen/visit/endpoint/generators/overload_generator.py +252 -0
- pyopenapi_gen/visit/endpoint/generators/request_generator.py +103 -0
- pyopenapi_gen/visit/endpoint/generators/response_handler_generator.py +705 -0
- pyopenapi_gen/visit/endpoint/generators/signature_generator.py +83 -0
- pyopenapi_gen/visit/endpoint/generators/url_args_generator.py +207 -0
- pyopenapi_gen/visit/endpoint/processors/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/processors/import_analyzer.py +78 -0
- pyopenapi_gen/visit/endpoint/processors/parameter_processor.py +171 -0
- pyopenapi_gen/visit/exception_visitor.py +90 -0
- pyopenapi_gen/visit/model/__init__.py +0 -0
- pyopenapi_gen/visit/model/alias_generator.py +93 -0
- pyopenapi_gen/visit/model/dataclass_generator.py +553 -0
- pyopenapi_gen/visit/model/enum_generator.py +212 -0
- pyopenapi_gen/visit/model/model_visitor.py +198 -0
- pyopenapi_gen/visit/visitor.py +97 -0
- pyopenapi_gen-2.7.2.dist-info/METADATA +1169 -0
- pyopenapi_gen-2.7.2.dist-info/RECORD +137 -0
- pyopenapi_gen-2.7.2.dist-info/WHEEL +4 -0
- pyopenapi_gen-2.7.2.dist-info/entry_points.txt +2 -0
- pyopenapi_gen-2.7.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generates Python code for type aliases from IRSchema objects.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from pyopenapi_gen import IRSchema
|
|
8
|
+
from pyopenapi_gen.context.render_context import RenderContext
|
|
9
|
+
from pyopenapi_gen.core.utils import NameSanitizer
|
|
10
|
+
from pyopenapi_gen.core.writers.python_construct_renderer import PythonConstructRenderer
|
|
11
|
+
from pyopenapi_gen.types.services.type_service import UnifiedTypeService
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AliasGenerator:
|
|
17
|
+
"""Generates Python code for a type alias."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
renderer: PythonConstructRenderer,
|
|
22
|
+
all_schemas: dict[str, IRSchema] | None,
|
|
23
|
+
):
|
|
24
|
+
# Pre-condition
|
|
25
|
+
if renderer is None:
|
|
26
|
+
raise ValueError("PythonConstructRenderer cannot be None")
|
|
27
|
+
self.renderer = renderer
|
|
28
|
+
self.all_schemas = all_schemas if all_schemas is not None else {}
|
|
29
|
+
self.type_service = UnifiedTypeService(self.all_schemas)
|
|
30
|
+
|
|
31
|
+
def generate(
|
|
32
|
+
self,
|
|
33
|
+
schema: IRSchema,
|
|
34
|
+
base_name: str,
|
|
35
|
+
context: RenderContext,
|
|
36
|
+
) -> str:
|
|
37
|
+
"""
|
|
38
|
+
Generates the Python code for a type alias.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
schema: The IRSchema for the alias.
|
|
42
|
+
base_name: The base name for the alias (e.g., schema.name).
|
|
43
|
+
context: The render context.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The generated Python code string for the type alias.
|
|
47
|
+
|
|
48
|
+
Contracts:
|
|
49
|
+
Pre-conditions:
|
|
50
|
+
- ``schema`` is not None and ``schema.name`` is not None.
|
|
51
|
+
- ``base_name`` is a non-empty string.
|
|
52
|
+
- ``context`` is not None.
|
|
53
|
+
- The schema should logically represent a type alias
|
|
54
|
+
(e.g., not have properties if it's not an array of anonymous objects).
|
|
55
|
+
Post-conditions:
|
|
56
|
+
- Returns a non-empty string containing valid Python code for a type alias.
|
|
57
|
+
- ``TypeAlias`` is imported in the context if not already present.
|
|
58
|
+
"""
|
|
59
|
+
# Pre-conditions
|
|
60
|
+
if schema is None:
|
|
61
|
+
raise ValueError("Schema cannot be None for alias generation.")
|
|
62
|
+
if schema.name is None:
|
|
63
|
+
raise ValueError("Schema name must be present for alias generation.")
|
|
64
|
+
if not base_name:
|
|
65
|
+
raise ValueError("Base name cannot be empty for alias generation.")
|
|
66
|
+
if context is None:
|
|
67
|
+
raise ValueError("RenderContext cannot be None.")
|
|
68
|
+
|
|
69
|
+
alias_name = NameSanitizer.sanitize_class_name(base_name)
|
|
70
|
+
target_type = self.type_service.resolve_schema_type(schema, context, required=True, resolve_underlying=True)
|
|
71
|
+
|
|
72
|
+
# logger.debug(f"AliasGenerator: Rendering alias '{alias_name}' for target type '{target_type}'.")
|
|
73
|
+
|
|
74
|
+
rendered_code = self.renderer.render_alias(
|
|
75
|
+
alias_name=alias_name,
|
|
76
|
+
target_type=target_type,
|
|
77
|
+
description=schema.description,
|
|
78
|
+
context=context,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Post-condition
|
|
82
|
+
if not rendered_code.strip():
|
|
83
|
+
raise RuntimeError("Generated alias code cannot be empty.")
|
|
84
|
+
# PythonConstructRenderer is responsible for adding TypeAlias import
|
|
85
|
+
# We can check if it was added to context if 'TypeAlias' is in the rendered code
|
|
86
|
+
if "TypeAlias" in rendered_code:
|
|
87
|
+
if not (
|
|
88
|
+
"typing" in context.import_collector.imports
|
|
89
|
+
and "TypeAlias" in context.import_collector.imports["typing"]
|
|
90
|
+
):
|
|
91
|
+
raise RuntimeError("TypeAlias import was not added to context by renderer.")
|
|
92
|
+
|
|
93
|
+
return rendered_code
|
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generates Python code for dataclasses from IRSchema objects.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import List, Tuple
|
|
8
|
+
|
|
9
|
+
from pyopenapi_gen import IRSchema
|
|
10
|
+
from pyopenapi_gen.context.render_context import RenderContext
|
|
11
|
+
from pyopenapi_gen.core.utils import NameSanitizer
|
|
12
|
+
from pyopenapi_gen.core.writers.python_construct_renderer import PythonConstructRenderer
|
|
13
|
+
from pyopenapi_gen.helpers.type_resolution.finalizer import TypeFinalizer
|
|
14
|
+
from pyopenapi_gen.types.services.type_service import UnifiedTypeService
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DataclassGenerator:
|
|
20
|
+
"""Generates Python code for a dataclass."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
renderer: PythonConstructRenderer,
|
|
25
|
+
all_schemas: dict[str, IRSchema] | None,
|
|
26
|
+
):
|
|
27
|
+
"""
|
|
28
|
+
Initialize a new DataclassGenerator.
|
|
29
|
+
|
|
30
|
+
Contracts:
|
|
31
|
+
Pre-conditions:
|
|
32
|
+
- ``renderer`` is not None.
|
|
33
|
+
"""
|
|
34
|
+
if renderer is None:
|
|
35
|
+
raise ValueError("PythonConstructRenderer cannot be None.")
|
|
36
|
+
self.renderer = renderer
|
|
37
|
+
self.all_schemas = all_schemas if all_schemas is not None else {}
|
|
38
|
+
self.type_service = UnifiedTypeService(self.all_schemas)
|
|
39
|
+
|
|
40
|
+
def _is_arbitrary_json_object(self, schema: IRSchema) -> bool:
|
|
41
|
+
"""
|
|
42
|
+
Check if schema represents an arbitrary JSON object (additionalProperties but no properties).
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
schema: The schema to check.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
True if this is an arbitrary JSON object that should use wrapper class.
|
|
49
|
+
"""
|
|
50
|
+
return (
|
|
51
|
+
schema.type == "object"
|
|
52
|
+
and not schema.properties # No defined properties
|
|
53
|
+
and (
|
|
54
|
+
schema.additional_properties is True # Explicit true
|
|
55
|
+
or isinstance(schema.additional_properties, IRSchema) # Schema for additional props
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def _generate_json_wrapper_class(
|
|
60
|
+
self,
|
|
61
|
+
class_name: str,
|
|
62
|
+
schema: IRSchema,
|
|
63
|
+
context: RenderContext,
|
|
64
|
+
) -> str:
|
|
65
|
+
"""
|
|
66
|
+
Generate a wrapper class for arbitrary JSON objects.
|
|
67
|
+
|
|
68
|
+
This wrapper class preserves all JSON data and provides dict-like access,
|
|
69
|
+
preventing data loss when deserializing API responses with arbitrary properties.
|
|
70
|
+
|
|
71
|
+
When additionalProperties references a typed schema, the wrapper will:
|
|
72
|
+
- Store values with proper type annotations
|
|
73
|
+
- Include _value_type ClassVar for runtime type resolution
|
|
74
|
+
- Generate hooks that deserialize values into their proper types
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
class_name: Name of the wrapper class.
|
|
78
|
+
schema: The schema (should have additionalProperties but no properties).
|
|
79
|
+
context: Render context for imports.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Python code for the wrapper class.
|
|
83
|
+
"""
|
|
84
|
+
# Register required imports
|
|
85
|
+
context.add_import("dataclasses", "dataclass")
|
|
86
|
+
context.add_import("dataclasses", "field")
|
|
87
|
+
context.add_import("typing", "Any")
|
|
88
|
+
|
|
89
|
+
description = schema.description or "Generic JSON value object that preserves arbitrary data."
|
|
90
|
+
|
|
91
|
+
# Determine value type from additionalProperties
|
|
92
|
+
value_type = "Any"
|
|
93
|
+
has_typed_values = False
|
|
94
|
+
|
|
95
|
+
if isinstance(schema.additional_properties, IRSchema):
|
|
96
|
+
ap_schema = schema.additional_properties
|
|
97
|
+
# Resolve the type using UnifiedTypeService
|
|
98
|
+
resolved_type = self.type_service.resolve_schema_type(ap_schema, context, required=True)
|
|
99
|
+
# Only use typed wrapper if we have a concrete type (not Any or Any | None)
|
|
100
|
+
if resolved_type and resolved_type != "Any" and "Any" not in resolved_type:
|
|
101
|
+
value_type = resolved_type
|
|
102
|
+
has_typed_values = True
|
|
103
|
+
|
|
104
|
+
if has_typed_values:
|
|
105
|
+
# Generate typed wrapper with value deserialization
|
|
106
|
+
context.add_import("typing", "ClassVar")
|
|
107
|
+
code = self._generate_typed_wrapper_class(class_name, value_type, description, context)
|
|
108
|
+
else:
|
|
109
|
+
# Generate untyped wrapper (existing behavior)
|
|
110
|
+
code = self._generate_untyped_wrapper_class(class_name, description, context)
|
|
111
|
+
|
|
112
|
+
return code
|
|
113
|
+
|
|
114
|
+
def _generate_untyped_wrapper_class(
|
|
115
|
+
self,
|
|
116
|
+
class_name: str,
|
|
117
|
+
description: str,
|
|
118
|
+
context: RenderContext,
|
|
119
|
+
) -> str:
|
|
120
|
+
"""Generate wrapper class for untyped additionalProperties (Any values)."""
|
|
121
|
+
return f'''__all__ = ["{class_name}"]
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class {class_name}:
|
|
125
|
+
"""
|
|
126
|
+
{description}
|
|
127
|
+
|
|
128
|
+
This class wraps arbitrary JSON objects with no defined schema,
|
|
129
|
+
preserving all data during serialization/deserialization.
|
|
130
|
+
|
|
131
|
+
Example:
|
|
132
|
+
from {context.core_package_name}.cattrs_converter import structure_from_dict, unstructure_to_dict
|
|
133
|
+
|
|
134
|
+
# Deserialize from API response
|
|
135
|
+
obj = structure_from_dict({{"key": "value"}}, {class_name})
|
|
136
|
+
|
|
137
|
+
# Access data
|
|
138
|
+
print(obj["key"]) # "value"
|
|
139
|
+
obj["new_key"] = "new_value"
|
|
140
|
+
|
|
141
|
+
# Serialize for API request
|
|
142
|
+
data = unstructure_to_dict(obj) # {{"key": "value", "new_key": "new_value"}}
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
_data: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
146
|
+
|
|
147
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
148
|
+
"""Get value for key, returning default if key not present."""
|
|
149
|
+
return self._data.get(key, default)
|
|
150
|
+
|
|
151
|
+
def __getitem__(self, key: str) -> Any:
|
|
152
|
+
"""Get value for key."""
|
|
153
|
+
return self._data[key]
|
|
154
|
+
|
|
155
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
156
|
+
"""Set value for key."""
|
|
157
|
+
self._data[key] = value
|
|
158
|
+
|
|
159
|
+
def __contains__(self, key: str) -> bool:
|
|
160
|
+
"""Check if key exists."""
|
|
161
|
+
return key in self._data
|
|
162
|
+
|
|
163
|
+
def __bool__(self) -> bool:
|
|
164
|
+
"""Return True if wrapper contains any data."""
|
|
165
|
+
return bool(self._data)
|
|
166
|
+
|
|
167
|
+
def keys(self) -> Any:
|
|
168
|
+
"""Return dictionary keys."""
|
|
169
|
+
return self._data.keys()
|
|
170
|
+
|
|
171
|
+
def values(self) -> Any:
|
|
172
|
+
"""Return dictionary values."""
|
|
173
|
+
return self._data.values()
|
|
174
|
+
|
|
175
|
+
def items(self) -> Any:
|
|
176
|
+
"""Return dictionary items."""
|
|
177
|
+
return self._data.items()
|
|
178
|
+
|
|
179
|
+
def __iter__(self) -> Any:
|
|
180
|
+
"""Iterate over keys."""
|
|
181
|
+
return iter(self._data)
|
|
182
|
+
|
|
183
|
+
def __len__(self) -> int:
|
|
184
|
+
"""Return number of items."""
|
|
185
|
+
return len(self._data)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# Register cattrs hooks for {class_name}
|
|
189
|
+
def _structure_{class_name.lower()}(data: dict[str, Any], _: type[{class_name}]) -> {class_name}:
|
|
190
|
+
"""Structure hook for cattrs to handle {class_name} deserialization."""
|
|
191
|
+
if data is None:
|
|
192
|
+
return {class_name}()
|
|
193
|
+
if isinstance(data, {class_name}):
|
|
194
|
+
return data
|
|
195
|
+
return {class_name}(_data=data)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _unstructure_{class_name.lower()}(instance: {class_name}) -> dict[str, Any]:
|
|
199
|
+
"""Unstructure hook for cattrs to handle {class_name} serialization."""
|
|
200
|
+
return instance._data.copy()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# Register hooks with cattrs converter at module import time
|
|
204
|
+
from {context.core_package_name}.cattrs_converter import converter
|
|
205
|
+
converter.register_structure_hook({class_name}, _structure_{class_name.lower()})
|
|
206
|
+
converter.register_unstructure_hook({class_name}, _unstructure_{class_name.lower()})
|
|
207
|
+
'''
|
|
208
|
+
|
|
209
|
+
def _generate_typed_wrapper_class(
|
|
210
|
+
self,
|
|
211
|
+
class_name: str,
|
|
212
|
+
value_type: str,
|
|
213
|
+
description: str,
|
|
214
|
+
context: RenderContext,
|
|
215
|
+
) -> str:
|
|
216
|
+
"""Generate wrapper class for typed additionalProperties with value deserialization."""
|
|
217
|
+
# Import Iterator and ValuesView for proper type hints
|
|
218
|
+
context.add_import("typing", "Iterator")
|
|
219
|
+
context.add_import("collections.abc", "ValuesView")
|
|
220
|
+
context.add_import("collections.abc", "ItemsView")
|
|
221
|
+
context.add_import("collections.abc", "KeysView")
|
|
222
|
+
|
|
223
|
+
return f'''__all__ = ["{class_name}"]
|
|
224
|
+
|
|
225
|
+
@dataclass
|
|
226
|
+
class {class_name}:
|
|
227
|
+
"""
|
|
228
|
+
{description}
|
|
229
|
+
|
|
230
|
+
This class wraps a dictionary with typed values, providing dict-like access
|
|
231
|
+
while ensuring values are properly deserialized into {value_type} instances.
|
|
232
|
+
|
|
233
|
+
Example:
|
|
234
|
+
from {context.core_package_name}.cattrs_converter import structure_from_dict, unstructure_to_dict
|
|
235
|
+
|
|
236
|
+
# Deserialize from API response - values become {value_type} instances
|
|
237
|
+
obj = structure_from_dict({{"key": {{"field": "value"}}}}, {class_name})
|
|
238
|
+
|
|
239
|
+
# Access returns typed {value_type} instance
|
|
240
|
+
item = obj["key"]
|
|
241
|
+
print(item.field) # "value" - direct attribute access
|
|
242
|
+
|
|
243
|
+
# Serialize for API request
|
|
244
|
+
data = unstructure_to_dict(obj)
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
_data: dict[str, {value_type}] = field(default_factory=dict, repr=False)
|
|
248
|
+
|
|
249
|
+
# Runtime type information for cattrs deserialization
|
|
250
|
+
_value_type: ClassVar[str] = "{value_type}"
|
|
251
|
+
|
|
252
|
+
def get(self, key: str, default: {value_type} | None = None) -> {value_type} | None:
|
|
253
|
+
"""Get value for key, returning default if key not present."""
|
|
254
|
+
return self._data.get(key, default)
|
|
255
|
+
|
|
256
|
+
def __getitem__(self, key: str) -> {value_type}:
|
|
257
|
+
"""Get value for key."""
|
|
258
|
+
return self._data[key]
|
|
259
|
+
|
|
260
|
+
def __setitem__(self, key: str, value: {value_type}) -> None:
|
|
261
|
+
"""Set value for key."""
|
|
262
|
+
self._data[key] = value
|
|
263
|
+
|
|
264
|
+
def __contains__(self, key: str) -> bool:
|
|
265
|
+
"""Check if key exists."""
|
|
266
|
+
return key in self._data
|
|
267
|
+
|
|
268
|
+
def __bool__(self) -> bool:
|
|
269
|
+
"""Return True if wrapper contains any data."""
|
|
270
|
+
return bool(self._data)
|
|
271
|
+
|
|
272
|
+
def keys(self) -> KeysView[str]:
|
|
273
|
+
"""Return dictionary keys."""
|
|
274
|
+
return self._data.keys()
|
|
275
|
+
|
|
276
|
+
def values(self) -> ValuesView[{value_type}]:
|
|
277
|
+
"""Return dictionary values."""
|
|
278
|
+
return self._data.values()
|
|
279
|
+
|
|
280
|
+
def items(self) -> ItemsView[str, {value_type}]:
|
|
281
|
+
"""Return dictionary items."""
|
|
282
|
+
return self._data.items()
|
|
283
|
+
|
|
284
|
+
def __iter__(self) -> Iterator[str]:
|
|
285
|
+
"""Iterate over keys."""
|
|
286
|
+
return iter(self._data)
|
|
287
|
+
|
|
288
|
+
def __len__(self) -> int:
|
|
289
|
+
"""Return number of items."""
|
|
290
|
+
return len(self._data)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# Register cattrs hooks for {class_name}
|
|
294
|
+
def _structure_{class_name.lower()}(data: dict[str, Any], _: type[{class_name}]) -> {class_name}:
|
|
295
|
+
"""Structure hook for cattrs to handle {class_name} deserialization with typed values."""
|
|
296
|
+
if data is None:
|
|
297
|
+
return {class_name}()
|
|
298
|
+
if isinstance(data, {class_name}):
|
|
299
|
+
return data
|
|
300
|
+
|
|
301
|
+
# Import converter lazily to avoid circular imports
|
|
302
|
+
from {context.core_package_name}.cattrs_converter import converter, _register_structure_hooks_recursively
|
|
303
|
+
|
|
304
|
+
# Register hooks for dataclass value types (once, outside loop)
|
|
305
|
+
if hasattr({value_type}, '__dataclass_fields__'):
|
|
306
|
+
_register_structure_hooks_recursively({value_type})
|
|
307
|
+
|
|
308
|
+
# Deserialize each value into {value_type}
|
|
309
|
+
# Using converter.structure() for all values - cattrs handles primitives, datetime, bytes, etc.
|
|
310
|
+
structured_data: dict[str, {value_type}] = {{}}
|
|
311
|
+
for key, value in data.items():
|
|
312
|
+
structured_data[key] = converter.structure(value, {value_type})
|
|
313
|
+
|
|
314
|
+
return {class_name}(_data=structured_data)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _unstructure_{class_name.lower()}(instance: {class_name}) -> dict[str, Any]:
|
|
318
|
+
"""Unstructure hook for cattrs to handle {class_name} serialization."""
|
|
319
|
+
from {context.core_package_name}.cattrs_converter import converter
|
|
320
|
+
|
|
321
|
+
# Unstructure each value
|
|
322
|
+
return {{
|
|
323
|
+
key: converter.unstructure(value)
|
|
324
|
+
for key, value in instance._data.items()
|
|
325
|
+
}}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# Register hooks with cattrs converter at module import time
|
|
329
|
+
from {context.core_package_name}.cattrs_converter import converter
|
|
330
|
+
converter.register_structure_hook({class_name}, _structure_{class_name.lower()})
|
|
331
|
+
converter.register_unstructure_hook({class_name}, _unstructure_{class_name.lower()})
|
|
332
|
+
'''
|
|
333
|
+
|
|
334
|
+
def _get_field_default(self, ps: IRSchema, context: RenderContext) -> str | None:
|
|
335
|
+
"""
|
|
336
|
+
Determines the default value expression string for a dataclass field.
|
|
337
|
+
This method is called for fields determined to be optional.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
ps: The property schema to analyze.
|
|
341
|
+
context: The rendering context.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
A string representing the Python default value expression or None.
|
|
345
|
+
|
|
346
|
+
Contracts:
|
|
347
|
+
Pre-conditions:
|
|
348
|
+
- ``ps`` is not None.
|
|
349
|
+
- ``context`` is not None.
|
|
350
|
+
Post-conditions:
|
|
351
|
+
- Returns a valid Python default value string
|
|
352
|
+
(e.g., "None", "field(default_factory=list)", "\"abc\"") or None.
|
|
353
|
+
"""
|
|
354
|
+
if ps is None:
|
|
355
|
+
raise ValueError("Property schema (ps) cannot be None.")
|
|
356
|
+
if context is None:
|
|
357
|
+
raise ValueError("RenderContext cannot be None.")
|
|
358
|
+
|
|
359
|
+
if ps.type == "array":
|
|
360
|
+
context.add_import("dataclasses", "field")
|
|
361
|
+
return "field(default_factory=list)"
|
|
362
|
+
elif ps.type == "object" and ps.name is None and not ps.any_of and not ps.one_of and not ps.all_of:
|
|
363
|
+
context.add_import("dataclasses", "field")
|
|
364
|
+
return "field(default_factory=dict)"
|
|
365
|
+
|
|
366
|
+
if ps.default is not None:
|
|
367
|
+
# Check if this is an enum field (has a name that references another schema with enum values)
|
|
368
|
+
if ps.name and self.all_schemas:
|
|
369
|
+
enum_schema = self.all_schemas.get(ps.name)
|
|
370
|
+
if enum_schema and enum_schema.enum:
|
|
371
|
+
# This is an enum field - convert default value to enum member access
|
|
372
|
+
# e.g., "default" -> JobPriorityEnum.DEFAULT
|
|
373
|
+
default_str = str(ps.default)
|
|
374
|
+
# Convert the value to the enum member name (e.g., "default" -> "DEFAULT")
|
|
375
|
+
enum_member_name = default_str.upper().replace("-", "_").replace(" ", "_")
|
|
376
|
+
return f"{ps.name}.{enum_member_name}"
|
|
377
|
+
|
|
378
|
+
if isinstance(ps.default, str):
|
|
379
|
+
escaped_inner_content = json.dumps(ps.default)[1:-1]
|
|
380
|
+
return '"' + escaped_inner_content + '"'
|
|
381
|
+
elif isinstance(ps.default, bool):
|
|
382
|
+
return str(ps.default)
|
|
383
|
+
elif isinstance(ps.default, (int, float)):
|
|
384
|
+
return str(ps.default)
|
|
385
|
+
else:
|
|
386
|
+
logger.warning(
|
|
387
|
+
f"DataclassGenerator: Complex default value '{ps.default}' for field '{ps.name}' of type '{ps.type}'"
|
|
388
|
+
f" cannot be directly rendered. Falling back to None. Type: {type(ps.default)}"
|
|
389
|
+
)
|
|
390
|
+
return "None"
|
|
391
|
+
|
|
392
|
+
def generate(
|
|
393
|
+
self,
|
|
394
|
+
schema: IRSchema,
|
|
395
|
+
base_name: str,
|
|
396
|
+
context: RenderContext,
|
|
397
|
+
) -> str:
|
|
398
|
+
"""
|
|
399
|
+
Generates the Python code for a dataclass.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
schema: The IRSchema for the dataclass.
|
|
403
|
+
base_name: The base name for the dataclass.
|
|
404
|
+
context: The render context.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
The generated Python code string for the dataclass.
|
|
408
|
+
|
|
409
|
+
Contracts:
|
|
410
|
+
Pre-conditions:
|
|
411
|
+
- ``schema`` is not None and ``schema.name`` is not None.
|
|
412
|
+
- ``base_name`` is a non-empty string.
|
|
413
|
+
- ``context`` is not None.
|
|
414
|
+
- ``schema.type`` is suitable for a dataclass (e.g. "object", or "array" for wrapper style).
|
|
415
|
+
Post-conditions:
|
|
416
|
+
- Returns a non-empty string containing valid Python code for a dataclass.
|
|
417
|
+
- ``@dataclass`` decorator is present, implying ``dataclasses.dataclass`` is imported.
|
|
418
|
+
"""
|
|
419
|
+
if schema is None:
|
|
420
|
+
raise ValueError("Schema cannot be None for dataclass generation.")
|
|
421
|
+
if schema.name is None:
|
|
422
|
+
raise ValueError("Schema name must be present for dataclass generation.")
|
|
423
|
+
if not base_name:
|
|
424
|
+
raise ValueError("Base name cannot be empty for dataclass generation.")
|
|
425
|
+
if context is None:
|
|
426
|
+
raise ValueError("RenderContext cannot be None.")
|
|
427
|
+
# Additional check for schema type might be too strict here, as ModelVisitor decides eligibility.
|
|
428
|
+
|
|
429
|
+
# Check if this is an arbitrary JSON object that needs wrapper class
|
|
430
|
+
if self._is_arbitrary_json_object(schema):
|
|
431
|
+
logger.info(
|
|
432
|
+
f"DataclassGenerator: Schema '{base_name}' is an arbitrary JSON object. "
|
|
433
|
+
"Generating wrapper class to preserve data."
|
|
434
|
+
)
|
|
435
|
+
return self._generate_json_wrapper_class(base_name, schema, context)
|
|
436
|
+
|
|
437
|
+
class_name = base_name
|
|
438
|
+
fields_data: List[Tuple[str, str, str | None, str | None]] = []
|
|
439
|
+
field_mappings: dict[str, str] = {}
|
|
440
|
+
|
|
441
|
+
if schema.type == "array" and schema.items:
|
|
442
|
+
field_name_for_array_content = "items"
|
|
443
|
+
if schema.items is None:
|
|
444
|
+
raise ValueError("Schema items must be present for array type dataclass field.")
|
|
445
|
+
|
|
446
|
+
list_item_py_type = self.type_service.resolve_schema_type(schema.items, context, required=True)
|
|
447
|
+
field_type_str = f"List[{list_item_py_type}]"
|
|
448
|
+
|
|
449
|
+
final_field_type_str = TypeFinalizer(context).finalize(
|
|
450
|
+
py_type=field_type_str, schema=schema, required=False
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
synthetic_field_schema_for_default = IRSchema(
|
|
454
|
+
name=field_name_for_array_content,
|
|
455
|
+
type="array",
|
|
456
|
+
items=schema.items,
|
|
457
|
+
is_nullable=schema.is_nullable,
|
|
458
|
+
default=schema.default,
|
|
459
|
+
)
|
|
460
|
+
array_items_field_default_expr = self._get_field_default(synthetic_field_schema_for_default, context)
|
|
461
|
+
|
|
462
|
+
field_description = schema.description
|
|
463
|
+
if not field_description and list_item_py_type != "Any":
|
|
464
|
+
field_description = f"A list of {list_item_py_type} items."
|
|
465
|
+
elif not field_description:
|
|
466
|
+
field_description = "A list of items."
|
|
467
|
+
|
|
468
|
+
fields_data.append(
|
|
469
|
+
(
|
|
470
|
+
field_name_for_array_content,
|
|
471
|
+
final_field_type_str,
|
|
472
|
+
array_items_field_default_expr,
|
|
473
|
+
field_description,
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
elif schema.properties:
|
|
477
|
+
sorted_props = sorted(schema.properties.items(), key=lambda item: (item[0] not in schema.required, item[0]))
|
|
478
|
+
|
|
479
|
+
# Track sanitized names to detect collisions
|
|
480
|
+
seen_field_names: dict[str, str] = {} # sanitized_name → original_api_name
|
|
481
|
+
|
|
482
|
+
for prop_name, prop_schema in sorted_props:
|
|
483
|
+
is_required = prop_name in schema.required
|
|
484
|
+
|
|
485
|
+
# Sanitize the property name for use as a Python attribute
|
|
486
|
+
field_name = NameSanitizer.sanitize_method_name(prop_name)
|
|
487
|
+
|
|
488
|
+
# Collision detection: check if this sanitized name was already used
|
|
489
|
+
if field_name in seen_field_names:
|
|
490
|
+
original_api_name = seen_field_names[field_name]
|
|
491
|
+
base_field_name = field_name
|
|
492
|
+
suffix = 2
|
|
493
|
+
while field_name in seen_field_names:
|
|
494
|
+
field_name = f"{base_field_name}_{suffix}"
|
|
495
|
+
suffix += 1
|
|
496
|
+
logger.warning(
|
|
497
|
+
f"Field name collision in schema '{base_name}': "
|
|
498
|
+
f"API fields '{original_api_name}' and '{prop_name}' both sanitize to '{base_field_name}'. "
|
|
499
|
+
f"Using '{seen_field_names[base_field_name]}' for '{original_api_name}' "
|
|
500
|
+
f"and '{field_name}' for '{prop_name}'."
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
# Track this field name as used
|
|
504
|
+
seen_field_names[field_name] = prop_name
|
|
505
|
+
|
|
506
|
+
# Always create field mapping to preserve original API name
|
|
507
|
+
field_mappings[prop_name] = field_name
|
|
508
|
+
|
|
509
|
+
py_type = self.type_service.resolve_schema_type(prop_schema, context, required=is_required)
|
|
510
|
+
|
|
511
|
+
default_expr: str | None = None
|
|
512
|
+
if not is_required:
|
|
513
|
+
default_expr = self._get_field_default(prop_schema, context)
|
|
514
|
+
|
|
515
|
+
# Enhance field documentation for mapped fields when names differ
|
|
516
|
+
field_doc = prop_schema.description
|
|
517
|
+
if prop_name != field_name:
|
|
518
|
+
if field_doc:
|
|
519
|
+
field_doc = f"{field_doc} (maps from '{prop_name}')"
|
|
520
|
+
else:
|
|
521
|
+
field_doc = f"Maps from '{prop_name}'"
|
|
522
|
+
|
|
523
|
+
fields_data.append((field_name, py_type, default_expr, field_doc))
|
|
524
|
+
|
|
525
|
+
# logger.debug(
|
|
526
|
+
# f"DataclassGenerator: Preparing to render dataclass '{class_name}' with fields: {fields_data}."
|
|
527
|
+
# )
|
|
528
|
+
|
|
529
|
+
# Always include field mappings to preserve original API field names
|
|
530
|
+
# This ensures correct serialization for any API naming convention
|
|
531
|
+
rendered_code = self.renderer.render_dataclass(
|
|
532
|
+
class_name=class_name,
|
|
533
|
+
fields=fields_data,
|
|
534
|
+
description=schema.description,
|
|
535
|
+
context=context,
|
|
536
|
+
field_mappings=field_mappings if field_mappings else None,
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
if not rendered_code.strip():
|
|
540
|
+
raise RuntimeError("Generated dataclass code cannot be empty.")
|
|
541
|
+
# PythonConstructRenderer adds the @dataclass decorator and import
|
|
542
|
+
if "@dataclass" not in rendered_code:
|
|
543
|
+
raise RuntimeError("Dataclass code missing @dataclass decorator.")
|
|
544
|
+
if not (
|
|
545
|
+
"dataclasses" in context.import_collector.imports
|
|
546
|
+
and "dataclass" in context.import_collector.imports["dataclasses"]
|
|
547
|
+
):
|
|
548
|
+
raise RuntimeError("dataclass import was not added to context by renderer.")
|
|
549
|
+
if "default_factory" in rendered_code: # Check for field import if factory is used
|
|
550
|
+
if "field" not in context.import_collector.imports.get("dataclasses", set()):
|
|
551
|
+
raise RuntimeError("'field' import from dataclasses missing when default_factory is used.")
|
|
552
|
+
|
|
553
|
+
return rendered_code
|