pyopenapi-gen 0.8.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. pyopenapi_gen/__init__.py +114 -0
  2. pyopenapi_gen/__main__.py +6 -0
  3. pyopenapi_gen/cli.py +86 -0
  4. pyopenapi_gen/context/file_manager.py +52 -0
  5. pyopenapi_gen/context/import_collector.py +382 -0
  6. pyopenapi_gen/context/render_context.py +630 -0
  7. pyopenapi_gen/core/__init__.py +0 -0
  8. pyopenapi_gen/core/auth/base.py +22 -0
  9. pyopenapi_gen/core/auth/plugins.py +89 -0
  10. pyopenapi_gen/core/exceptions.py +25 -0
  11. pyopenapi_gen/core/http_transport.py +219 -0
  12. pyopenapi_gen/core/loader/__init__.py +12 -0
  13. pyopenapi_gen/core/loader/loader.py +158 -0
  14. pyopenapi_gen/core/loader/operations/__init__.py +12 -0
  15. pyopenapi_gen/core/loader/operations/parser.py +155 -0
  16. pyopenapi_gen/core/loader/operations/post_processor.py +60 -0
  17. pyopenapi_gen/core/loader/operations/request_body.py +85 -0
  18. pyopenapi_gen/core/loader/parameters/__init__.py +10 -0
  19. pyopenapi_gen/core/loader/parameters/parser.py +121 -0
  20. pyopenapi_gen/core/loader/responses/__init__.py +10 -0
  21. pyopenapi_gen/core/loader/responses/parser.py +104 -0
  22. pyopenapi_gen/core/loader/schemas/__init__.py +11 -0
  23. pyopenapi_gen/core/loader/schemas/extractor.py +184 -0
  24. pyopenapi_gen/core/pagination.py +64 -0
  25. pyopenapi_gen/core/parsing/__init__.py +13 -0
  26. pyopenapi_gen/core/parsing/common/__init__.py +1 -0
  27. pyopenapi_gen/core/parsing/common/ref_resolution/__init__.py +9 -0
  28. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/__init__.py +0 -0
  29. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/cyclic_properties.py +66 -0
  30. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/direct_cycle.py +33 -0
  31. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/existing_schema.py +22 -0
  32. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/list_response.py +54 -0
  33. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/missing_ref.py +52 -0
  34. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/new_schema.py +50 -0
  35. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/stripped_suffix.py +51 -0
  36. pyopenapi_gen/core/parsing/common/ref_resolution/resolve_schema_ref.py +86 -0
  37. pyopenapi_gen/core/parsing/common/type_parser.py +74 -0
  38. pyopenapi_gen/core/parsing/context.py +184 -0
  39. pyopenapi_gen/core/parsing/cycle_helpers.py +123 -0
  40. pyopenapi_gen/core/parsing/keywords/__init__.py +1 -0
  41. pyopenapi_gen/core/parsing/keywords/all_of_parser.py +77 -0
  42. pyopenapi_gen/core/parsing/keywords/any_of_parser.py +79 -0
  43. pyopenapi_gen/core/parsing/keywords/array_items_parser.py +69 -0
  44. pyopenapi_gen/core/parsing/keywords/one_of_parser.py +72 -0
  45. pyopenapi_gen/core/parsing/keywords/properties_parser.py +98 -0
  46. pyopenapi_gen/core/parsing/schema_finalizer.py +166 -0
  47. pyopenapi_gen/core/parsing/schema_parser.py +610 -0
  48. pyopenapi_gen/core/parsing/transformers/__init__.py +0 -0
  49. pyopenapi_gen/core/parsing/transformers/inline_enum_extractor.py +285 -0
  50. pyopenapi_gen/core/parsing/transformers/inline_object_promoter.py +117 -0
  51. pyopenapi_gen/core/parsing/unified_cycle_detection.py +293 -0
  52. pyopenapi_gen/core/postprocess_manager.py +161 -0
  53. pyopenapi_gen/core/schemas.py +40 -0
  54. pyopenapi_gen/core/streaming_helpers.py +86 -0
  55. pyopenapi_gen/core/telemetry.py +67 -0
  56. pyopenapi_gen/core/utils.py +409 -0
  57. pyopenapi_gen/core/warning_collector.py +83 -0
  58. pyopenapi_gen/core/writers/code_writer.py +135 -0
  59. pyopenapi_gen/core/writers/documentation_writer.py +222 -0
  60. pyopenapi_gen/core/writers/line_writer.py +217 -0
  61. pyopenapi_gen/core/writers/python_construct_renderer.py +274 -0
  62. pyopenapi_gen/core_package_template/README.md +21 -0
  63. pyopenapi_gen/emit/models_emitter.py +143 -0
  64. pyopenapi_gen/emitters/client_emitter.py +51 -0
  65. pyopenapi_gen/emitters/core_emitter.py +181 -0
  66. pyopenapi_gen/emitters/docs_emitter.py +44 -0
  67. pyopenapi_gen/emitters/endpoints_emitter.py +223 -0
  68. pyopenapi_gen/emitters/exceptions_emitter.py +52 -0
  69. pyopenapi_gen/emitters/models_emitter.py +428 -0
  70. pyopenapi_gen/generator/client_generator.py +562 -0
  71. pyopenapi_gen/helpers/__init__.py +1 -0
  72. pyopenapi_gen/helpers/endpoint_utils.py +552 -0
  73. pyopenapi_gen/helpers/type_cleaner.py +341 -0
  74. pyopenapi_gen/helpers/type_helper.py +112 -0
  75. pyopenapi_gen/helpers/type_resolution/__init__.py +1 -0
  76. pyopenapi_gen/helpers/type_resolution/array_resolver.py +57 -0
  77. pyopenapi_gen/helpers/type_resolution/composition_resolver.py +79 -0
  78. pyopenapi_gen/helpers/type_resolution/finalizer.py +89 -0
  79. pyopenapi_gen/helpers/type_resolution/named_resolver.py +174 -0
  80. pyopenapi_gen/helpers/type_resolution/object_resolver.py +212 -0
  81. pyopenapi_gen/helpers/type_resolution/primitive_resolver.py +57 -0
  82. pyopenapi_gen/helpers/type_resolution/resolver.py +48 -0
  83. pyopenapi_gen/helpers/url_utils.py +14 -0
  84. pyopenapi_gen/http_types.py +20 -0
  85. pyopenapi_gen/ir.py +167 -0
  86. pyopenapi_gen/py.typed +1 -0
  87. pyopenapi_gen/types/__init__.py +11 -0
  88. pyopenapi_gen/types/contracts/__init__.py +13 -0
  89. pyopenapi_gen/types/contracts/protocols.py +106 -0
  90. pyopenapi_gen/types/contracts/types.py +30 -0
  91. pyopenapi_gen/types/resolvers/__init__.py +7 -0
  92. pyopenapi_gen/types/resolvers/reference_resolver.py +71 -0
  93. pyopenapi_gen/types/resolvers/response_resolver.py +203 -0
  94. pyopenapi_gen/types/resolvers/schema_resolver.py +367 -0
  95. pyopenapi_gen/types/services/__init__.py +5 -0
  96. pyopenapi_gen/types/services/type_service.py +133 -0
  97. pyopenapi_gen/visit/client_visitor.py +228 -0
  98. pyopenapi_gen/visit/docs_visitor.py +38 -0
  99. pyopenapi_gen/visit/endpoint/__init__.py +1 -0
  100. pyopenapi_gen/visit/endpoint/endpoint_visitor.py +103 -0
  101. pyopenapi_gen/visit/endpoint/generators/__init__.py +1 -0
  102. pyopenapi_gen/visit/endpoint/generators/docstring_generator.py +121 -0
  103. pyopenapi_gen/visit/endpoint/generators/endpoint_method_generator.py +87 -0
  104. pyopenapi_gen/visit/endpoint/generators/request_generator.py +103 -0
  105. pyopenapi_gen/visit/endpoint/generators/response_handler_generator.py +497 -0
  106. pyopenapi_gen/visit/endpoint/generators/signature_generator.py +88 -0
  107. pyopenapi_gen/visit/endpoint/generators/url_args_generator.py +183 -0
  108. pyopenapi_gen/visit/endpoint/processors/__init__.py +1 -0
  109. pyopenapi_gen/visit/endpoint/processors/import_analyzer.py +76 -0
  110. pyopenapi_gen/visit/endpoint/processors/parameter_processor.py +171 -0
  111. pyopenapi_gen/visit/exception_visitor.py +52 -0
  112. pyopenapi_gen/visit/model/__init__.py +0 -0
  113. pyopenapi_gen/visit/model/alias_generator.py +89 -0
  114. pyopenapi_gen/visit/model/dataclass_generator.py +197 -0
  115. pyopenapi_gen/visit/model/enum_generator.py +200 -0
  116. pyopenapi_gen/visit/model/model_visitor.py +197 -0
  117. pyopenapi_gen/visit/visitor.py +97 -0
  118. pyopenapi_gen-0.8.3.dist-info/METADATA +224 -0
  119. pyopenapi_gen-0.8.3.dist-info/RECORD +122 -0
  120. pyopenapi_gen-0.8.3.dist-info/WHEEL +4 -0
  121. pyopenapi_gen-0.8.3.dist-info/entry_points.txt +2 -0
  122. pyopenapi_gen-0.8.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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
+ )