pyopenapi-gen 2.7.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. pyopenapi_gen/__init__.py +224 -0
  2. pyopenapi_gen/__main__.py +6 -0
  3. pyopenapi_gen/cli.py +62 -0
  4. pyopenapi_gen/context/CLAUDE.md +284 -0
  5. pyopenapi_gen/context/file_manager.py +52 -0
  6. pyopenapi_gen/context/import_collector.py +382 -0
  7. pyopenapi_gen/context/render_context.py +726 -0
  8. pyopenapi_gen/core/CLAUDE.md +224 -0
  9. pyopenapi_gen/core/__init__.py +0 -0
  10. pyopenapi_gen/core/auth/base.py +22 -0
  11. pyopenapi_gen/core/auth/plugins.py +89 -0
  12. pyopenapi_gen/core/cattrs_converter.py +810 -0
  13. pyopenapi_gen/core/exceptions.py +20 -0
  14. pyopenapi_gen/core/http_status_codes.py +218 -0
  15. pyopenapi_gen/core/http_transport.py +222 -0
  16. pyopenapi_gen/core/loader/__init__.py +12 -0
  17. pyopenapi_gen/core/loader/loader.py +174 -0
  18. pyopenapi_gen/core/loader/operations/__init__.py +12 -0
  19. pyopenapi_gen/core/loader/operations/parser.py +161 -0
  20. pyopenapi_gen/core/loader/operations/post_processor.py +62 -0
  21. pyopenapi_gen/core/loader/operations/request_body.py +90 -0
  22. pyopenapi_gen/core/loader/parameters/__init__.py +10 -0
  23. pyopenapi_gen/core/loader/parameters/parser.py +186 -0
  24. pyopenapi_gen/core/loader/responses/__init__.py +10 -0
  25. pyopenapi_gen/core/loader/responses/parser.py +111 -0
  26. pyopenapi_gen/core/loader/schemas/__init__.py +11 -0
  27. pyopenapi_gen/core/loader/schemas/extractor.py +275 -0
  28. pyopenapi_gen/core/pagination.py +64 -0
  29. pyopenapi_gen/core/parsing/__init__.py +13 -0
  30. pyopenapi_gen/core/parsing/common/__init__.py +1 -0
  31. pyopenapi_gen/core/parsing/common/ref_resolution/__init__.py +9 -0
  32. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/__init__.py +0 -0
  33. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/cyclic_properties.py +66 -0
  34. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/direct_cycle.py +33 -0
  35. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/existing_schema.py +22 -0
  36. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/list_response.py +54 -0
  37. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/missing_ref.py +52 -0
  38. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/new_schema.py +50 -0
  39. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/stripped_suffix.py +51 -0
  40. pyopenapi_gen/core/parsing/common/ref_resolution/resolve_schema_ref.py +86 -0
  41. pyopenapi_gen/core/parsing/common/type_parser.py +73 -0
  42. pyopenapi_gen/core/parsing/context.py +187 -0
  43. pyopenapi_gen/core/parsing/cycle_helpers.py +126 -0
  44. pyopenapi_gen/core/parsing/keywords/__init__.py +1 -0
  45. pyopenapi_gen/core/parsing/keywords/all_of_parser.py +81 -0
  46. pyopenapi_gen/core/parsing/keywords/any_of_parser.py +84 -0
  47. pyopenapi_gen/core/parsing/keywords/array_items_parser.py +72 -0
  48. pyopenapi_gen/core/parsing/keywords/one_of_parser.py +77 -0
  49. pyopenapi_gen/core/parsing/keywords/properties_parser.py +98 -0
  50. pyopenapi_gen/core/parsing/schema_finalizer.py +169 -0
  51. pyopenapi_gen/core/parsing/schema_parser.py +804 -0
  52. pyopenapi_gen/core/parsing/transformers/__init__.py +0 -0
  53. pyopenapi_gen/core/parsing/transformers/inline_enum_extractor.py +285 -0
  54. pyopenapi_gen/core/parsing/transformers/inline_object_promoter.py +120 -0
  55. pyopenapi_gen/core/parsing/unified_cycle_detection.py +293 -0
  56. pyopenapi_gen/core/postprocess_manager.py +260 -0
  57. pyopenapi_gen/core/spec_fetcher.py +148 -0
  58. pyopenapi_gen/core/streaming_helpers.py +84 -0
  59. pyopenapi_gen/core/telemetry.py +69 -0
  60. pyopenapi_gen/core/utils.py +456 -0
  61. pyopenapi_gen/core/warning_collector.py +83 -0
  62. pyopenapi_gen/core/writers/code_writer.py +135 -0
  63. pyopenapi_gen/core/writers/documentation_writer.py +222 -0
  64. pyopenapi_gen/core/writers/line_writer.py +217 -0
  65. pyopenapi_gen/core/writers/python_construct_renderer.py +321 -0
  66. pyopenapi_gen/core_package_template/README.md +21 -0
  67. pyopenapi_gen/emit/models_emitter.py +143 -0
  68. pyopenapi_gen/emitters/CLAUDE.md +286 -0
  69. pyopenapi_gen/emitters/client_emitter.py +51 -0
  70. pyopenapi_gen/emitters/core_emitter.py +181 -0
  71. pyopenapi_gen/emitters/docs_emitter.py +44 -0
  72. pyopenapi_gen/emitters/endpoints_emitter.py +247 -0
  73. pyopenapi_gen/emitters/exceptions_emitter.py +187 -0
  74. pyopenapi_gen/emitters/mocks_emitter.py +185 -0
  75. pyopenapi_gen/emitters/models_emitter.py +426 -0
  76. pyopenapi_gen/generator/CLAUDE.md +352 -0
  77. pyopenapi_gen/generator/client_generator.py +567 -0
  78. pyopenapi_gen/generator/exceptions.py +7 -0
  79. pyopenapi_gen/helpers/CLAUDE.md +325 -0
  80. pyopenapi_gen/helpers/__init__.py +1 -0
  81. pyopenapi_gen/helpers/endpoint_utils.py +532 -0
  82. pyopenapi_gen/helpers/type_cleaner.py +334 -0
  83. pyopenapi_gen/helpers/type_helper.py +112 -0
  84. pyopenapi_gen/helpers/type_resolution/__init__.py +1 -0
  85. pyopenapi_gen/helpers/type_resolution/array_resolver.py +57 -0
  86. pyopenapi_gen/helpers/type_resolution/composition_resolver.py +79 -0
  87. pyopenapi_gen/helpers/type_resolution/finalizer.py +105 -0
  88. pyopenapi_gen/helpers/type_resolution/named_resolver.py +172 -0
  89. pyopenapi_gen/helpers/type_resolution/object_resolver.py +216 -0
  90. pyopenapi_gen/helpers/type_resolution/primitive_resolver.py +109 -0
  91. pyopenapi_gen/helpers/type_resolution/resolver.py +47 -0
  92. pyopenapi_gen/helpers/url_utils.py +14 -0
  93. pyopenapi_gen/http_types.py +20 -0
  94. pyopenapi_gen/ir.py +165 -0
  95. pyopenapi_gen/py.typed +1 -0
  96. pyopenapi_gen/types/CLAUDE.md +140 -0
  97. pyopenapi_gen/types/__init__.py +11 -0
  98. pyopenapi_gen/types/contracts/__init__.py +13 -0
  99. pyopenapi_gen/types/contracts/protocols.py +106 -0
  100. pyopenapi_gen/types/contracts/types.py +28 -0
  101. pyopenapi_gen/types/resolvers/__init__.py +7 -0
  102. pyopenapi_gen/types/resolvers/reference_resolver.py +71 -0
  103. pyopenapi_gen/types/resolvers/response_resolver.py +177 -0
  104. pyopenapi_gen/types/resolvers/schema_resolver.py +498 -0
  105. pyopenapi_gen/types/services/__init__.py +5 -0
  106. pyopenapi_gen/types/services/type_service.py +165 -0
  107. pyopenapi_gen/types/strategies/__init__.py +5 -0
  108. pyopenapi_gen/types/strategies/response_strategy.py +310 -0
  109. pyopenapi_gen/visit/CLAUDE.md +272 -0
  110. pyopenapi_gen/visit/client_visitor.py +477 -0
  111. pyopenapi_gen/visit/docs_visitor.py +38 -0
  112. pyopenapi_gen/visit/endpoint/__init__.py +1 -0
  113. pyopenapi_gen/visit/endpoint/endpoint_visitor.py +292 -0
  114. pyopenapi_gen/visit/endpoint/generators/__init__.py +1 -0
  115. pyopenapi_gen/visit/endpoint/generators/docstring_generator.py +123 -0
  116. pyopenapi_gen/visit/endpoint/generators/endpoint_method_generator.py +222 -0
  117. pyopenapi_gen/visit/endpoint/generators/mock_generator.py +140 -0
  118. pyopenapi_gen/visit/endpoint/generators/overload_generator.py +252 -0
  119. pyopenapi_gen/visit/endpoint/generators/request_generator.py +103 -0
  120. pyopenapi_gen/visit/endpoint/generators/response_handler_generator.py +705 -0
  121. pyopenapi_gen/visit/endpoint/generators/signature_generator.py +83 -0
  122. pyopenapi_gen/visit/endpoint/generators/url_args_generator.py +207 -0
  123. pyopenapi_gen/visit/endpoint/processors/__init__.py +1 -0
  124. pyopenapi_gen/visit/endpoint/processors/import_analyzer.py +78 -0
  125. pyopenapi_gen/visit/endpoint/processors/parameter_processor.py +171 -0
  126. pyopenapi_gen/visit/exception_visitor.py +90 -0
  127. pyopenapi_gen/visit/model/__init__.py +0 -0
  128. pyopenapi_gen/visit/model/alias_generator.py +93 -0
  129. pyopenapi_gen/visit/model/dataclass_generator.py +553 -0
  130. pyopenapi_gen/visit/model/enum_generator.py +212 -0
  131. pyopenapi_gen/visit/model/model_visitor.py +198 -0
  132. pyopenapi_gen/visit/visitor.py +97 -0
  133. pyopenapi_gen-2.7.2.dist-info/METADATA +1169 -0
  134. pyopenapi_gen-2.7.2.dist-info/RECORD +137 -0
  135. pyopenapi_gen-2.7.2.dist-info/WHEEL +4 -0
  136. pyopenapi_gen-2.7.2.dist-info/entry_points.txt +2 -0
  137. pyopenapi_gen-2.7.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,105 @@
