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,630 @@
1
+ """
2
+ RenderContext: Central context manager for Python code generation.
3
+
4
+ This module provides the RenderContext class, which serves as the central state
5
+ management object during code generation. It tracks imports, generated modules,
6
+ and the current file being processed, ensuring proper relative/absolute import
7
+ handling and maintaining consistent state across the generation process.
8
+ """
9
+
10
+ import logging
11
+ import os
12
+ import re
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Dict, Optional, Set
16
+
17
+ from pyopenapi_gen import IRSchema
18
+ from pyopenapi_gen.core.utils import NameSanitizer
19
+
20
+ from .file_manager import FileManager
21
+ from .import_collector import ImportCollector
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class RenderContext:
27
+ """
28
+ Central context manager for tracking state during code generation.
29
+
30
+ This class serves as the primary state container during code generation,
31
+ managing imports, tracking generated modules, and calculating import paths.
32
+ All imports are stored as absolute (package-root-relative) module paths internally
33
+ and converted to appropriate relative or absolute imports at render time.
34
+
35
+ Attributes:
36
+ file_manager: Utility for writing files to disk
37
+ import_collector: Manages imports for the current file being rendered
38
+ generated_modules: Set of absolute paths to modules generated in this run
39
+ current_file: Absolute path of the file currently being rendered
40
+ core_package_name: The full Python import path of the core package (e.g., "custom_core", "shared.my_core").
41
+ package_root_for_generated_code: Absolute path to the root of the *currently emitting* package
42
+ (e.g., project_root/client_api or project_root/custom_core if emitting core
43
+ itself). Used for calculating relative paths *within* this package.
44
+ overall_project_root: Absolute path to the top-level project.
45
+ Used as the base for resolving absolute Python import paths,
46
+ especially for an external core_package.
47
+ parsed_schemas: Optional dictionary of all parsed IRSchema objects, keyed by their original names.
48
+ conditional_imports: Dictionary of conditional imports (e.g., under TYPE_CHECKING)
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ file_manager: Optional[FileManager] = None,
54
+ core_package_name: str = "core",
55
+ package_root_for_generated_code: Optional[str] = None,
56
+ overall_project_root: Optional[str] = None,
57
+ parsed_schemas: Optional[Dict[str, IRSchema]] = None,
58
+ use_absolute_imports: bool = True,
59
+ output_package_name: Optional[str] = None,
60
+ ) -> None:
61
+ """
62
+ Initialize a new RenderContext.
63
+
64
+ Args:
65
+ file_manager: Utility for file operations (defaults to a new FileManager)
66
+ core_package_name: The full Python import path of the core package (e.g., "custom_core", "shared.my_core").
67
+ package_root_for_generated_code: Absolute path to the root of the *currently emitting* package
68
+ (e.g., project_root/client_api or project_root/custom_core if emitting core
69
+ itself). Used for calculating relative paths *within* this package.
70
+ overall_project_root: Absolute path to the top-level project.
71
+ Used as the base for resolving absolute Python import paths,
72
+ especially for an external core_package.
73
+ parsed_schemas: Optional dictionary of all parsed IRSchema objects.
74
+ use_absolute_imports: Whether to use absolute imports instead of relative imports for internal modules.
75
+ output_package_name: The full output package name (e.g., "pyapis.business") for generating absolute imports.
76
+ """
77
+ self.file_manager = file_manager or FileManager()
78
+ self.import_collector = ImportCollector()
79
+ self.generated_modules: Set[str] = set()
80
+ self.current_file: Optional[str] = None
81
+ self.core_package_name: str = core_package_name
82
+ self.package_root_for_generated_code: Optional[str] = package_root_for_generated_code
83
+ self.overall_project_root: Optional[str] = overall_project_root or os.getcwd()
84
+ self.parsed_schemas: Optional[Dict[str, IRSchema]] = parsed_schemas
85
+ self.use_absolute_imports: bool = use_absolute_imports
86
+ self.output_package_name: Optional[str] = output_package_name
87
+ # Dictionary to store conditional imports, keyed by condition
88
+ self.conditional_imports: Dict[str, Dict[str, Set[str]]] = {}
89
+
90
+ def set_current_file(self, abs_path: str) -> None:
91
+ """
92
+ Set the absolute path of the file currently being rendered.
93
+
94
+ This method also resets the import collector and immediately re-initializes
95
+ its file context for subsequent import additions.
96
+
97
+ Args:
98
+ abs_path: The absolute path of the file to set as current
99
+ """
100
+ self.current_file = abs_path
101
+ # Reset the import collector for each new file to ensure isolation
102
+ self.import_collector.reset()
103
+
104
+ # Immediately set the new context on the import_collector
105
+ current_module_dot_path = self.get_current_module_dot_path()
106
+ package_root_for_collector = self.get_current_package_name_for_generated_code()
107
+
108
+ self.import_collector.set_current_file_context_for_rendering(
109
+ current_module_dot_path=current_module_dot_path,
110
+ package_root=package_root_for_collector,
111
+ core_package_name_for_absolute_treatment=self.core_package_name,
112
+ )
113
+
114
+ def add_import(self, logical_module: str, name: Optional[str] = None, is_typing_import: bool = False) -> None:
115
+ """
116
+ Add an import to the collector.
117
+
118
+ - Core package imports are always absolute using `core_package_name`.
119
+ - Standard library imports are absolute.
120
+ - Other internal package imports are made relative if possible.
121
+ - Unknown modules are treated as absolute external imports.
122
+
123
+ Args:
124
+ logical_module: The logical module path to import from (e.g., "typing",
125
+ "shared_core.http_transport", "generated_client.models.mymodel",
126
+ "some_external_lib.api").
127
+ For internal modules, this should be the fully qualified path from project root.
128
+ name: The name to import from the module
129
+ is_typing_import: Whether the import is a typing import
130
+ """
131
+ if not logical_module:
132
+ return
133
+
134
+ # Fix incomplete module paths for absolute imports
135
+ if self.use_absolute_imports and self.output_package_name:
136
+ # Detect incomplete paths like "business.models.agent" that should be "pyapis.business.models.agent"
137
+ root_package = self.output_package_name.split(".")[0] # "pyapis" from "pyapis.business"
138
+ package_suffix = ".".join(self.output_package_name.split(".")[1:]) # "business" from "pyapis.business"
139
+
140
+ # Check if this is an incomplete internal module path
141
+ if package_suffix and logical_module.startswith(f"{package_suffix}."):
142
+ # This is an incomplete path like "business.models.agent"
143
+ # Convert to complete path like "pyapis.business.models.agent"
144
+ logical_module = f"{root_package}.{logical_module}"
145
+
146
+ # 1. Special handling for typing imports if is_typing_import is True
147
+ if is_typing_import and logical_module == "typing" and name:
148
+ self.import_collector.add_typing_import(name)
149
+ return
150
+
151
+ # 2. Core module import?
152
+ is_target_in_core_pkg_namespace = logical_module == self.core_package_name or logical_module.startswith(
153
+ self.core_package_name + "."
154
+ )
155
+ if is_target_in_core_pkg_namespace:
156
+ if name:
157
+ self.import_collector.add_import(module=logical_module, name=name)
158
+ else:
159
+ self.import_collector.add_plain_import(module=logical_module) # Core plain import
160
+ return
161
+
162
+ # 3. Stdlib/Builtin?
163
+ COMMON_STDLIB = {
164
+ "typing",
165
+ "os",
166
+ "sys",
167
+ "re",
168
+ "json",
169
+ "collections",
170
+ "datetime",
171
+ "enum",
172
+ "pathlib",
173
+ "abc",
174
+ "contextlib",
175
+ "functools",
176
+ "itertools",
177
+ "logging",
178
+ "math",
179
+ "decimal",
180
+ "dataclasses",
181
+ "asyncio",
182
+ "tempfile",
183
+ "subprocess",
184
+ "textwrap",
185
+ }
186
+ top_level_module = logical_module.split(".")[0]
187
+ if (
188
+ logical_module in sys.builtin_module_names
189
+ or logical_module in COMMON_STDLIB
190
+ or top_level_module in COMMON_STDLIB
191
+ ):
192
+ if name:
193
+ self.import_collector.add_import(module=logical_module, name=name)
194
+ else:
195
+ self.import_collector.add_plain_import(module=logical_module) # Stdlib plain import
196
+ return
197
+
198
+ # 4. Known third-party?
199
+ KNOWN_THIRD_PARTY = {"httpx", "pydantic"}
200
+ if logical_module in KNOWN_THIRD_PARTY or top_level_module in KNOWN_THIRD_PARTY:
201
+ if name:
202
+ self.import_collector.add_import(module=logical_module, name=name)
203
+ else:
204
+ self.import_collector.add_plain_import(module=logical_module) # Third-party plain import
205
+ return
206
+
207
+ # 5. Internal to current generated package?
208
+ current_gen_package_name_str = self.get_current_package_name_for_generated_code()
209
+
210
+ is_internal_module_candidate = False # Initialize here
211
+ if current_gen_package_name_str:
212
+ if logical_module == current_gen_package_name_str: # e.g. importing current_gen_package_name_str itself
213
+ is_internal_module_candidate = True
214
+ elif logical_module.startswith(current_gen_package_name_str + "."):
215
+ is_internal_module_candidate = True
216
+
217
+ if is_internal_module_candidate:
218
+ # It looks like an internal module.
219
+ # First, check if it's a direct self-import of the full logical path.
220
+ current_full_module_path = self.get_current_module_dot_path()
221
+ if current_full_module_path == logical_module:
222
+ return # Skip if it's a direct self-import
223
+
224
+ # Determine module path relative to current generated package root
225
+ module_relative_to_gen_pkg_root: str
226
+ if logical_module == current_gen_package_name_str: # Importing the root package itself
227
+ # This case should likely be handled by calculate_relative_path based on current file
228
+ # For now, let's treat it as a root module, and calculate_relative_path will see if it needs dots
229
+ module_relative_to_gen_pkg_root = logical_module # This might be too simplistic for root pkg itself
230
+ elif current_gen_package_name_str: # Should be true due to is_internal_module_candidate
231
+ module_relative_to_gen_pkg_root = logical_module[len(current_gen_package_name_str) + 1 :]
232
+ else: # Should not happen if current_gen_package_name_str was required for is_internal_module_candidate
233
+ module_relative_to_gen_pkg_root = logical_module
234
+
235
+ # For internal modules, try to use relative imports when appropriate
236
+ relative_path = self.calculate_relative_path_for_internal_module(module_relative_to_gen_pkg_root)
237
+
238
+ if relative_path:
239
+ # Use relative imports for internal modules when possible
240
+ if name is None:
241
+ return
242
+ self.import_collector.add_relative_import(relative_path, name)
243
+ return
244
+ else:
245
+ # Fall back to absolute imports for internal modules that can't use relative paths
246
+ if name:
247
+ self.import_collector.add_import(module=logical_module, name=name)
248
+ else:
249
+ self.import_collector.add_plain_import(module=logical_module)
250
+ return
251
+
252
+ # 6. Default: External library, add as absolute.
253
+ if name:
254
+ self.import_collector.add_import(module=logical_module, name=name)
255
+ else:
256
+ # If name is None, it's a plain import like 'import os'
257
+ self.import_collector.add_plain_import(module=logical_module)
258
+
259
+ def mark_generated_module(self, abs_module_path: str) -> None:
260
+ """
261
+ Mark a module as generated in this run.
262
+ This helps in determining if an import is for a locally generated module.
263
+
264
+ Args:
265
+ abs_module_path: The absolute path of the generated module
266
+ """
267
+ self.generated_modules.add(abs_module_path)
268
+
269
+ def add_conditional_import(self, condition: str, module: str, name: str) -> None:
270
+ """
271
+ Add a conditional import (e.g., under TYPE_CHECKING).
272
+
273
+ Args:
274
+ condition: The condition for the import (e.g., "TYPE_CHECKING")
275
+ module: The module to import from
276
+ name: The name to import
277
+ """
278
+ # Apply the same unified import path correction logic as add_import
279
+ logical_module = module
280
+
281
+ # Fix incomplete module paths for absolute imports
282
+ if self.use_absolute_imports and self.output_package_name:
283
+ # Detect incomplete paths like "business.models.agent" that should be "pyapis.business.models.agent"
284
+ root_package = self.output_package_name.split(".")[0] # "pyapis" from "pyapis.business"
285
+ package_suffix = ".".join(self.output_package_name.split(".")[1:]) # "business" from "pyapis.business"
286
+
287
+ # Check if this is an incomplete internal module path
288
+ if package_suffix and logical_module.startswith(f"{package_suffix}."):
289
+ # This is an incomplete path like "business.models.agent"
290
+ # Convert to complete path like "pyapis.business.models.agent"
291
+ logical_module = f"{root_package}.{logical_module}"
292
+
293
+ if condition not in self.conditional_imports:
294
+ self.conditional_imports[condition] = {}
295
+ if logical_module not in self.conditional_imports[condition]:
296
+ self.conditional_imports[condition][logical_module] = set()
297
+ self.conditional_imports[condition][logical_module].add(name)
298
+
299
+ def render_imports(self) -> str:
300
+ """
301
+ Render all imports for the current file, including conditional imports.
302
+
303
+ Returns:
304
+ A string containing all import statements.
305
+ """
306
+ # Get standard imports
307
+ regular_imports = self.import_collector.get_formatted_imports()
308
+
309
+ # Handle conditional imports
310
+ conditional_imports = []
311
+ has_type_checking_imports = False
312
+
313
+ for condition, imports in self.conditional_imports.items():
314
+ if imports:
315
+ # Check if this uses TYPE_CHECKING
316
+ if condition == "TYPE_CHECKING":
317
+ has_type_checking_imports = True
318
+
319
+ # Start the conditional block
320
+ conditional_block = [f"\nif {condition}:"]
321
+
322
+ # Add each import under the condition
323
+ for module, names in sorted(imports.items()):
324
+ names_str = ", ".join(sorted(names))
325
+ conditional_block.append(f" from {module} import {names_str}")
326
+
327
+ conditional_imports.append("\n".join(conditional_block))
328
+
329
+ # Add TYPE_CHECKING import if needed but not already present
330
+ if has_type_checking_imports and not self.import_collector.has_import("typing", "TYPE_CHECKING"):
331
+ self.import_collector.add_typing_import("TYPE_CHECKING")
332
+ # Re-generate regular imports to include TYPE_CHECKING
333
+ regular_imports = self.import_collector.get_formatted_imports()
334
+
335
+ # Combine all imports
336
+ all_imports = regular_imports
337
+ if conditional_imports:
338
+ all_imports += "\n" + "\n".join(conditional_imports)
339
+
340
+ return all_imports
341
+
342
+ def add_typing_imports_for_type(self, type_str: str) -> None:
343
+ """
344
+ Add necessary typing imports for a given type string.
345
+
346
+ Args:
347
+ type_str: The type string to parse for typing imports
348
+ """
349
+ # Handle datetime.date and datetime.datetime explicitly
350
+ # Regex to find "datetime.date" or "datetime.datetime" as whole words
351
+ datetime_specific_matches = re.findall(r"\b(datetime\.(?:date|datetime))\b", type_str)
352
+ for dt_match in datetime_specific_matches:
353
+ module_name, class_name = dt_match.split(".")
354
+ self.add_import(module_name, class_name, is_typing_import=False)
355
+
356
+ # Remove datetime.xxx parts to avoid matching 'date' or 'datetime' as typing members
357
+ type_str_for_typing_search = re.sub(r"\bdatetime\.(?:date|datetime)\b", "", type_str)
358
+
359
+ # General regex for other potential typing names (words)
360
+ all_words_in_type_str = re.findall(r"\b([A-Za-z_][A-Za-z0-9_]*)\b", type_str_for_typing_search)
361
+ potential_names_to_import = set(all_words_in_type_str)
362
+
363
+ # Names that were part of datetime.date or datetime.datetime (e.g., "date", "datetime")
364
+ # These should not be re-imported from typing or as models if already handled by specific datetime import
365
+ handled_datetime_parts = set()
366
+ for dt_match in datetime_specific_matches: # e.g., "datetime.date"
367
+ parts = dt_match.split(".") # ["datetime", "date"]
368
+ handled_datetime_parts.update(parts)
369
+
370
+ known_typing_constructs = {
371
+ "List",
372
+ "Optional",
373
+ "Dict",
374
+ "Set",
375
+ "Tuple",
376
+ "Union",
377
+ "Any",
378
+ "AsyncIterator",
379
+ "Iterator",
380
+ "Sequence",
381
+ "Mapping",
382
+ "Type",
383
+ "Literal",
384
+ "TypedDict",
385
+ "DefaultDict",
386
+ "Deque",
387
+ "Counter",
388
+ "ChainMap",
389
+ "NoReturn",
390
+ "Generator",
391
+ "Awaitable",
392
+ "Callable",
393
+ "Protocol",
394
+ "runtime_checkable",
395
+ "Self",
396
+ "ClassVar",
397
+ "Final",
398
+ "Required",
399
+ "NotRequired",
400
+ "Annotated",
401
+ "TypeGuard",
402
+ "SupportsIndex",
403
+ "SupportsAbs",
404
+ "SupportsBytes",
405
+ "SupportsComplex",
406
+ "SupportsFloat",
407
+ "SupportsInt",
408
+ "SupportsRound",
409
+ "TypeAlias",
410
+ }
411
+
412
+ for name in potential_names_to_import:
413
+ if name in handled_datetime_parts and name in {"date", "datetime"}:
414
+ # If 'date' or 'datetime' were part of 'datetime.date' or 'datetime.datetime'
415
+ # they were handled by add_import(module_name, class_name) earlier. Skip further processing.
416
+ continue
417
+
418
+ if name in known_typing_constructs:
419
+ self.add_import("typing", name, is_typing_import=True)
420
+ continue # Successfully handled as a typing import
421
+
422
+ # Check if 'name' is a known schema (model)
423
+ if self.parsed_schemas:
424
+ found_schema_obj = None
425
+ # schema_obj.name is the Python class name (e.g., VectorDatabase)
426
+ for schema_obj in self.parsed_schemas.values():
427
+ if schema_obj.name == name:
428
+ found_schema_obj = schema_obj
429
+ break
430
+
431
+ if found_schema_obj:
432
+ # Use the generation_name if available, fallback to name
433
+ schema_class_name = found_schema_obj.generation_name or found_schema_obj.name
434
+ if schema_class_name is None:
435
+ logger.warning(f"Skipping import generation for an unnamed schema: {found_schema_obj}")
436
+ continue # Skip to the next name if schema_class_name is None
437
+
438
+ # Use the final_module_stem if available, otherwise sanitize the class name
439
+ if found_schema_obj.final_module_stem:
440
+ schema_file_name_segment = found_schema_obj.final_module_stem
441
+ else:
442
+ # Fallback to sanitizing the class name
443
+ schema_file_name_segment = NameSanitizer.sanitize_filename(schema_class_name, suffix="")
444
+
445
+ current_gen_pkg_base_name = self.get_current_package_name_for_generated_code()
446
+
447
+ if current_gen_pkg_base_name:
448
+ # Models are typically in <current_gen_pkg_base_name>.models.<schema_file_name>
449
+ model_module_logical_path = f"{current_gen_pkg_base_name}.models.{schema_file_name_segment}"
450
+
451
+ current_rendering_module_logical_path = self.get_current_module_dot_path()
452
+
453
+ # Avoid self-importing if the model is defined in the current file being rendered
454
+ if current_rendering_module_logical_path != model_module_logical_path:
455
+ self.add_import(logical_module=model_module_logical_path, name=schema_class_name)
456
+ # else:
457
+ # logger.debug(f"Skipping import of {schema_class_name} from "
458
+ # f"{model_module_logical_path} as it's the current module.")
459
+ continue # Successfully handled (or skipped self-import) as a model import
460
+ else:
461
+ logger.warning(
462
+ f"Cannot determine current generated package name for schema '{schema_class_name}'. "
463
+ f"Import for it might be missing in {self.current_file}."
464
+ )
465
+ # Fall-through: if name is not a typing construct, not datetime part, and not a parsed schema,
466
+ # it will be ignored by this function. Other import mechanisms might handle it (e.g. primitives like 'str').
467
+
468
+ def add_plain_import(self, module: str) -> None:
469
+ """Add a plain import statement (e.g., `import os`)."""
470
+ self.import_collector.add_plain_import(module)
471
+
472
+ def calculate_relative_path_for_internal_module(
473
+ self,
474
+ target_logical_module_relative_to_gen_pkg_root: str,
475
+ ) -> str | None:
476
+ """
477
+ Calculates a relative Python import path for a target module within the
478
+ currently generated package, given the current file being rendered.
479
+
480
+ Example:
481
+ current_file: /project/out_pkg/endpoints/tags_api.py
482
+ package_root_for_generated_code: /project/out_pkg
483
+ target_logical_module_relative_to_gen_pkg_root: "models.tag_model"
484
+ Returns: "..models.tag_model"
485
+
486
+ Args:
487
+ target_logical_module_relative_to_gen_pkg_root: The dot-separated path of the target module,
488
+ relative to the `package_root_for_generated_code` (e.g., "models.user").
489
+
490
+ Returns:
491
+ The relative import string (e.g., ".sibling", "..models.user"), or None if a relative path
492
+ cannot be determined (e.g., context not fully set, or target is current file).
493
+ """
494
+ if not self.current_file or not self.package_root_for_generated_code:
495
+ return None
496
+
497
+ try:
498
+ current_file_abs = os.path.abspath(self.current_file)
499
+ package_root_abs = os.path.abspath(self.package_root_for_generated_code)
500
+ current_dir_abs = os.path.dirname(current_file_abs)
501
+ except Exception: # Was error logging here
502
+ return None
503
+
504
+ target_parts = target_logical_module_relative_to_gen_pkg_root.split(".")
505
+
506
+ # Construct potential absolute paths for the target (as a directory/package or as a .py file)
507
+ target_as_dir_abs = os.path.join(package_root_abs, *target_parts)
508
+ target_as_file_abs = os.path.join(package_root_abs, *target_parts) + ".py"
509
+
510
+ target_abs_path: str
511
+ is_target_package: bool # True if target is a package (directory), False if a module (.py file)
512
+
513
+ if os.path.isdir(target_as_dir_abs):
514
+ target_abs_path = target_as_dir_abs
515
+ is_target_package = True
516
+ elif os.path.isfile(target_as_file_abs):
517
+ target_abs_path = target_as_file_abs
518
+ is_target_package = False
519
+ else:
520
+ # Target does not exist. Assume it WILL be a .py module for path calculation.
521
+ target_abs_path = target_as_file_abs
522
+ is_target_package = False # Assume it's a module if it doesn't exist
523
+
524
+ # Self-import check: if the resolved target_abs_path is the same as the current_file_abs.
525
+ if current_file_abs == target_abs_path:
526
+ return None
527
+
528
+ try:
529
+ relative_file_path = os.path.relpath(target_abs_path, start=current_dir_abs)
530
+ except ValueError: # Was warning logging here
531
+ return None
532
+
533
+ # If the target is a module file (not a package/directory), and the relative path ends with .py, remove it.
534
+ if not is_target_package and relative_file_path.endswith(".py"):
535
+ relative_file_path = relative_file_path[:-3]
536
+
537
+ path_components = relative_file_path.split(os.sep)
538
+ level = 0
539
+ parts_after_pardir = []
540
+ pardir_found_and_processed = False
541
+
542
+ for part in path_components:
543
+ if part == os.pardir:
544
+ if not pardir_found_and_processed:
545
+ level += 1
546
+ elif part == os.curdir:
547
+ # If current dir '.' is the first part, it implies sibling, level remains 0.
548
+ # If it appears after '..', it's unusual but we just ignore it.
549
+ if not path_components[0] == os.curdir and not pardir_found_and_processed:
550
+ # This case ('.' after some actual path part) means we treat it as a path segment
551
+ parts_after_pardir.append(part)
552
+ else: # Actual path segment
553
+ pardir_found_and_processed = True # Stop counting '..' for level once a real segment is found
554
+ parts_after_pardir.append(part)
555
+
556
+ # If the path started with '..' (e.g. '../../foo'), level is correct.
557
+ # If it started with 'foo' (sibling dir or file), level is 0.
558
+ # num_dots_for_prefix: 0 for current dir, 1 for ./foo, 2 for ../foo, 3 for ../../foo
559
+ if relative_file_path == os.curdir or not path_components or path_components == [os.curdir]:
560
+ # This means target is current_dir itself (e.g. importing __init__.py)
561
+ # This should ideally be caught by self-import if current_file is __init__.py itself
562
+ # Or if target is __init__.py in current_dir. For this, we need just "."
563
+ num_dots_for_prefix = 1
564
+ parts_after_pardir = [] # No suffix needed for just "."
565
+ elif level == 0 and (not parts_after_pardir or parts_after_pardir == [os.curdir]):
566
+ # This condition implies relative_file_path was something like "." or empty after processing,
567
+ # meaning it's in the current directory. If os.curdir was the only thing, it's handled above.
568
+ # This is for safety, might be redundant with the direct os.curdir check.
569
+ num_dots_for_prefix = 1
570
+ parts_after_pardir = [
571
+ comp for comp in parts_after_pardir if comp != os.curdir
572
+ ] # clean out curdir if it's there
573
+ else:
574
+ num_dots_for_prefix = level + 1
575
+
576
+ leading_dots_str = "." * num_dots_for_prefix
577
+ module_name_suffix = ".".join(p for p in parts_after_pardir if p) # Filter out empty strings
578
+
579
+ if module_name_suffix:
580
+ final_relative_path = leading_dots_str + module_name_suffix
581
+ else:
582
+ # This happens if only dots are needed, e.g. `from .. import foo` (suffix is empty, path is just dots)
583
+ # or `from . import bar`
584
+ final_relative_path = leading_dots_str
585
+
586
+ return final_relative_path
587
+
588
+ def get_current_package_name_for_generated_code(self) -> str | None:
589
+ """
590
+ Get the current package name for the generated code.
591
+
592
+ Returns:
593
+ The current package name for the generated code, or None if not set.
594
+ """
595
+ # If we have the full output package name, use it for absolute imports
596
+ if self.output_package_name:
597
+ return self.output_package_name
598
+
599
+ # Fallback to deriving from filesystem path (legacy behavior)
600
+ return self.package_root_for_generated_code.split(os.sep)[-1] if self.package_root_for_generated_code else None
601
+
602
+ def get_current_module_dot_path(self) -> str | None:
603
+ """
604
+ Get the current module dot path relative to the overall project root.
605
+ Example: if current_file is /project/pkg/sub/mod.py and package_root_for_generated_code is /project/pkg,
606
+ and overall_project_root is /project, this should attempt to return pkg.sub.mod
607
+ """
608
+ if not self.current_file or not self.overall_project_root:
609
+ return None
610
+
611
+ try:
612
+ abs_current_file = Path(self.current_file).resolve()
613
+ abs_overall_project_root = Path(self.overall_project_root).resolve()
614
+
615
+ # Get the relative path of the current file from the overall project root
616
+ relative_path_from_project_root = abs_current_file.relative_to(abs_overall_project_root)
617
+
618
+ # Remove .py extension
619
+ module_parts = list(relative_path_from_project_root.parts)
620
+ if module_parts[-1].endswith(".py"):
621
+ module_parts[-1] = module_parts[-1][:-3]
622
+
623
+ # Handle __init__.py cases: if the last part is __init__, it refers to the directory itself as the module
624
+ if module_parts[-1] == "__init__":
625
+ module_parts.pop()
626
+
627
+ return ".".join(module_parts)
628
+
629
+ except ValueError: # If current_file is not under overall_project_root
630
+ return None
File without changes
@@ -0,0 +1,22 @@
1
+ from typing import Any, Protocol # noqa: F401
2
+
3
+
4
+ class BaseAuth(Protocol):
5
+ """Protocol for authentication plugins."""
6
+
7
+ async def authenticate_request(self, request_args: dict[str, Any]) -> dict[str, Any]:
8
+ """Modify or augment the request arguments for authentication."""
9
+ # Default stub returns the input unchanged
10
+ return request_args
11
+
12
+
13
+ class CompositeAuth(BaseAuth):
14
+ """Compose multiple BaseAuth plugins, applying each in sequence to the request."""
15
+
16
+ def __init__(self, *plugins: BaseAuth):
17
+ self.plugins = plugins
18
+
19
+ async def authenticate_request(self, request_args: dict[str, Any]) -> dict[str, Any]:
20
+ for plugin in self.plugins:
21
+ request_args = await plugin.authenticate_request(request_args)
22
+ return request_args