pyopenapi-gen 0.8.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyopenapi_gen/__init__.py +114 -0
- pyopenapi_gen/__main__.py +6 -0
- pyopenapi_gen/cli.py +86 -0
- pyopenapi_gen/context/file_manager.py +52 -0
- pyopenapi_gen/context/import_collector.py +382 -0
- pyopenapi_gen/context/render_context.py +630 -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/exceptions.py +25 -0
- pyopenapi_gen/core/http_transport.py +219 -0
- pyopenapi_gen/core/loader/__init__.py +12 -0
- pyopenapi_gen/core/loader/loader.py +158 -0
- pyopenapi_gen/core/loader/operations/__init__.py +12 -0
- pyopenapi_gen/core/loader/operations/parser.py +155 -0
- pyopenapi_gen/core/loader/operations/post_processor.py +60 -0
- pyopenapi_gen/core/loader/operations/request_body.py +85 -0
- pyopenapi_gen/core/loader/parameters/__init__.py +10 -0
- pyopenapi_gen/core/loader/parameters/parser.py +121 -0
- pyopenapi_gen/core/loader/responses/__init__.py +10 -0
- pyopenapi_gen/core/loader/responses/parser.py +104 -0
- pyopenapi_gen/core/loader/schemas/__init__.py +11 -0
- pyopenapi_gen/core/loader/schemas/extractor.py +184 -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 +74 -0
- pyopenapi_gen/core/parsing/context.py +184 -0
- pyopenapi_gen/core/parsing/cycle_helpers.py +123 -0
- pyopenapi_gen/core/parsing/keywords/__init__.py +1 -0
- pyopenapi_gen/core/parsing/keywords/all_of_parser.py +77 -0
- pyopenapi_gen/core/parsing/keywords/any_of_parser.py +79 -0
- pyopenapi_gen/core/parsing/keywords/array_items_parser.py +69 -0
- pyopenapi_gen/core/parsing/keywords/one_of_parser.py +72 -0
- pyopenapi_gen/core/parsing/keywords/properties_parser.py +98 -0
- pyopenapi_gen/core/parsing/schema_finalizer.py +166 -0
- pyopenapi_gen/core/parsing/schema_parser.py +610 -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 +117 -0
- pyopenapi_gen/core/parsing/unified_cycle_detection.py +293 -0
- pyopenapi_gen/core/postprocess_manager.py +161 -0
- pyopenapi_gen/core/schemas.py +40 -0
- pyopenapi_gen/core/streaming_helpers.py +86 -0
- pyopenapi_gen/core/telemetry.py +67 -0
- pyopenapi_gen/core/utils.py +409 -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 +274 -0
- pyopenapi_gen/core_package_template/README.md +21 -0
- pyopenapi_gen/emit/models_emitter.py +143 -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 +223 -0
- pyopenapi_gen/emitters/exceptions_emitter.py +52 -0
- pyopenapi_gen/emitters/models_emitter.py +428 -0
- pyopenapi_gen/generator/client_generator.py +562 -0
- pyopenapi_gen/helpers/__init__.py +1 -0
- pyopenapi_gen/helpers/endpoint_utils.py +552 -0
- pyopenapi_gen/helpers/type_cleaner.py +341 -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 +89 -0
- pyopenapi_gen/helpers/type_resolution/named_resolver.py +174 -0
- pyopenapi_gen/helpers/type_resolution/object_resolver.py +212 -0
- pyopenapi_gen/helpers/type_resolution/primitive_resolver.py +57 -0
- pyopenapi_gen/helpers/type_resolution/resolver.py +48 -0
- pyopenapi_gen/helpers/url_utils.py +14 -0
- pyopenapi_gen/http_types.py +20 -0
- pyopenapi_gen/ir.py +167 -0
- pyopenapi_gen/py.typed +1 -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 +30 -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 +203 -0
- pyopenapi_gen/types/resolvers/schema_resolver.py +367 -0
- pyopenapi_gen/types/services/__init__.py +5 -0
- pyopenapi_gen/types/services/type_service.py +133 -0
- pyopenapi_gen/visit/client_visitor.py +228 -0
- pyopenapi_gen/visit/docs_visitor.py +38 -0
- pyopenapi_gen/visit/endpoint/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/endpoint_visitor.py +103 -0
- pyopenapi_gen/visit/endpoint/generators/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/generators/docstring_generator.py +121 -0
- pyopenapi_gen/visit/endpoint/generators/endpoint_method_generator.py +87 -0
- pyopenapi_gen/visit/endpoint/generators/request_generator.py +103 -0
- pyopenapi_gen/visit/endpoint/generators/response_handler_generator.py +497 -0
- pyopenapi_gen/visit/endpoint/generators/signature_generator.py +88 -0
- pyopenapi_gen/visit/endpoint/generators/url_args_generator.py +183 -0
- pyopenapi_gen/visit/endpoint/processors/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/processors/import_analyzer.py +76 -0
- pyopenapi_gen/visit/endpoint/processors/parameter_processor.py +171 -0
- pyopenapi_gen/visit/exception_visitor.py +52 -0
- pyopenapi_gen/visit/model/__init__.py +0 -0
- pyopenapi_gen/visit/model/alias_generator.py +89 -0
- pyopenapi_gen/visit/model/dataclass_generator.py +197 -0
- pyopenapi_gen/visit/model/enum_generator.py +200 -0
- pyopenapi_gen/visit/model/model_visitor.py +197 -0
- pyopenapi_gen/visit/visitor.py +97 -0
- pyopenapi_gen-0.8.3.dist-info/METADATA +224 -0
- pyopenapi_gen-0.8.3.dist-info/RECORD +122 -0
- pyopenapi_gen-0.8.3.dist-info/WHEEL +4 -0
- pyopenapi_gen-0.8.3.dist-info/entry_points.txt +2 -0
- pyopenapi_gen-0.8.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,197 @@
|
|
1
|
+
"""
|
2
|
+
Generates Python code for dataclasses from IRSchema objects.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import json
|
6
|
+
import logging
|
7
|
+
from typing import Dict, List, Optional, 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: Optional[Dict[str, IRSchema]],
|
26
|
+
):
|
27
|
+
"""
|
28
|
+
Initialize a new DataclassGenerator.
|
29
|
+
|
30
|
+
Contracts:
|
31
|
+
Pre-conditions:
|
32
|
+
- ``renderer`` is not None.
|
33
|
+
"""
|
34
|
+
assert renderer is not None, "PythonConstructRenderer cannot be None."
|
35
|
+
self.renderer = renderer
|
36
|
+
self.all_schemas = all_schemas if all_schemas is not None else {}
|
37
|
+
self.type_service = UnifiedTypeService(self.all_schemas)
|
38
|
+
|
39
|
+
def _get_field_default(self, ps: IRSchema, context: RenderContext) -> Optional[str]:
|
40
|
+
"""
|
41
|
+
Determines the default value expression string for a dataclass field.
|
42
|
+
This method is called for fields determined to be optional.
|
43
|
+
|
44
|
+
Args:
|
45
|
+
ps: The property schema to analyze.
|
46
|
+
context: The rendering context.
|
47
|
+
|
48
|
+
Returns:
|
49
|
+
A string representing the Python default value expression or None.
|
50
|
+
|
51
|
+
Contracts:
|
52
|
+
Pre-conditions:
|
53
|
+
- ``ps`` is not None.
|
54
|
+
- ``context`` is not None.
|
55
|
+
Post-conditions:
|
56
|
+
- Returns a valid Python default value string
|
57
|
+
(e.g., "None", "field(default_factory=list)", "\"abc\"") or None.
|
58
|
+
"""
|
59
|
+
assert ps is not None, "Property schema (ps) cannot be None."
|
60
|
+
assert context is not None, "RenderContext cannot be None."
|
61
|
+
|
62
|
+
if ps.type == "array":
|
63
|
+
context.add_import("dataclasses", "field")
|
64
|
+
return "field(default_factory=list)"
|
65
|
+
elif ps.type == "object" and ps.name is None and not ps.any_of and not ps.one_of and not ps.all_of:
|
66
|
+
context.add_import("dataclasses", "field")
|
67
|
+
return "field(default_factory=dict)"
|
68
|
+
|
69
|
+
if ps.default is not None:
|
70
|
+
if isinstance(ps.default, str):
|
71
|
+
escaped_inner_content = json.dumps(ps.default)[1:-1]
|
72
|
+
return '"' + escaped_inner_content + '"'
|
73
|
+
elif isinstance(ps.default, bool):
|
74
|
+
return str(ps.default)
|
75
|
+
elif isinstance(ps.default, (int, float)):
|
76
|
+
return str(ps.default)
|
77
|
+
else:
|
78
|
+
logger.warning(
|
79
|
+
f"DataclassGenerator: Complex default value '{ps.default}' for field '{ps.name}' of type '{ps.type}"
|
80
|
+
f" cannot be directly rendered. Falling back to None. Type: {type(ps.default)}"
|
81
|
+
)
|
82
|
+
return "None"
|
83
|
+
|
84
|
+
def generate(
|
85
|
+
self,
|
86
|
+
schema: IRSchema,
|
87
|
+
base_name: str,
|
88
|
+
context: RenderContext,
|
89
|
+
) -> str:
|
90
|
+
"""
|
91
|
+
Generates the Python code for a dataclass.
|
92
|
+
|
93
|
+
Args:
|
94
|
+
schema: The IRSchema for the dataclass.
|
95
|
+
base_name: The base name for the dataclass.
|
96
|
+
context: The render context.
|
97
|
+
|
98
|
+
Returns:
|
99
|
+
The generated Python code string for the dataclass.
|
100
|
+
|
101
|
+
Contracts:
|
102
|
+
Pre-conditions:
|
103
|
+
- ``schema`` is not None and ``schema.name`` is not None.
|
104
|
+
- ``base_name`` is a non-empty string.
|
105
|
+
- ``context`` is not None.
|
106
|
+
- ``schema.type`` is suitable for a dataclass (e.g. "object", or "array" for wrapper style).
|
107
|
+
Post-conditions:
|
108
|
+
- Returns a non-empty string containing valid Python code for a dataclass.
|
109
|
+
- ``@dataclass`` decorator is present, implying ``dataclasses.dataclass`` is imported.
|
110
|
+
"""
|
111
|
+
assert schema is not None, "Schema cannot be None for dataclass generation."
|
112
|
+
assert schema.name is not None, "Schema name must be present for dataclass generation."
|
113
|
+
assert base_name, "Base name cannot be empty for dataclass generation."
|
114
|
+
assert context is not None, "RenderContext cannot be None."
|
115
|
+
# Additional check for schema type might be too strict here, as ModelVisitor decides eligibility.
|
116
|
+
|
117
|
+
class_name = base_name
|
118
|
+
fields_data: List[Tuple[str, str, Optional[str], Optional[str]]] = []
|
119
|
+
|
120
|
+
if schema.type == "array" and schema.items:
|
121
|
+
field_name_for_array_content = "items"
|
122
|
+
assert schema.items is not None, "Schema items must be present for array type dataclass field."
|
123
|
+
|
124
|
+
list_item_py_type = self.type_service.resolve_schema_type(schema.items, context, required=True)
|
125
|
+
list_item_py_type = TypeFinalizer(context)._clean_type(list_item_py_type)
|
126
|
+
field_type_str = f"List[{list_item_py_type}]"
|
127
|
+
|
128
|
+
final_field_type_str = TypeFinalizer(context).finalize(
|
129
|
+
py_type=field_type_str, schema=schema, required=False
|
130
|
+
)
|
131
|
+
|
132
|
+
synthetic_field_schema_for_default = IRSchema(
|
133
|
+
name=field_name_for_array_content,
|
134
|
+
type="array",
|
135
|
+
items=schema.items,
|
136
|
+
is_nullable=schema.is_nullable,
|
137
|
+
default=schema.default,
|
138
|
+
)
|
139
|
+
array_items_field_default_expr = self._get_field_default(synthetic_field_schema_for_default, context)
|
140
|
+
|
141
|
+
field_description = schema.description
|
142
|
+
if not field_description and list_item_py_type != "Any":
|
143
|
+
field_description = f"A list of {list_item_py_type} items."
|
144
|
+
elif not field_description:
|
145
|
+
field_description = "A list of items."
|
146
|
+
|
147
|
+
fields_data.append(
|
148
|
+
(
|
149
|
+
field_name_for_array_content,
|
150
|
+
final_field_type_str,
|
151
|
+
array_items_field_default_expr,
|
152
|
+
field_description,
|
153
|
+
)
|
154
|
+
)
|
155
|
+
elif schema.properties:
|
156
|
+
sorted_props = sorted(schema.properties.items(), key=lambda item: (item[0] not in schema.required, item[0]))
|
157
|
+
|
158
|
+
for prop_name, prop_schema in sorted_props:
|
159
|
+
is_required = prop_name in schema.required
|
160
|
+
|
161
|
+
# Sanitize the property name for use as a Python attribute
|
162
|
+
field_name = NameSanitizer.sanitize_method_name(prop_name)
|
163
|
+
|
164
|
+
py_type = self.type_service.resolve_schema_type(prop_schema, context, required=is_required)
|
165
|
+
py_type = TypeFinalizer(context)._clean_type(py_type)
|
166
|
+
|
167
|
+
default_expr: Optional[str] = None
|
168
|
+
if not is_required:
|
169
|
+
default_expr = self._get_field_default(prop_schema, context)
|
170
|
+
|
171
|
+
field_doc = prop_schema.description
|
172
|
+
fields_data.append((field_name, py_type, default_expr, field_doc))
|
173
|
+
|
174
|
+
# logger.debug(
|
175
|
+
# f"DataclassGenerator: Preparing to render dataclass '{class_name}' with fields: {fields_data}."
|
176
|
+
# )
|
177
|
+
|
178
|
+
rendered_code = self.renderer.render_dataclass(
|
179
|
+
class_name=class_name,
|
180
|
+
fields=fields_data,
|
181
|
+
description=schema.description,
|
182
|
+
context=context,
|
183
|
+
)
|
184
|
+
|
185
|
+
assert rendered_code.strip(), "Generated dataclass code cannot be empty."
|
186
|
+
# PythonConstructRenderer adds the @dataclass decorator and import
|
187
|
+
assert "@dataclass" in rendered_code, "Dataclass code missing @dataclass decorator."
|
188
|
+
assert (
|
189
|
+
"dataclasses" in context.import_collector.imports
|
190
|
+
and "dataclass" in context.import_collector.imports["dataclasses"]
|
191
|
+
), "dataclass import was not added to context by renderer."
|
192
|
+
if "default_factory" in rendered_code: # Check for field import if factory is used
|
193
|
+
assert "field" in context.import_collector.imports.get(
|
194
|
+
"dataclasses", set()
|
195
|
+
), "'field' import from dataclasses missing when default_factory is used."
|
196
|
+
|
197
|
+
return rendered_code
|
@@ -0,0 +1,200 @@
|
|
1
|
+
"""
|
2
|
+
Generates Python code for enums from IRSchema objects.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import keyword
|
6
|
+
import logging
|
7
|
+
import re
|
8
|
+
from typing import List, Tuple
|
9
|
+
|
10
|
+
from pyopenapi_gen import IRSchema
|
11
|
+
from pyopenapi_gen.context.render_context import RenderContext
|
12
|
+
from pyopenapi_gen.core.writers.python_construct_renderer import PythonConstructRenderer
|
13
|
+
|
14
|
+
logger = logging.getLogger(__name__)
|
15
|
+
|
16
|
+
|
17
|
+
class EnumGenerator:
|
18
|
+
"""Generates Python code for an enum."""
|
19
|
+
|
20
|
+
def __init__(self, renderer: PythonConstructRenderer):
|
21
|
+
# Pre-condition
|
22
|
+
assert renderer is not None, "PythonConstructRenderer cannot be None"
|
23
|
+
self.renderer = renderer
|
24
|
+
|
25
|
+
def _generate_member_name_for_string_enum(self, value: str) -> str:
|
26
|
+
"""
|
27
|
+
Generates a Python-valid member name from a string enum value.
|
28
|
+
|
29
|
+
Contracts:
|
30
|
+
Pre-conditions:
|
31
|
+
- ``value`` is a string.
|
32
|
+
Post-conditions:
|
33
|
+
- Returns a non-empty string that is a valid Python identifier, typically uppercase.
|
34
|
+
"""
|
35
|
+
assert isinstance(value, str), "Input value must be a string."
|
36
|
+
base_member_name = str(value).upper().replace("-", "_").replace(" ", "_")
|
37
|
+
sanitized_member_name = re.sub(r"[^A-Z0-9_]", "", base_member_name)
|
38
|
+
|
39
|
+
if not sanitized_member_name:
|
40
|
+
# Handle empty string or string that became empty after sanitization
|
41
|
+
# Using a generic placeholder if original value was also effectively empty/non-descriptive
|
42
|
+
# Or try to derive something if the original string had some content before stripping
|
43
|
+
original_alnum = re.sub(r"[^A-Za-z0-9]", "", str(value))
|
44
|
+
if not original_alnum:
|
45
|
+
sanitized_member_name = "MEMBER_EMPTY_STRING"
|
46
|
+
else:
|
47
|
+
# Attempt to form a name from original alphanumeric chars if sanitization wiped it
|
48
|
+
sanitized_member_name = f"MEMBER_{original_alnum.upper()}"
|
49
|
+
if sanitized_member_name[0].isdigit(): # Check if this new form starts with a digit
|
50
|
+
sanitized_member_name = f"MEMBER_{sanitized_member_name}" # MEMBER_MEMBER_... is ok
|
51
|
+
elif sanitized_member_name[0].isdigit():
|
52
|
+
sanitized_member_name = f"MEMBER_{sanitized_member_name}"
|
53
|
+
|
54
|
+
if keyword.iskeyword(sanitized_member_name.lower()): # Check lowercase version for keyword
|
55
|
+
sanitized_member_name += "_"
|
56
|
+
|
57
|
+
# Final check for safety: if it's still not a valid start (e.g. _MEMBER_...)
|
58
|
+
if not re.match(r"^[A-Z_]", sanitized_member_name.upper()):
|
59
|
+
sanitized_member_name = f"MEMBER_{sanitized_member_name}"
|
60
|
+
|
61
|
+
assert sanitized_member_name and re.match(r"^[A-Z_][A-Z0-9_]*$", sanitized_member_name.upper()), (
|
62
|
+
f"Generated string enum member name '{sanitized_member_name}' "
|
63
|
+
f"is not a valid Python identifier from value '{value}'."
|
64
|
+
)
|
65
|
+
return sanitized_member_name
|
66
|
+
|
67
|
+
def _generate_member_name_for_integer_enum(self, value: str | int, int_value_for_fallback: int) -> str:
|
68
|
+
"""
|
69
|
+
Generates a Python-valid member name from an integer enum value (or its string representation).
|
70
|
+
|
71
|
+
Contracts:
|
72
|
+
Pre-conditions:
|
73
|
+
- ``value`` is a string or an int.
|
74
|
+
- ``int_value_for_fallback`` is an int.
|
75
|
+
Post-conditions:
|
76
|
+
- Returns a non-empty string that is a valid Python identifier, typically uppercase.
|
77
|
+
"""
|
78
|
+
assert isinstance(value, (str, int)), "Input value for integer enum naming must be str or int."
|
79
|
+
assert isinstance(int_value_for_fallback, int), "Fallback integer value must be an int."
|
80
|
+
|
81
|
+
name_basis = str(value) # Use string representation as basis for name
|
82
|
+
base_member_name = name_basis.upper().replace("-", "_").replace(" ", "_").replace(".", "_DOT_")
|
83
|
+
sanitized_member_name = re.sub(r"[^A-Z0-9_]", "", base_member_name)
|
84
|
+
|
85
|
+
if not sanitized_member_name:
|
86
|
+
# If string value like "-" or "." became empty, use the int value directly
|
87
|
+
if int_value_for_fallback < 0:
|
88
|
+
sanitized_member_name = f"VALUE_NEG_{abs(int_value_for_fallback)}"
|
89
|
+
else:
|
90
|
+
sanitized_member_name = f"VALUE_{int_value_for_fallback}"
|
91
|
+
# This form should be inherently valid (VALUE_ + digits or VALUE_NEG_ + digits)
|
92
|
+
elif not re.match(r"^[A-Z_]", sanitized_member_name.upper()): # Check if starts with letter/underscore
|
93
|
+
# If it starts with a digit, or some other non-alpha (though re.sub should prevent others)
|
94
|
+
sanitized_member_name = f"VALUE_{sanitized_member_name}"
|
95
|
+
|
96
|
+
if keyword.iskeyword(sanitized_member_name.lower()): # Check lowercase version
|
97
|
+
sanitized_member_name += "_"
|
98
|
+
|
99
|
+
# One final check: ensure it starts with an uppercase letter or underscore
|
100
|
+
if not re.match(r"^[A-Z_]", sanitized_member_name.upper()):
|
101
|
+
# This is a last resort, should be rare. Prefix to ensure validity.
|
102
|
+
sanitized_member_name = f"ENUM_MEMBER_{sanitized_member_name}"
|
103
|
+
# And re-sanitize this new prefixed name just in case the original had problematic chars
|
104
|
+
sanitized_member_name = re.sub(r"[^A-Z0-9_]", "", sanitized_member_name.upper())
|
105
|
+
if not sanitized_member_name: # Should be impossible
|
106
|
+
sanitized_member_name = f"ENUM_MEMBER_UNKNOWN_{abs(int_value_for_fallback)}"
|
107
|
+
|
108
|
+
assert sanitized_member_name and re.match(r"^[A-Z_][A-Z0-9_]*$", sanitized_member_name.upper()), (
|
109
|
+
f"Generated integer enum member name '{sanitized_member_name}' "
|
110
|
+
f"is not a valid Python identifier from value '{value}'."
|
111
|
+
)
|
112
|
+
return sanitized_member_name
|
113
|
+
|
114
|
+
def generate(
|
115
|
+
self,
|
116
|
+
schema: IRSchema,
|
117
|
+
base_name: str, # This is the class name, will be sanitized by PythonConstructRenderer
|
118
|
+
context: RenderContext,
|
119
|
+
) -> str:
|
120
|
+
"""
|
121
|
+
Generates the Python code for an enum.
|
122
|
+
|
123
|
+
Args:
|
124
|
+
schema: The IRSchema for the enum.
|
125
|
+
base_name: The base name for the enum class.
|
126
|
+
context: The render context.
|
127
|
+
|
128
|
+
Returns:
|
129
|
+
The generated Python code string for the enum.
|
130
|
+
|
131
|
+
Contracts:
|
132
|
+
Pre-conditions:
|
133
|
+
- ``schema`` is not None, ``schema.name`` is not None, and ``schema.enum`` is not None and not empty.
|
134
|
+
- ``schema.type`` is either "string" or "integer".
|
135
|
+
- ``base_name`` is a non-empty string.
|
136
|
+
- ``context`` is not None.
|
137
|
+
Post-conditions:
|
138
|
+
- Returns a non-empty string containing valid Python code for an enum.
|
139
|
+
- ``Enum`` from the ``enum`` module is imported in the context.
|
140
|
+
"""
|
141
|
+
assert schema is not None, "Schema cannot be None for enum generation."
|
142
|
+
assert schema.name is not None, "Schema name must be present for enum generation."
|
143
|
+
assert base_name, "Base name cannot be empty for enum generation."
|
144
|
+
assert context is not None, "RenderContext cannot be None."
|
145
|
+
assert schema.enum, "Schema must have enum values for enum generation."
|
146
|
+
assert schema.type in ("string", "integer"), "Enum schema type must be 'string' or 'integer'."
|
147
|
+
|
148
|
+
enum_class_name = base_name # PythonConstructRenderer will sanitize this class name
|
149
|
+
base_type = "str" if schema.type == "string" else "int"
|
150
|
+
values: List[Tuple[str, str | int]] = []
|
151
|
+
processed_member_names = set()
|
152
|
+
|
153
|
+
for val_from_spec in schema.enum:
|
154
|
+
member_name: str
|
155
|
+
member_value: str | int
|
156
|
+
|
157
|
+
if base_type == "str":
|
158
|
+
member_value = str(val_from_spec)
|
159
|
+
member_name = self._generate_member_name_for_string_enum(member_value)
|
160
|
+
else: # Integer enum
|
161
|
+
try:
|
162
|
+
actual_int_value = int(val_from_spec)
|
163
|
+
except (ValueError, TypeError):
|
164
|
+
logger.warning(
|
165
|
+
f"EnumGenerator: Could not convert enum value '{val_from_spec}' "
|
166
|
+
f"to int for schema '{schema.name}'. Using value 0."
|
167
|
+
)
|
168
|
+
actual_int_value = 0 # Fallback value
|
169
|
+
member_value = actual_int_value
|
170
|
+
# Pass original spec value for naming, and the actual int value for fallback naming
|
171
|
+
member_name = self._generate_member_name_for_integer_enum(val_from_spec, actual_int_value)
|
172
|
+
|
173
|
+
# Handle duplicate member names by appending a counter
|
174
|
+
unique_member_name = member_name
|
175
|
+
counter = 1
|
176
|
+
while unique_member_name in processed_member_names:
|
177
|
+
unique_member_name = f"{member_name}_{counter}"
|
178
|
+
counter += 1
|
179
|
+
processed_member_names.add(unique_member_name)
|
180
|
+
|
181
|
+
values.append((unique_member_name, member_value))
|
182
|
+
|
183
|
+
# logger.debug(
|
184
|
+
# f"EnumGenerator: Preparing to render enum '{enum_class_name}' "
|
185
|
+
# f"with base type '{base_type}' and members: {values}."
|
186
|
+
# )
|
187
|
+
rendered_code = self.renderer.render_enum(
|
188
|
+
enum_name=enum_class_name, # Pass the original base_name; renderer handles class name sanitization
|
189
|
+
base_type=base_type,
|
190
|
+
values=values,
|
191
|
+
description=schema.description,
|
192
|
+
context=context,
|
193
|
+
)
|
194
|
+
|
195
|
+
assert rendered_code.strip(), "Generated enum code cannot be empty."
|
196
|
+
assert (
|
197
|
+
"enum" in context.import_collector.imports and "Enum" in context.import_collector.imports["enum"]
|
198
|
+
), "Enum import was not added to context by renderer."
|
199
|
+
|
200
|
+
return rendered_code
|
@@ -0,0 +1,197 @@
|
|
1
|
+
"""
|
2
|
+
ModelVisitor: Transforms IRSchema objects into Python model code (dataclasses and enums).
|
3
|
+
|
4
|
+
This module provides the ModelVisitor class that generates Python code for data models
|
5
|
+
defined in OpenAPI specifications, supporting type aliases, enums, and dataclasses.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import logging
|
9
|
+
from typing import Dict, Optional
|
10
|
+
|
11
|
+
from pyopenapi_gen import IRSchema
|
12
|
+
from pyopenapi_gen.context.render_context import RenderContext
|
13
|
+
from pyopenapi_gen.core.utils import Formatter
|
14
|
+
from pyopenapi_gen.core.writers.python_construct_renderer import PythonConstructRenderer
|
15
|
+
from pyopenapi_gen.helpers.type_helper import TypeHelper
|
16
|
+
|
17
|
+
from ..visitor import Visitor # Relative import from parent package
|
18
|
+
|
19
|
+
# Import new generators from the current 'model' package
|
20
|
+
from .alias_generator import AliasGenerator
|
21
|
+
from .dataclass_generator import DataclassGenerator
|
22
|
+
from .enum_generator import EnumGenerator
|
23
|
+
|
24
|
+
logger = logging.getLogger(__name__)
|
25
|
+
|
26
|
+
|
27
|
+
class ModelVisitor(Visitor[IRSchema, str]):
|
28
|
+
"""
|
29
|
+
Visitor for rendering a Python model (dataclass, enum, or type alias) from an IRSchema.
|
30
|
+
It determines the model type and delegates to specialized generators.
|
31
|
+
|
32
|
+
Contracts:
|
33
|
+
Post-conditions:
|
34
|
+
- Returns a valid Python code string representing the model.
|
35
|
+
- Returns an empty string if the schema should not be rendered as a standalone model.
|
36
|
+
- All necessary imports for the generated model are registered in the context.
|
37
|
+
"""
|
38
|
+
|
39
|
+
def __init__(self, schemas: Optional[Dict[str, IRSchema]] = None) -> None:
|
40
|
+
"""
|
41
|
+
Initialize a new ModelVisitor.
|
42
|
+
|
43
|
+
Args:
|
44
|
+
schemas: Dictionary of all schemas for reference resolution.
|
45
|
+
"""
|
46
|
+
self.formatter = Formatter()
|
47
|
+
self.all_schemas = schemas or {}
|
48
|
+
|
49
|
+
# Initialize PythonConstructRenderer once; it's passed to generators.
|
50
|
+
self.renderer = PythonConstructRenderer()
|
51
|
+
|
52
|
+
# Initialize generators, passing the shared renderer and all_schemas (where needed).
|
53
|
+
self.alias_generator = AliasGenerator(self.renderer, self.all_schemas)
|
54
|
+
self.enum_generator = EnumGenerator(self.renderer)
|
55
|
+
self.dataclass_generator = DataclassGenerator(self.renderer, self.all_schemas)
|
56
|
+
|
57
|
+
def visit_IRSchema(self, schema: IRSchema, context: RenderContext) -> str:
|
58
|
+
"""
|
59
|
+
Visit an IRSchema node, determine model type, and delegate to the appropriate generator.
|
60
|
+
|
61
|
+
Args:
|
62
|
+
schema: The schema to visit.
|
63
|
+
context: The rendering context for imports and configuration.
|
64
|
+
|
65
|
+
Returns:
|
66
|
+
Formatted Python code for the model as a string, or an empty string if not generated.
|
67
|
+
|
68
|
+
Contracts:
|
69
|
+
Pre-conditions:
|
70
|
+
- ``schema`` is a valid ``IRSchema`` object.
|
71
|
+
- ``context`` is a valid ``RenderContext`` object.
|
72
|
+
Post-conditions:
|
73
|
+
- If a model is generated, it's a valid Python code string.
|
74
|
+
- If ``schema.name`` is None and the schema would be a complex type (alias, enum, dataclass),
|
75
|
+
an empty string is returned.
|
76
|
+
- Necessary imports for the generated types are added to ``context``.
|
77
|
+
"""
|
78
|
+
# --- Model Type Detection Logic ---
|
79
|
+
is_enum = bool(schema.name and schema.enum and schema.type in ("string", "integer"))
|
80
|
+
|
81
|
+
is_type_alias = bool(schema.name and not schema.properties and not is_enum and schema.type != "object")
|
82
|
+
|
83
|
+
if schema.type == "array" and schema.items and schema.items.type == "object" and schema.items.name is None:
|
84
|
+
if is_type_alias:
|
85
|
+
# logger.debug(
|
86
|
+
# f"ModelVisitor: Schema '{schema.name}' is an array of anonymous items. "
|
87
|
+
# "It will be rendered as a dataclass instead of a TypeAlias."
|
88
|
+
# )
|
89
|
+
is_type_alias = False
|
90
|
+
schema.is_data_wrapper = True
|
91
|
+
|
92
|
+
is_dataclass = not is_enum and not is_type_alias
|
93
|
+
# --- End of Detection Logic ---
|
94
|
+
|
95
|
+
if not schema.name and (is_type_alias or is_enum or is_dataclass):
|
96
|
+
# logger.debug(f"ModelVisitor: Skipping anonymous schema that would be a standalone model: {schema}")
|
97
|
+
return ""
|
98
|
+
|
99
|
+
# Pre-condition check after filtering anonymous
|
100
|
+
# schema.name is the original sanitized name. schema.generation_name is the de-collided one.
|
101
|
+
# For standalone models, generation_name should be set and used.
|
102
|
+
if schema.generation_name:
|
103
|
+
base_name_for_construct = schema.generation_name
|
104
|
+
# logger.debug(f"Using schema.generation_name ('{schema.generation_name}') for construct base name.")
|
105
|
+
elif (
|
106
|
+
schema.name
|
107
|
+
): # Fallback for schemas not processed by emitter pre-naming (e.g. inline, or if generation_name wasn't set)
|
108
|
+
base_name_for_construct = schema.name
|
109
|
+
# logger.debug(f"Using schema.name ('{schema.name}') for construct base name, "
|
110
|
+
# f"as generation_name is not set.")
|
111
|
+
else:
|
112
|
+
# This case should ideally be caught by the "not schema.name and (is_type_alias...)" check above
|
113
|
+
# or by assertions in generators if they receive a schema without a usable name.
|
114
|
+
logger.error(
|
115
|
+
f"ModelVisitor: Schema has no usable name (name or generation_name) for model generation: {schema}"
|
116
|
+
)
|
117
|
+
assert False, "Schema must have a name or generation_name for model code generation at this point."
|
118
|
+
# return "" # Should not reach here if assertions are active
|
119
|
+
|
120
|
+
# --- Import Registration ---
|
121
|
+
# Analyze the schema for all necessary type imports and register them.
|
122
|
+
_ = TypeHelper.get_python_type_for_schema(
|
123
|
+
schema, self.all_schemas, context, required=True, resolve_alias_target=True
|
124
|
+
)
|
125
|
+
|
126
|
+
if context.current_file:
|
127
|
+
context.mark_generated_module(context.current_file)
|
128
|
+
|
129
|
+
# --- Code Generation Dispatch ---
|
130
|
+
rendered_code = ""
|
131
|
+
if is_type_alias:
|
132
|
+
# logger.debug(f"ModelVisitor: Dispatching to AliasGenerator for schema: {schema.name}")
|
133
|
+
rendered_code = self.alias_generator.generate(schema, base_name_for_construct, context)
|
134
|
+
elif is_enum:
|
135
|
+
# logger.debug(f"ModelVisitor: Dispatching to EnumGenerator for schema: {schema.name}")
|
136
|
+
rendered_code = self.enum_generator.generate(schema, base_name_for_construct, context)
|
137
|
+
elif is_dataclass:
|
138
|
+
# logger.debug(f"ModelVisitor: Dispatching to DataclassGenerator for schema: {schema.name}")
|
139
|
+
rendered_code = self.dataclass_generator.generate(schema, base_name_for_construct, context)
|
140
|
+
else:
|
141
|
+
# logger.debug(
|
142
|
+
# f"ModelVisitor: Schema '{schema.name if schema.name else 'Unnamed'}' "
|
143
|
+
# f"(type: {schema.type}) did not map to a dedicated "
|
144
|
+
# "alias, enum, or dataclass. No standalone model generated by ModelVisitor."
|
145
|
+
# )
|
146
|
+
# Post-condition: returns empty string if no specific generator called
|
147
|
+
assert not rendered_code, "Rendered code should be empty if no generator was matched."
|
148
|
+
return ""
|
149
|
+
|
150
|
+
# Post-condition: ensure some code was generated if a generator was called
|
151
|
+
assert rendered_code.strip() or not (
|
152
|
+
is_type_alias or is_enum or is_dataclass
|
153
|
+
), f"Code generation resulted in an empty string for schema '{schema.name}' which was matched as a model type."
|
154
|
+
|
155
|
+
return self.formatter.format(rendered_code)
|
156
|
+
|
157
|
+
def _get_field_default(self, ps: IRSchema, context: RenderContext) -> Optional[str]:
|
158
|
+
"""
|
159
|
+
Determines the default value expression string for a dataclass field.
|
160
|
+
This method is called for fields determined to be optional.
|
161
|
+
|
162
|
+
Args:
|
163
|
+
ps: The property schema to analyze
|
164
|
+
context: The rendering context
|
165
|
+
|
166
|
+
Returns:
|
167
|
+
A string representing the Python default value expression
|
168
|
+
"""
|
169
|
+
# Restore logic for default_factory for list and dict
|
170
|
+
if ps.type == "array":
|
171
|
+
context.add_import("dataclasses", "field")
|
172
|
+
return "field(default_factory=list)"
|
173
|
+
elif ps.type == "object" and ps.name is None and not ps.any_of and not ps.one_of and not ps.all_of:
|
174
|
+
# This condition aims for anonymous objects that are not part of a union/composition.
|
175
|
+
# These should get default_factory=dict if they are optional fields.
|
176
|
+
context.add_import("dataclasses", "field")
|
177
|
+
return "field(default_factory=dict)"
|
178
|
+
else:
|
179
|
+
# Primitives, enums, named objects, unions default to None when optional
|
180
|
+
return "None"
|
181
|
+
|
182
|
+
def _analyze_and_register_imports(self, schema: IRSchema, context: RenderContext) -> None:
|
183
|
+
"""
|
184
|
+
Analyze a schema and register necessary imports for the generated code.
|
185
|
+
|
186
|
+
This ensures that all necessary types used in the model are properly imported
|
187
|
+
in the generated Python file.
|
188
|
+
|
189
|
+
Args:
|
190
|
+
schema: The schema to analyze
|
191
|
+
context: The rendering context for import registration
|
192
|
+
"""
|
193
|
+
# Call the helper to ensure types within properties/items/composition are analyzed
|
194
|
+
# and imports registered
|
195
|
+
_ = TypeHelper.get_python_type_for_schema(
|
196
|
+
schema, self.all_schemas, context, required=True, resolve_alias_target=True
|
197
|
+
)
|