1
+ """Finalizes and cleans Python type strings."""
2
+
3
+ import logging
4
+
5
+ from pyopenapi_gen import IRSchema
6
+ from pyopenapi_gen.context.render_context import RenderContext
7
+ from pyopenapi_gen.helpers.type_cleaner import TypeCleaner
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class TypeFinalizer:
13
+ """Handles final wrapping (Optional) and cleaning of type strings."""
14
+
15
+ def __init__(self, context: RenderContext, all_schemas: dict[str, IRSchema] | None = None):
16
+ self.context = context
17
+ self.all_schemas = all_schemas if all_schemas is not None else {}
18
+
19
+ def finalize(self, py_type: str | None, schema: IRSchema, required: bool) -> str:
20
+ """Wraps with Optional if needed, cleans the type string, and ensures typing imports."""
21
+ if py_type is None:
22
+ logger.warning(
23
+ f"[TypeFinalizer] Received None as py_type for schema "
24
+ f"'{schema.name or 'anonymous'}'. Defaulting to 'Any'."
25
+ )
26
+ self.context.add_import("typing", "Any")
27
+ py_type = "Any"
28
+
29
+ # CRITICAL: Clean BEFORE wrapping to prevent TypeCleaner from breaking "Union[X] | None" patterns
30
+ cleaned_type = self._clean_type(py_type)
31
+ optional_type = self._wrap_with_optional_if_needed(cleaned_type, schema, required)
32
+
33
+ # Ensure imports for common typing constructs that might have been introduced by cleaning or wrapping
34
+ final_type = optional_type # Use the wrapped type for import analysis
35
+ if "dict[" in final_type or final_type == "Dict":
36
+ self.context.add_import("typing", "Dict")
37
+ if "List[" in final_type or final_type == "List":
38
+ self.context.add_import("typing", "List")
39
+ if "Tuple[" in final_type or final_type == "Tuple": # Tuple might also appear bare
40
+ self.context.add_import("typing", "Tuple")
41
+ if "Union[" in final_type:
42
+ self.context.add_import("typing", "Union")
43
+ # Optional is now handled entirely by _wrap_with_optional_if_needed and not here
44
+ if final_type == "Any" or final_type == "Any | None": # Ensure Any is imported if it's the final type
45
+ self.context.add_import("typing", "Any")
46
+
47
+ return final_type
48
+
49
+ def _wrap_with_optional_if_needed(self, py_type: str, schema_being_wrapped: IRSchema, required: bool) -> str:
50
+ """Wraps the Python type string with `... | None` if necessary.
51
+
52
+ Note: Modern Python 3.10+ uses X | None syntax exclusively.
53
+ Optional[X] should NEVER appear here - our unified type system generates X | None directly.
54
+ """
55
+ is_considered_optional_by_usage = not required or schema_being_wrapped.is_nullable is True
56
+
57
+ if not is_considered_optional_by_usage:
58
+ return py_type # Not optional by usage, so don't wrap.
59
+
60
+ # At this point, usage implies optional. Now check if py_type inherently is.
61
+
62
+ if py_type == "Any":
63
+ # Modern Python 3.10+ doesn't need Optional import for | None syntax
64
+ return "Any | None" # Any is special, always wrap if usage is optional.
65
+
66
+ # SANITY CHECK: Unified type system should never produce Optional[X]
67
+ if py_type.startswith("Optional[") and py_type.endswith("]"):
68
+ logger.error(
69
+ f"❌ ARCHITECTURE VIOLATION: Received legacy Optional[X] type: {py_type}. "
70
+ f"This should NEVER happen - unified type system generates X | None directly. "
71
+ f"Schema: {schema_being_wrapped.name or 'anonymous'}. "
72
+ f"This indicates a bug in the type resolution pipeline."
73
+ )
74
+ # Defensive conversion (but this indicates a serious bug upstream)
75
+ inner_type = py_type[9:-1] # Remove "Optional[" and "]"
76
+ logger.warning(f"⚠️ Converted to modern syntax: {inner_type} | None")
77
+ return f"{inner_type} | None"
78
+
79
+ # If already has | None (modern style), don't add again
80
+ if " | None" in py_type or py_type.endswith("| None"):
81
+ return py_type # Already has | None union syntax.
82
+
83
+ is_union_with_none = "Union[" in py_type and (
84
+ ", None]" in py_type or "[None," in py_type or ", None," in py_type or py_type == "Union[None]"
85
+ )
86
+ if is_union_with_none:
87
+ return py_type # Already a Union with None.
88
+
89
+ # New check: if py_type refers to a named schema that IS ITSELF nullable,
90
+ # its alias definition (if it's an alias) or its usage as a dataclass field type
91
+ # will effectively be Optional. So, if field usage is optional, we don't ADD another Optional layer.
92
+ if py_type in self.all_schemas: # Check if py_type is a known schema name
93
+ referenced_schema = self.all_schemas[py_type]
94
+ # If the schema being referenced is itself nullable, its definition (if alias)
95
+ # or its direct usage (if dataclass) will incorporate Optional via the resolver calling this finalizer.
96
+ # Thus, we avoid double-wrapping if the *usage* of this type is also optional.
97
+ if referenced_schema.is_nullable:
98
+ return py_type
99
+
100
+ # Wrap type with modern | None syntax (no Optional import needed in Python 3.10+)
101
+ return f"{py_type} | None"
102
+
103
+ def _clean_type(self, type_str: str) -> str:
104
+ """Cleans a Python type string using TypeCleaner."""
105
+ return TypeCleaner.clean_type_parameters(type_str)
@@ -0,0 +1,172 @@
1
+ """Resolves IRSchema to Python named types (classes, enums)."""
2
+
3
+ import logging
4
+ import os
5
+
6
+ from pyopenapi_gen import IRSchema
7
+ from pyopenapi_gen.context.render_context import RenderContext
8
+ from pyopenapi_gen.core.utils import NameSanitizer
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class NamedTypeResolver:
14
+ """Resolves IRSchema instances that refer to named models/enums."""
15
+
16
+ def __init__(self, context: RenderContext, all_schemas: dict[str, IRSchema]):
17
+ self.context = context
18
+ self.all_schemas = all_schemas
19
+
20
+ def _is_self_reference(self, target_module_name: str, target_class_name: str) -> bool:
21
+ """Check if the target class is the same as the one currently being generated."""
22
+ if not self.context.current_file:
23
+ return False
24
+
25
+ # Extract current module name from the file path
26
+ current_file_name = self.context.current_file
27
+ current_module_name = os.path.splitext(os.path.basename(current_file_name))[0]
28
+
29
+ # For self-reference detection, we need to check if:
30
+ # 1. The target module name matches the current module name
31
+ # 2. The target class name is likely the class being defined in this file
32
+ #
33
+ # The target_class_name should match the class being generated in the current file
34
+ # For example, if we're in tree_node.py generating TreeNode class, and we're trying
35
+ # to reference TreeNode, then this is a self-reference
36
+ return current_module_name == target_module_name and self._class_being_generated_matches(target_class_name)
37
+
38
+ def _class_being_generated_matches(self, target_class_name: str) -> bool:
39
+ """Check if the target class name matches what's being generated in the current file."""
40
+ # This is a simple heuristic: if the file is tree_node.py, we expect TreeNode class
41
+ # More sophisticated logic could be added by tracking what class is currently being generated
42
+ if not self.context.current_file:
43
+ return False
44
+
45
+ current_file_name = self.context.current_file
46
+ current_module_name = os.path.splitext(os.path.basename(current_file_name))[0]
47
+
48
+ # Convert module name to expected class name (snake_case to PascalCase)
49
+ expected_class_name = self._module_name_to_class_name(current_module_name)
50
+
51
+ return target_class_name == expected_class_name
52
+
53
+ def _module_name_to_class_name(self, module_name: str) -> str:
54
+ """Convert module name (snake_case) to class name (PascalCase)."""
55
+ # Convert snake_case to PascalCase
56
+ # tree_node -> TreeNode
57
+ # message -> Message
58
+ parts = module_name.split("_")
59
+ return "".join(word.capitalize() for word in parts)
60
+
61
+ def resolve(self, schema: IRSchema, resolve_alias_target: bool = False) -> str | None:
62
+ """
63
+ Resolves an IRSchema that refers to a named model/enum, or an inline named enum.
64
+
65
+ Args:
66
+ schema: The IRSchema to resolve.
67
+ resolve_alias_target: If true, the resolver should return the Python type string for the
68
+ *target* of an alias. If false, it should return the alias name itself.
69
+
70
+ Returns:
71
+ A Python type string for the resolved schema, e.g., "MyModel", "MyModel | None".
72
+ """
73
+
74
+ if schema.name and schema.name in self.all_schemas:
75
+ # This schema is a REFERENCE to a globally defined schema (e.g., in components/schemas)
76
+ ref_schema = self.all_schemas[schema.name] # Get the actual definition
77
+ if ref_schema.name is None:
78
+ raise RuntimeError(f"Schema '{schema.name}' resolved to ref_schema with None name.")
79
+
80
+ # NEW: Use generation_name and final_module_stem from the referenced schema
81
+ if ref_schema.generation_name is None:
82
+ raise RuntimeError(f"Referenced schema '{ref_schema.name}' must have generation_name set.")
83
+ if ref_schema.final_module_stem is None:
84
+ raise RuntimeError(f"Referenced schema '{ref_schema.name}' must have final_module_stem set.")
85
+
86
+ class_name_for_ref = ref_schema.generation_name
87
+ module_name_for_ref = ref_schema.final_module_stem
88
+
89
+ model_module_path_for_ref = (
90
+ f"{self.context.get_current_package_name_for_generated_code()}.models.{module_name_for_ref}"
91
+ )
92
+
93
+ key_to_check = model_module_path_for_ref
94
+ name_to_add = class_name_for_ref
95
+
96
+ if not resolve_alias_target:
97
+ # Check for self-reference: if we're generating the same class that we're trying to import
98
+ is_self_reference = self._is_self_reference(module_name_for_ref, class_name_for_ref)
99
+
100
+ if is_self_reference:
101
+ # For self-references, don't add import and return quoted type name
102
+ return f'"{name_to_add}"'
103
+ else:
104
+ # For external references, add import and return unquoted type name
105
+ self.context.add_import(logical_module=key_to_check, name=name_to_add)
106
+ return name_to_add
107
+ else:
108
+ # self.resolve_alias_target is TRUE. We are trying to find the *actual underlying type*
109
+ # of 'ref_schema' for use in an alias definition (e.g., MyStringAlias: TypeAlias = str).
110
+ # Check if ref_schema is structurally a simple alias (no properties, enum, composition)
111
+ is_structurally_simple_alias = not (
112
+ ref_schema.properties
113
+ or ref_schema.enum
114
+ or ref_schema.any_of
115
+ or ref_schema.one_of
116
+ or ref_schema.all_of
117
+ )
118
+
119
+ if is_structurally_simple_alias:
120
+ # It's an alias to a primitive, array, or simple object.
121
+ # We need to return the Python type of its target.
122
+ # For this, we delegate back to the main resolver, but on ref_schema's definition,
123
+ # and crucially, with resolve_alias_target=False for that sub-call to avoid loops
124
+ # and to get the structural type.
125
+ # Also, treat ref_schema as anonymous for this sub-resolution so it's purely structural.
126
+
127
+ # Construct a temporary schema that is like ref_schema but anonymous
128
+ # to force structural resolution by the main resolver.
129
+ # This is a bit of a workaround for not having direct access to other resolvers here.
130
+ # A better design might involve passing the main SchemaTypeResolver instance.
131
+ # For now, returning None effectively tells TypeHelper to do this.
132
+
133
+ return None # Signal to TypeHelper to resolve ref_schema structurally.
134
+ else:
135
+ # ref_schema is NOT structurally alias-like (e.g., it's a full object schema).
136
+ # If we are resolving an alias target, and the target is a full object schema,
137
+ # the "target type" IS that object schema's name.
138
+ # e.g. MyDataAlias = DataObject. Here, DataObject is the target.
139
+ # The AliasGenerator will then generate "MyDataAlias: TypeAlias = DataObject".
140
+ # It needs "DataObject" as the string.
141
+ # The import for DataObject will be handled by TypeHelper when generating that alias
142
+ # file itself, using the regular non-alias-target path.
143
+
144
+ self.context.add_import(logical_module=key_to_check, name=name_to_add)
145
+ return name_to_add # Return name_to_add
146
+
147
+ elif schema.enum:
148
+ # This is an INLINE enum definition (not a reference to a global enum)
149
+ enum_name: str | None = None
150
+ if schema.name: # If the inline enum has a name, it will be generated as a named enum class
151
+ enum_name = NameSanitizer.sanitize_class_name(schema.name)
152
+ module_name = NameSanitizer.sanitize_module_name(schema.name)
153
+ model_module_path = f"{self.context.get_current_package_name_for_generated_code()}.models.{module_name}"
154
+ self.context.add_import(logical_module=model_module_path, name=enum_name)
155
+ return enum_name
156
+ else: # Inline anonymous enum, falls back to primitive type of its values
157
+ # (Handled by PrimitiveTypeResolver if this returns None or specific primitive)
158
+ # For now, this path might lead to PrimitiveTypeResolver via TypeHelper's main loop.
159
+ # Let's try to return the primitive type directly if possible.
160
+ primitive_type_of_enum = "str" # Default for enums if type not specified
161
+ if schema.type == "integer":
162
+ primitive_type_of_enum = "int"
163
+ elif schema.type == "number":
164
+ primitive_type_of_enum = "float"
165
+ # other types for enums are unusual.
166
+ return primitive_type_of_enum
167
+ else:
168
+ # Not a reference to a known schema, and not an inline enum.
169
+ # This could be an anonymous complex type, or an unresolved reference.
170
+ # Defer to other resolvers by returning None.
171
+
172
+ return None
@@ -0,0 +1,216 @@
1
+ """Resolves IRSchema to Python object types (classes, dicts)."""
2
+
3
+ import logging
4
+ import os
5
+ from typing import TYPE_CHECKING
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
+
11
+ if TYPE_CHECKING:
12
+ from .resolver import SchemaTypeResolver # Avoid circular import
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class ObjectTypeResolver:
18
+ """Resolves IRSchema instances of type 'object'."""
19
+
20
+ def __init__(self, context: RenderContext, all_schemas: dict[str, IRSchema], main_resolver: "SchemaTypeResolver"):
21
+ self.context = context
22
+ self.all_schemas = all_schemas
23
+ self.main_resolver = main_resolver # For resolving nested types
24
+
25
+ def _promote_anonymous_object_schema_if_needed(
26
+ self,
27
+ schema_to_promote: IRSchema,
28
+ proposed_name_base: str | None,
29
+ ) -> str | None:
30
+ """Gives a name to an anonymous object schema and registers it."""
31
+ if not proposed_name_base:
32
+ return None
33
+
34
+ class_name_base = NameSanitizer.sanitize_class_name(proposed_name_base)
35
+ # Suffix logic can be refined here if needed (e.g. Property vs Item)
36
+ potential_new_name = f"{class_name_base}Item"
37
+ counter = 1
38
+ final_new_name = potential_new_name
39
+ while final_new_name in self.all_schemas:
40
+ final_new_name = f"{potential_new_name}{counter}"
41
+ counter += 1
42
+ if counter > 10: # Safety break
43
+ logger.error(
44
+ f"[ObjectTypeResolver._promote] Could not find unique name "
45
+ f"for base '{potential_new_name}' after 10 tries."
46
+ )
47
+ return None
48
+
49
+ schema_to_promote.name = final_new_name # Assign the new name
50
+ # Set generation_name and final_module_stem after the new name is assigned
51
+ schema_to_promote.generation_name = NameSanitizer.sanitize_class_name(final_new_name)
52
+ schema_to_promote.final_module_stem = NameSanitizer.sanitize_module_name(final_new_name)
53
+
54
+ self.all_schemas[final_new_name] = schema_to_promote # Register in global schemas
55
+
56
+ # Add import for this newly named model
57
+ module_to_import_from = f"models.{NameSanitizer.sanitize_module_name(final_new_name)}"
58
+ self.context.add_import(module_to_import_from, final_new_name)
59
+ return final_new_name
60
+
61
+ def resolve(self, schema: IRSchema, parent_schema_name_for_anon_promotion: str | None = None) -> str | None:
62
+ """
63
+ Resolves an IRSchema of `type: "object"`.
64
+ Args:
65
+ schema: The IRSchema, expected to have `type: "object"`.
66
+ parent_schema_name_for_anon_promotion: Contextual name for promoting anonymous objects.
67
+ Returns:
68
+ A Python type string or None.
69
+ """
70
+ if schema.type == "object":
71
+ # Path A: additionalProperties is True (boolean)
72
+ if isinstance(schema.additional_properties, bool) and schema.additional_properties:
73
+ self.context.add_import("typing", "Dict")
74
+ self.context.add_import("typing", "Any")
75
+ return "dict[str, Any]"
76
+
77
+ # Path B: additionalProperties is an IRSchema instance
78
+ if isinstance(schema.additional_properties, IRSchema):
79
+ ap_schema_instance = schema.additional_properties
80
+ is_ap_schema_defined = (
81
+ ap_schema_instance.type is not None
82
+ or ap_schema_instance.format is not None
83
+ or ap_schema_instance.properties
84
+ or ap_schema_instance.items
85
+ or ap_schema_instance.enum
86
+ or ap_schema_instance.any_of
87
+ or ap_schema_instance.one_of
88
+ or ap_schema_instance.all_of
89
+ )
90
+ if is_ap_schema_defined:
91
+ additional_prop_type = self.main_resolver.resolve(ap_schema_instance, required=True)
92
+ self.context.add_import("typing", "Dict")
93
+ return f"dict[str, {additional_prop_type}]"
94
+
95
+ # Path C: additionalProperties is False, None, or empty IRSchema.
96
+ if schema.properties: # Object has its own properties
97
+ if not schema.name: # Anonymous object with properties
98
+ if parent_schema_name_for_anon_promotion:
99
+ promoted_name = self._promote_anonymous_object_schema_if_needed(
100
+ schema, parent_schema_name_for_anon_promotion
101
+ )
102
+ if promoted_name:
103
+ return promoted_name
104
+ # Fallback for unpromoted anonymous object with properties
105
+ logger.warning(
106
+ f"[ObjectTypeResolver] Anonymous object with properties not promoted. "
107
+ f"-> dict[str, Any]. Schema: {schema}"
108
+ )
109
+ self.context.add_import("typing", "Dict")
110
+ self.context.add_import("typing", "Any")
111
+ return "dict[str, Any]"
112
+ else: # Named object with properties
113
+ # If this named object is a component schema, ensure it's imported.
114
+ if schema.name and schema.name in self.all_schemas:
115
+ actual_schema_def = self.all_schemas[schema.name]
116
+ if actual_schema_def.generation_name is None:
117
+ raise RuntimeError(
118
+ f"Actual schema '{actual_schema_def.name}' for '{schema.name}' must have generation_name."
119
+ )
120
+ if actual_schema_def.final_module_stem is None:
121
+ raise RuntimeError(
122
+ f"Actual schema '{actual_schema_def.name}' for '{schema.name}' must have final_module_stem."
123
+ )
124
+
125
+ class_name_to_use = actual_schema_def.generation_name
126
+ module_stem_to_use = actual_schema_def.final_module_stem
127
+
128
+ base_model_path_part = f"models.{module_stem_to_use}"
129
+ model_module_path = base_model_path_part
130
+
131
+ if self.context.package_root_for_generated_code and self.context.overall_project_root:
132
+ abs_pkg_root = os.path.abspath(self.context.package_root_for_generated_code)
133
+ abs_overall_root = os.path.abspath(self.context.overall_project_root)
134
+ if abs_pkg_root.startswith(abs_overall_root) and abs_pkg_root != abs_overall_root:
135
+ rel_pkg_path = os.path.relpath(abs_pkg_root, abs_overall_root)
136
+ current_gen_pkg_dot_path = rel_pkg_path.replace(os.sep, ".")
137
+ model_module_path = f"{current_gen_pkg_dot_path}.{base_model_path_part}"
138
+ elif abs_pkg_root == abs_overall_root:
139
+ model_module_path = base_model_path_part
140
+ elif self.context.package_root_for_generated_code:
141
+ current_gen_pkg_name_from_basename = os.path.basename(
142
+ os.path.normpath(self.context.package_root_for_generated_code)
143
+ )
144
+ if current_gen_pkg_name_from_basename and current_gen_pkg_name_from_basename != ".":
145
+ model_module_path = f"{current_gen_pkg_name_from_basename}.{base_model_path_part}"
146
+
147
+ current_module_dot_path = self.context.get_current_module_dot_path()
148
+ if model_module_path != current_module_dot_path: # Avoid self-imports
149
+ self.context.add_import(model_module_path, class_name_to_use)
150
+ return class_name_to_use # Return the potentially de-collided name
151
+ else:
152
+ # This case should ideally not be hit if all named objects are in all_schemas
153
+ # Or, it's a named object that isn't a global component (e.g. inline named object for promotion)
154
+ # For safety, use schema.name if it's not in all_schemas (might be a freshly promoted name)
155
+ class_name_to_use = NameSanitizer.sanitize_class_name(schema.name)
156
+ logger.warning(
157
+ f"[ObjectTypeResolver] Named object '{schema.name}' not in all_schemas, "
158
+ f"using its own name '{class_name_to_use}'. "
159
+ f"This might occur for locally promoted anonymous objects."
160
+ )
161
+ return class_name_to_use
162
+ else: # Object has NO properties
163
+ if (
164
+ schema.name and schema.name in self.all_schemas
165
+ ): # Named object, no properties, AND it's a known component
166
+ actual_schema_def = self.all_schemas[schema.name]
167
+ if actual_schema_def.generation_name is None:
168
+ raise RuntimeError(
169
+ f"Actual schema (no props) '{actual_schema_def.name}' "
170
+ f"for '{schema.name}' must have generation_name."
171
+ )
172
+ if actual_schema_def.final_module_stem is None:
173
+ raise RuntimeError(
174
+ f"Actual schema (no props) '{actual_schema_def.name}' "
175
+ f"for '{schema.name}' must have final_module_stem."
176
+ )
177
+
178
+ class_name_to_use = actual_schema_def.generation_name
179
+ module_stem_to_use = actual_schema_def.final_module_stem
180
+
181
+ base_model_path_part = f"models.{module_stem_to_use}"
182
+ model_module_path = base_model_path_part
183
+
184
+ if self.context.package_root_for_generated_code and self.context.overall_project_root:
185
+ abs_pkg_root = os.path.abspath(self.context.package_root_for_generated_code)
186
+ abs_overall_root = os.path.abspath(self.context.overall_project_root)
187
+ if abs_pkg_root.startswith(abs_overall_root) and abs_pkg_root != abs_overall_root:
188
+ rel_pkg_path = os.path.relpath(abs_pkg_root, abs_overall_root)
189
+ current_gen_pkg_dot_path = rel_pkg_path.replace(os.sep, ".")
190
+ model_module_path = f"{current_gen_pkg_dot_path}.{base_model_path_part}"
191
+ elif abs_pkg_root == abs_overall_root:
192
+ model_module_path = base_model_path_part
193
+ elif self.context.package_root_for_generated_code:
194
+ current_gen_pkg_name_from_basename = os.path.basename(
195
+ os.path.normpath(self.context.package_root_for_generated_code)
196
+ )
197
+ if current_gen_pkg_name_from_basename and current_gen_pkg_name_from_basename != ".":
198
+ model_module_path = f"{current_gen_pkg_name_from_basename}.{base_model_path_part}"
199
+
200
+ current_module_dot_path = self.context.get_current_module_dot_path()
201
+ if model_module_path != current_module_dot_path: # Avoid self-imports
202
+ self.context.add_import(model_module_path, class_name_to_use)
203
+ return class_name_to_use # Return the potentially de-collided name
204
+ elif schema.name: # Named object, no properties, but NOT a known component
205
+ self.context.add_import("typing", "Dict")
206
+ self.context.add_import("typing", "Any")
207
+ return "dict[str, Any]"
208
+ else: # Anonymous object, no properties
209
+ if schema.additional_properties is None: # Default OpenAPI behavior allows additional props
210
+ self.context.add_import("typing", "Dict")
211
+ self.context.add_import("typing", "Any")
212
+ return "dict[str, Any]"
213
+ else: # additionalProperties was False or restrictive empty schema
214
+ self.context.add_import("typing", "Any")
215
+ return "Any"
216
+ return None
@@ -0,0 +1,109 @@
1
+ """Resolves IRSchema to Python primitive types."""
2
+
3
+ import logging
4
+
5
+ from pyopenapi_gen import IRSchema
6
+ from pyopenapi_gen.context.render_context import RenderContext
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class PrimitiveTypeResolver:
12
+ """Resolves IRSchema to Python primitive type strings."""
13
+
14
+ def __init__(self, context: RenderContext):
15
+ self.context = context
16
+
17
+ def resolve(self, schema: IRSchema) -> str | None:
18
+ """
19
+ Resolves an IRSchema to a Python primitive type string based on its 'type' and 'format'.
20
+
21
+ Handles standard OpenAPI types and formats:
22
+ - integer -> "int" (also int32, int64 formats)
23
+ - number -> "float" (also float, double formats)
24
+ - boolean -> "bool"
25
+ - string -> "str"
26
+ - string with format "date-time" -> "datetime" (imports `datetime.datetime`)
27
+ - string with format "date" -> "date" (imports `datetime.date`)
28
+ - string with format "time" -> "time" (imports `datetime.time`)
29
+ - string with format "duration" -> "timedelta" (imports `datetime.timedelta`)
30
+ - string with format "uuid" -> "UUID" (imports `uuid.UUID`)
31
+ - string with format "binary" or "byte" -> "bytes"
32
+ - string with format "ipv4" -> "IPv4Address" (imports `ipaddress.IPv4Address`)
33
+ - string with format "ipv6" -> "IPv6Address" (imports `ipaddress.IPv6Address`)
34
+ - string with format "uri", "url", "email", "hostname", "password" -> "str"
35
+ - null -> "None" (the string literal "None")
36
+
37
+ Args:
38
+ schema: The IRSchema to resolve.
39
+
40
+ Returns:
41
+ The Python primitive type string if the schema matches a known primitive type/format,
42
+ otherwise None.
43
+ """
44
+ if schema.type == "null":
45
+ return "None"
46
+
47
+ # Handle string formats first (before falling through to generic string)
48
+ if schema.type == "string" and schema.format:
49
+ return self._resolve_string_format(schema.format)
50
+
51
+ # Handle integer formats
52
+ if schema.type == "integer":
53
+ # int32 and int64 both map to Python int
54
+ return "int"
55
+
56
+ # Handle number formats
57
+ if schema.type == "number":
58
+ # float and double both map to Python float
59
+ return "float"
60
+
61
+ # Handle remaining primitive types
62
+ primitive_type_map = {
63
+ "boolean": "bool",
64
+ "string": "str",
65
+ }
66
+ if schema.type in primitive_type_map:
67
+ return primitive_type_map[schema.type]
68
+
69
+ return None
70
+
71
+ def _resolve_string_format(self, format_value: str) -> str:
72
+ """Resolve string type with specific format to Python type."""
73
+ # Date/time formats
74
+ if format_value == "date-time":
75
+ self.context.add_import("datetime", "datetime")
76
+ return "datetime"
77
+ if format_value == "date":
78
+ self.context.add_import("datetime", "date")
79
+ return "date"
80
+ if format_value == "time":
81
+ self.context.add_import("datetime", "time")
82
+ return "time"
83
+ if format_value == "duration":
84
+ self.context.add_import("datetime", "timedelta")
85
+ return "timedelta"
86
+
87
+ # UUID format
88
+ if format_value == "uuid":
89
+ self.context.add_import("uuid", "UUID")
90
+ return "UUID"
91
+
92
+ # Binary formats
93
+ if format_value in ("binary", "byte"):
94
+ return "bytes"
95
+
96
+ # IP address formats
97
+ if format_value == "ipv4":
98
+ self.context.add_import("ipaddress", "IPv4Address")
99
+ return "IPv4Address"
100
+ if format_value == "ipv6":
101
+ self.context.add_import("ipaddress", "IPv6Address")
102
+ return "IPv6Address"
103
+
104
+ # String-based formats (no special Python type, just str)
105
+ if format_value in ("uri", "url", "email", "hostname", "password"):
106
+ return "str"
107
+
108
+ # Unknown format - fall back to str
109
+ return "str"
@@ -0,0 +1,47 @@
1
+ """Orchestrates IRSchema to Python type resolution."""
2
+
3
+ import logging
4
+
5
+ from pyopenapi_gen import IRSchema
6
+ from pyopenapi_gen.context.render_context import RenderContext
7
+ from pyopenapi_gen.types.services.type_service import UnifiedTypeService
8
+
9
+ from .array_resolver import ArrayTypeResolver
10
+ from .composition_resolver import CompositionTypeResolver
11
+ from .finalizer import TypeFinalizer
12
+ from .named_resolver import NamedTypeResolver
13
+ from .object_resolver import ObjectTypeResolver
14
+ from .primitive_resolver import PrimitiveTypeResolver
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class SchemaTypeResolver:
20
+ """Orchestrates the resolution of IRSchema to Python type strings."""
21
+
22
+ def __init__(self, context: RenderContext, all_schemas: dict[str, IRSchema]):
23
+ self.context = context
24
+ self.all_schemas = all_schemas
25
+
26
+ # Initialize specialized resolvers, passing self for circular dependencies if needed
27
+ self.primitive_resolver = PrimitiveTypeResolver(context)
28
+ self.named_resolver = NamedTypeResolver(context, all_schemas)
29
+ self.array_resolver = ArrayTypeResolver(context, all_schemas, self)
30
+ self.object_resolver = ObjectTypeResolver(context, all_schemas, self)
31
+ self.composition_resolver = CompositionTypeResolver(context, all_schemas, self)
32
+ self.finalizer = TypeFinalizer(context, self.all_schemas)
33
+
34
+ def resolve(
35
+ self,
36
+ schema: IRSchema,
37
+ required: bool = True,
38
+ resolve_alias_target: bool = False,
39
+ current_schema_context_name: str | None = None,
40
+ ) -> str:
41
+ """
42
+ Determines the Python type string for a given IRSchema.
43
+ Now delegates to the UnifiedTypeService for consistent type resolution.
44
+ """
45
+ # Delegate to the unified type service for all type resolution
46
+ type_service = UnifiedTypeService(self.all_schemas)
47
+ return type_service.resolve_schema_type(schema, self.context, required)
@@ -0,0 +1,14 @@
1
+ """
2
+ Utility for extracting variable names from URL templates.
3
+ """
4
+
5
+ import re
6
+ from typing import Set
7
+
8
+
9
+ def extract_url_variables(url: str) -> Set[str]:
10
+ """
11
+ Extract all variable names (e.g., 'foo' from '{foo}') from a URL template.
12
+ Returns a set of variable names as strings.
13
+ """
14
+ return set(re.findall(r"{([^}]+)}", url))