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,567 @@
1
+ """
2
+ ClientGenerator: Encapsulates the OpenAPI client generation logic for use by CLI or other frontends.
3
+ """
4
+
5
+ import logging
6
+ import os
7
+ import shutil
8
+ import tempfile
9
+ import time
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Any, List
13
+
14
+ from pyopenapi_gen.context.render_context import RenderContext
15
+ from pyopenapi_gen.core.loader.loader import load_ir_from_spec
16
+ from pyopenapi_gen.core.postprocess_manager import PostprocessManager
17
+ from pyopenapi_gen.core.spec_fetcher import fetch_spec
18
+ from pyopenapi_gen.core.warning_collector import WarningCollector
19
+ from pyopenapi_gen.emitters.client_emitter import ClientEmitter
20
+ from pyopenapi_gen.emitters.core_emitter import CoreEmitter
21
+ from pyopenapi_gen.emitters.endpoints_emitter import EndpointsEmitter
22
+ from pyopenapi_gen.emitters.exceptions_emitter import ExceptionsEmitter
23
+ from pyopenapi_gen.emitters.mocks_emitter import MocksEmitter
24
+ from pyopenapi_gen.emitters.models_emitter import ModelsEmitter
25
+ from pyopenapi_gen.generator.exceptions import GenerationError
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # Re-export for backwards compatibility
30
+ __all__ = ["ClientGenerator", "GenerationError"]
31
+
32
+
33
+ class ClientGenerator:
34
+ """
35
+ Generates a Python OpenAPI client package from a given OpenAPI spec file or URL.
36
+
37
+ This class encapsulates all logic for code generation, diffing, post-processing, and output management.
38
+ It is independent of any CLI or UI framework and can be used programmatically.
39
+ """
40
+
41
+ def __init__(self, verbose: bool = True) -> None:
42
+ """
43
+ Initialize the client generator.
44
+
45
+ Args:
46
+ verbose: Whether to output detailed progress information.
47
+ """
48
+ self.verbose = verbose
49
+ self.start_time = time.time()
50
+ self.timings: dict[str, float] = {}
51
+
52
+ def _log_progress(self, message: str, stage: str | None = None) -> None:
53
+ """
54
+ Log a progress message with timestamp.
55
+
56
+ Args:
57
+ message: The progress message to log.
58
+ stage: Optional name of the current stage for timing information.
59
+ """
60
+ if not self.verbose:
61
+ return
62
+
63
+ elapsed = time.time() - self.start_time
64
+ timestamp = datetime.now().strftime("%H:%M:%S")
65
+
66
+ if stage:
67
+ # Mark stage start
68
+ if stage not in self.timings:
69
+ self.timings[stage] = time.time()
70
+ stage_msg = f"[STARTING {stage}]"
71
+ else:
72
+ # Mark stage end
73
+ stage_time = time.time() - self.timings[stage]
74
+ stage_msg = f"[COMPLETED {stage} in {stage_time:.2f}s]"
75
+
76
+ log_msg = f"{timestamp} ({elapsed:.2f}s) {stage_msg} {message}"
77
+ else:
78
+ log_msg = f"{timestamp} ({elapsed:.2f}s) {message}"
79
+
80
+ logger.info(log_msg)
81
+ # Also print to stdout for CLI users when verbose mode is enabled
82
+ if self.verbose:
83
+ print(log_msg)
84
+
85
+ def generate(
86
+ self,
87
+ spec_path: str,
88
+ project_root: Path,
89
+ output_package: str,
90
+ force: bool = False,
91
+ no_postprocess: bool = False,
92
+ core_package: str | None = None,
93
+ ) -> List[Path]:
94
+ """
95
+ Generate the client code from the OpenAPI spec.
96
+
97
+ Args:
98
+ spec_path (str): Path or URL to the OpenAPI spec file.
99
+ project_root (Path): Path to the root of the Python project (absolute or relative).
100
+ output_package (str): Python package path for the generated client (e.g., 'pyapis.my_api_client').
101
+ force (bool): Overwrite output without diff check.
102
+ name (str | None): Custom client package name (not used).
103
+ docs (bool): Kept for interface compatibility.
104
+ telemetry (bool): Kept for interface compatibility.
105
+ auth (str | None): Kept for interface compatibility.
106
+ no_postprocess (bool): Skip post-processing (type checking, etc.).
107
+ core_package (str): Python package path for the core package.
108
+
109
+ Raises:
110
+ GenerationError: If generation fails or diffs are found (when not forcing overwrite).
111
+ """
112
+ self._log_progress(f"Starting code generation for specification: {spec_path}", "GENERATION")
113
+ project_root = Path(project_root).resolve()
114
+
115
+ # Stage 1: Load Spec
116
+ self._log_progress(f"Loading specification from {spec_path}", "LOAD_SPEC")
117
+ spec_dict = self._load_spec(spec_path)
118
+ self._log_progress(f"Loaded specification with {len(spec_dict)} top-level keys", "LOAD_SPEC")
119
+
120
+ # Stage 2: Parse to IR
121
+ self._log_progress(f"Parsing specification into intermediate representation", "PARSE_IR")
122
+ ir = load_ir_from_spec(spec_dict)
123
+
124
+ # Log stats about the IR
125
+ schema_count = len(ir.schemas) if ir.schemas else 0
126
+ operation_count = len(ir.operations) if ir.operations else 0
127
+ self._log_progress(f"Parsed IR with {schema_count} schemas and {operation_count} operations", "PARSE_IR")
128
+
129
+ # Stage 3: Collect warnings
130
+ self._log_progress("Collecting warnings", "WARNINGS")
131
+ collector = WarningCollector()
132
+ reports = collector.collect(ir)
133
+ for report in reports:
134
+ warning_msg = f"WARNING [{report.code}]: {report.message} (Hint: {report.hint})"
135
+ # print(warning_msg) # Changed to logger.warning
136
+ logger.warning(warning_msg)
137
+ self._log_progress(f"Found {len(reports)} warnings", "WARNINGS")
138
+
139
+ # Resolve output and core directories from package paths
140
+ def pkg_to_path(pkg: str) -> Path:
141
+ return project_root.joinpath(*pkg.split("."))
142
+
143
+ # Default output_package if not set
144
+ if not output_package:
145
+ raise ValueError("Output package name cannot be empty")
146
+ out_dir = pkg_to_path(output_package)
147
+
148
+ # --- Robust Defaulting for core_package ---
149
+ if core_package is None: # User did not specify, use default relative to output_package
150
+ resolved_core_package_fqn = output_package + ".core"
151
+ else: # User specified something, use it as is
152
+ resolved_core_package_fqn = core_package
153
+ # --- End Robust Defaulting ---
154
+
155
+ # Determine core_dir (physical path for CoreEmitter)
156
+ core_dir = pkg_to_path(resolved_core_package_fqn)
157
+
158
+ # The actual_core_module_name_for_emitter_init becomes resolved_core_package_fqn
159
+ # The core_import_path_for_context also becomes resolved_core_package_fqn
160
+
161
+ self._log_progress(f"Output directory: {out_dir}", "CONFIG")
162
+ self._log_progress(f"Core package: {resolved_core_package_fqn}", "CONFIG")
163
+
164
+ generated_files = []
165
+
166
+ # Create RenderContext once and populate its parsed_schemas for the force=True path
167
+ # It will be used if not doing a diff, or after a successful diff.
168
+ self._log_progress("Creating render context", "INIT")
169
+ main_render_context = RenderContext(
170
+ core_package_name=resolved_core_package_fqn,
171
+ package_root_for_generated_code=str(out_dir),
172
+ overall_project_root=str(project_root),
173
+ parsed_schemas=ir.schemas,
174
+ output_package_name=output_package,
175
+ )
176
+
177
+ if not force and out_dir.exists():
178
+ self._log_progress("Checking for differences with existing output", "DIFF_CHECK")
179
+ # --- Refactored Diff Logic ---
180
+ with tempfile.TemporaryDirectory() as tmpdir:
181
+ tmp_project_root_for_diff = Path(tmpdir)
182
+
183
+ # Define temporary destination paths based on the temp project root
184
+ def tmp_pkg_to_path(pkg: str) -> Path:
185
+ # Ensure the path is relative to the temp root, not the final project root
186
+ return tmp_project_root_for_diff.joinpath(*pkg.split("."))
187
+
188
+ tmp_out_dir_for_diff = tmp_pkg_to_path(output_package)
189
+ tmp_core_dir_for_diff = tmp_pkg_to_path(resolved_core_package_fqn)
190
+
191
+ # Ensure temporary directories exist (FileManager used by emitters might handle this,
192
+ # but explicit is safer)
193
+ tmp_out_dir_for_diff.mkdir(parents=True, exist_ok=True)
194
+ tmp_core_dir_for_diff.mkdir(parents=True, exist_ok=True) # Ensure core temp dir always exists
195
+
196
+ # --- Generate files into the temporary structure ---
197
+ temp_generated_files = [] # Track files generated in temp dir
198
+
199
+ # 1. ExceptionsEmitter (emits exception_aliases.py to tmp_core_dir_for_diff)
200
+ self._log_progress("Generating exception files (temp)", "EMIT_EXCEPTIONS_TEMP")
201
+ exceptions_emitter = ExceptionsEmitter(
202
+ core_package_name=resolved_core_package_fqn,
203
+ overall_project_root=str(tmp_project_root_for_diff), # Use temp project root for context
204
+ )
205
+ exception_files_list, exception_alias_names = exceptions_emitter.emit(
206
+ ir, str(tmp_core_dir_for_diff), client_package_name=output_package
207
+ ) # Emit TO temp core dir
208
+ exception_files = [Path(p) for p in exception_files_list]
209
+ temp_generated_files += exception_files
210
+ self._log_progress(f"Generated {len(exception_files)} exception files (temp)", "EMIT_EXCEPTIONS_TEMP")
211
+
212
+ # 2. CoreEmitter (emits core files to tmp_core_dir_for_diff)
213
+ self._log_progress("Generating core files (temp)", "EMIT_CORE_TEMP")
214
+ # Note: CoreEmitter copies files, RenderContext isn't strictly needed for it, but path must be correct.
215
+ relative_core_path_for_emitter_init_temp = os.path.relpath(tmp_core_dir_for_diff, tmp_out_dir_for_diff)
216
+ core_emitter = CoreEmitter(
217
+ core_dir=str(relative_core_path_for_emitter_init_temp),
218
+ core_package=resolved_core_package_fqn,
219
+ exception_alias_names=exception_alias_names,
220
+ )
221
+ core_files = [Path(p) for p in core_emitter.emit(str(tmp_out_dir_for_diff))]
222
+ temp_generated_files += core_files
223
+ self._log_progress(f"Generated {len(core_files)} core files (temp)", "EMIT_CORE_TEMP")
224
+
225
+ # 3. config.py (write to tmp_core_dir_for_diff using FileManager) - REMOVED, CoreEmitter handles this
226
+ # fm = FileManager()
227
+ # config_dst_temp = tmp_core_dir_for_diff / "config.py"
228
+ # config_content = CONFIG_TEMPLATE
229
+ # fm.write_file(str(config_dst_temp), config_content)
230
+ # temp_generated_files.append(config_dst_temp)
231
+
232
+ # 4. ModelsEmitter (emits models to tmp_out_dir_for_diff/models)
233
+ self._log_progress("Generating model files (temp)", "EMIT_MODELS_TEMP")
234
+ # Create a temporary RenderContext for the diff path
235
+ tmp_render_context_for_diff = RenderContext(
236
+ core_package_name=resolved_core_package_fqn,
237
+ package_root_for_generated_code=str(tmp_out_dir_for_diff),
238
+ overall_project_root=str(tmp_project_root_for_diff),
239
+ parsed_schemas=ir.schemas,
240
+ output_package_name=output_package,
241
+ )
242
+ models_emitter = ModelsEmitter(context=tmp_render_context_for_diff, parsed_schemas=ir.schemas)
243
+ model_files_dict = models_emitter.emit(
244
+ ir, str(tmp_out_dir_for_diff)
245
+ ) # ModelsEmitter.emit now takes IRSpec
246
+ temp_generated_files += [
247
+ Path(p) for p_list in model_files_dict.values() for p in p_list
248
+ ] # Flatten list of lists
249
+ schema_count = len(ir.schemas) if ir.schemas else 0
250
+ self._log_progress(
251
+ f"Generated {len(model_files_dict)} model files for {schema_count} schemas (temp)",
252
+ "EMIT_MODELS_TEMP",
253
+ )
254
+
255
+ # 5. EndpointsEmitter (emits endpoints to tmp_out_dir_for_diff/endpoints)
256
+ self._log_progress("Generating endpoint files (temp)", "EMIT_ENDPOINTS_TEMP")
257
+ endpoints_emitter = EndpointsEmitter(context=tmp_render_context_for_diff)
258
+ endpoint_files = [
259
+ Path(p)
260
+ for p in endpoints_emitter.emit(
261
+ ir.operations, str(tmp_out_dir_for_diff)
262
+ ) # emit takes ir.operations, str output_dir
263
+ ]
264
+ temp_generated_files += endpoint_files
265
+ operation_count = len(ir.operations) if ir.operations else 0
266
+ self._log_progress(
267
+ f"Generated {len(endpoint_files)} endpoint files for {operation_count} operations (temp)",
268
+ "EMIT_ENDPOINTS_TEMP",
269
+ )
270
+
271
+ # 6. ClientEmitter (emits client.py to tmp_out_dir_for_diff)
272
+ self._log_progress("Generating client file (temp)", "EMIT_CLIENT_TEMP")
273
+ client_emitter = ClientEmitter(context=tmp_render_context_for_diff) # ClientEmitter now takes context
274
+ client_files = [
275
+ Path(p) for p in client_emitter.emit(ir, str(tmp_out_dir_for_diff)) # emit takes ir, str output_dir
276
+ ]
277
+ temp_generated_files += client_files
278
+ self._log_progress(f"Generated {len(client_files)} client files (temp)", "EMIT_CLIENT_TEMP")
279
+
280
+ # 7. MocksEmitter (emits mock files to tmp_out_dir_for_diff)
281
+ self._log_progress("Generating mock helper classes (temp)", "EMIT_MOCKS_TEMP")
282
+ mocks_emitter = MocksEmitter(context=tmp_render_context_for_diff)
283
+ mock_files = [Path(p) for p in mocks_emitter.emit(ir, str(tmp_out_dir_for_diff))]
284
+ temp_generated_files += mock_files
285
+ self._log_progress(f"Generated {len(mock_files)} mock files (temp)", "EMIT_MOCKS_TEMP")
286
+
287
+ # Post-processing should run on the temporary files if enabled
288
+ if not no_postprocess:
289
+ self._log_progress("Running post-processing on temporary files", "POSTPROCESS_TEMP")
290
+ # Pass the temp project root to PostprocessManager
291
+ PostprocessManager(str(tmp_project_root_for_diff)).run([str(p) for p in temp_generated_files])
292
+ self._log_progress(f"Post-processed {len(temp_generated_files)} files", "POSTPROCESS_TEMP")
293
+
294
+ # --- Compare final output dirs with the temp output dirs ---
295
+ self._log_progress("Comparing generated files with existing files", "DIFF")
296
+ # Compare client package dir
297
+ self._log_progress(f"Checking client package differences", "DIFF_CLIENT")
298
+ has_diff_client = self._show_diffs(str(out_dir), str(tmp_out_dir_for_diff))
299
+
300
+ # Compare core package dir IF it's different from the client dir
301
+ has_diff_core = False
302
+ if core_dir != out_dir:
303
+ self._log_progress(f"Checking core package differences", "DIFF_CORE")
304
+ has_diff_core = self._show_diffs(str(core_dir), str(tmp_core_dir_for_diff))
305
+
306
+ if has_diff_client or has_diff_core:
307
+ self._log_progress("Differences found, not updating existing output", "DIFF_RESULT")
308
+ raise GenerationError("Differences found between generated and existing output.")
309
+
310
+ self._log_progress("No differences found, using existing files", "DIFF_RESULT")
311
+ # If no diffs, return the paths of the *existing* files (no changes made)
312
+ # We need to collect the actual existing file paths corresponding to temp_generated_files
313
+ # This is tricky because _show_diffs only returns bool.
314
+ # A simpler approach if no diff: do nothing, return empty list? Or paths of existing files?
315
+ # Let's return the existing paths for consistency with the `else` block.
316
+ # Need to map temp_generated_files back to original project_root based paths.
317
+ final_generated_files = []
318
+ for tmp_file in temp_generated_files:
319
+ try:
320
+ # Find relative path from temp root
321
+ rel_path = tmp_file.relative_to(tmp_project_root_for_diff)
322
+ # Construct path relative to final project root
323
+ final_path = project_root / rel_path
324
+ if final_path.exists(): # Should exist if no diff
325
+ final_generated_files.append(final_path)
326
+ except ValueError:
327
+ # Should not happen if paths are constructed correctly
328
+ print(f"Warning: Could not map temporary file {tmp_file} back to project root {project_root}")
329
+ generated_files = final_generated_files
330
+ self._log_progress(f"Mapped {len(generated_files)} existing files", "DIFF_COMPLETE")
331
+
332
+ # --- End Refactored Diff Logic ---
333
+ else: # This is the force=True or first-run logic
334
+ self._log_progress("Direct generation (force=True or first run)", "DIRECT_GEN")
335
+ if out_dir.exists():
336
+ self._log_progress(f"Removing existing directory: {out_dir}", "CLEANUP")
337
+ shutil.rmtree(str(out_dir))
338
+ # Ensure parent dirs exist before creating final output dir
339
+ self._log_progress(f"Creating directory structure", "SETUP_DIRS")
340
+ out_dir.parent.mkdir(parents=True, exist_ok=True)
341
+ out_dir.mkdir(parents=True, exist_ok=True) # Create final output dir
342
+
343
+ # Ensure core dir exists if different from out_dir
344
+ if core_dir != out_dir:
345
+ core_dir.parent.mkdir(parents=True, exist_ok=True)
346
+ core_dir.mkdir(parents=True, exist_ok=True) # Create final core dir
347
+
348
+ # Write root __init__.py if needed (handle nested packages like a.b.c)
349
+ self._log_progress("Creating __init__.py files for package structure", "INIT_FILES")
350
+ init_files_created = 0
351
+ current = out_dir
352
+ while current != project_root:
353
+ init_path = current / "__init__.py"
354
+ if not init_path.exists():
355
+ init_path.write_text("")
356
+ init_files_created += 1
357
+ if current.parent == current: # Avoid infinite loop at root
358
+ break
359
+ current = current.parent
360
+
361
+ # If core_dir is outside out_dir structure, ensure its __init__.py exist too
362
+ if not str(core_dir).startswith(str(out_dir)):
363
+ current = core_dir
364
+ while current != project_root:
365
+ init_path = current / "__init__.py"
366
+ if not init_path.exists():
367
+ init_path.write_text("")
368
+ init_files_created += 1
369
+ if current.parent == current:
370
+ break
371
+ current = current.parent
372
+
373
+ self._log_progress(f"Created {init_files_created} __init__.py files", "INIT_FILES")
374
+
375
+ # --- Generate directly into final destination paths ---
376
+ self._log_progress("Starting direct file generation", "DIRECT_GEN_FILES")
377
+
378
+ # 1. ExceptionsEmitter
379
+ self._log_progress("Generating exception files", "EMIT_EXCEPTIONS")
380
+ exceptions_emitter = ExceptionsEmitter(
381
+ core_package_name=resolved_core_package_fqn,
382
+ overall_project_root=str(project_root),
383
+ )
384
+ exception_files_list, exception_alias_names = exceptions_emitter.emit(
385
+ ir, str(core_dir), client_package_name=output_package
386
+ )
387
+ generated_files += [Path(p) for p in exception_files_list]
388
+ self._log_progress(f"Generated {len(exception_files_list)} exception files", "EMIT_EXCEPTIONS")
389
+
390
+ # 2. CoreEmitter
391
+ self._log_progress("Generating core files", "EMIT_CORE")
392
+ relative_core_path_for_emitter_init = os.path.relpath(core_dir, out_dir)
393
+ core_emitter = CoreEmitter(
394
+ core_dir=str(relative_core_path_for_emitter_init),
395
+ core_package=resolved_core_package_fqn,
396
+ exception_alias_names=exception_alias_names,
397
+ )
398
+ generated_files += [Path(p) for p in core_emitter.emit(str(out_dir))]
399
+ self._log_progress(f"Generated {len(core_emitter.emit(str(out_dir)))} core files", "EMIT_CORE")
400
+
401
+ # 3. config.py (using FileManager) - REMOVED, CoreEmitter handles this
402
+ # fm = FileManager()
403
+ # config_dst = core_dir / "config.py"
404
+ # config_content = CONFIG_TEMPLATE
405
+ # fm.write_file(str(config_dst), config_content) # Use FileManager
406
+ # generated_files.append(config_dst)
407
+
408
+ # 4. ModelsEmitter
409
+ self._log_progress("Generating model files", "EMIT_MODELS")
410
+ models_emitter = ModelsEmitter(context=main_render_context, parsed_schemas=ir.schemas)
411
+ model_files_dict = models_emitter.emit(ir, str(out_dir)) # ModelsEmitter.emit now takes IRSpec
412
+ generated_files += [
413
+ Path(p) for p_list in model_files_dict.values() for p in p_list
414
+ ] # Flatten list of lists
415
+ schema_count = len(ir.schemas) if ir.schemas else 0
416
+ self._log_progress(
417
+ f"Generated {len(model_files_dict)} model files for {schema_count} schemas",
418
+ "EMIT_MODELS",
419
+ )
420
+
421
+ # 5. EndpointsEmitter
422
+ self._log_progress("Generating endpoint files", "EMIT_ENDPOINTS")
423
+ endpoints_emitter = EndpointsEmitter(context=main_render_context)
424
+ generated_files += [
425
+ Path(p) for p in endpoints_emitter.emit(ir.operations, str(out_dir))
426
+ ] # emit takes ir.operations, str output_dir
427
+ operation_count = len(ir.operations) if ir.operations else 0
428
+ self._log_progress(
429
+ f"Generated {len(endpoints_emitter.emit(ir.operations, str(out_dir)))} "
430
+ f"endpoint files for {operation_count} operations",
431
+ "EMIT_ENDPOINTS",
432
+ )
433
+
434
+ # 6. ClientEmitter
435
+ self._log_progress("Generating client file", "EMIT_CLIENT")
436
+ client_emitter = ClientEmitter(context=main_render_context) # ClientEmitter now takes context
437
+ client_files = [Path(p) for p in client_emitter.emit(ir, str(out_dir))] # emit takes ir, str output_dir
438
+ generated_files += client_files
439
+ self._log_progress(f"Generated {len(client_files)} client files", "EMIT_CLIENT")
440
+
441
+ # 7. MocksEmitter
442
+ self._log_progress("Generating mock helper classes", "EMIT_MOCKS")
443
+ mocks_emitter = MocksEmitter(context=main_render_context)
444
+ mock_files = [Path(p) for p in mocks_emitter.emit(ir, str(out_dir))]
445
+ generated_files += mock_files
446
+ self._log_progress(f"Generated {len(mock_files)} mock files", "EMIT_MOCKS")
447
+
448
+ # After all emitters, if core_package is specified (external core),
449
+ # create a rich __init__.py in the client's output_package (out_dir).
450
+ if core_package: # core_package is the user-provided original arg
451
+ client_init_py_path = out_dir / "__init__.py"
452
+ self._log_progress(
453
+ f"Generating rich __init__.py for client package at {client_init_py_path}", "CLIENT_INIT"
454
+ )
455
+
456
+ # Core components to re-export.
457
+ # resolved_core_package_fqn is the correct fully qualified name to use for imports.
458
+ core_imports = [
459
+ f"from {resolved_core_package_fqn}.auth import BaseAuth, ApiKeyAuth, BearerAuth, OAuth2Auth",
460
+ f"from {resolved_core_package_fqn}.config import ClientConfig",
461
+ f"from {resolved_core_package_fqn}.exceptions import HTTPError, ClientError, ServerError",
462
+ f"from {resolved_core_package_fqn}.exception_aliases import * # noqa: F401, F403",
463
+ f"from {resolved_core_package_fqn}.http_transport import HttpTransport, HttpxTransport",
464
+ f"from {resolved_core_package_fqn}.cattrs_converter import structure_from_dict, unstructure_to_dict, converter",
465
+ ]
466
+
467
+ client_imports = [
468
+ "from .client import APIClient",
469
+ ]
470
+
471
+ all_list = [
472
+ '"APIClient",',
473
+ '"BaseAuth", "ApiKeyAuth", "BearerAuth", "OAuth2Auth",',
474
+ '"ClientConfig",',
475
+ '"HTTPError", "ClientError", "ServerError",',
476
+ # Names from exception_aliases are available via star import
477
+ '"HttpTransport", "HttpxTransport",',
478
+ '"structure_from_dict", "unstructure_to_dict", "converter",',
479
+ ]
480
+
481
+ init_content_lines = [
482
+ "# Client package __init__.py",
483
+ "# Re-exports from core and local client.",
484
+ "",
485
+ ]
486
+ init_content_lines.extend(core_imports)
487
+ init_content_lines.extend(client_imports)
488
+ init_content_lines.append("")
489
+ init_content_lines.append("__all__ = [")
490
+ for item in all_list:
491
+ init_content_lines.append(f" {item}")
492
+ init_content_lines.append("]")
493
+ init_content_lines.append("") # Trailing newline
494
+
495
+ # Use FileManager from the main_render_context if available, or create one.
496
+ # For simplicity here, just write directly.
497
+ try:
498
+ with open(client_init_py_path, "w") as f:
499
+ f.write("\\n".join(init_content_lines))
500
+ generated_files.append(client_init_py_path) # Track this generated file
501
+ self._log_progress(f"Successfully wrote rich __init__.py to {client_init_py_path}", "CLIENT_INIT")
502
+ except IOError as e:
503
+ self._log_progress(f"ERROR: Failed to write client __init__.py: {e}", "CLIENT_INIT")
504
+ # Optionally re-raise or handle as a generation failure
505
+ raise GenerationError(f"Failed to write client __init__.py: {e}") from e
506
+
507
+ # Post-processing applies to all generated files
508
+ if not no_postprocess:
509
+ self._log_progress("Running post-processing on generated files", "POSTPROCESS")
510
+ PostprocessManager(str(project_root)).run([str(p) for p in generated_files])
511
+ self._log_progress(f"Post-processed {len(generated_files)} files", "POSTPROCESS")
512
+
513
+ total_time = time.time() - self.start_time
514
+ self._log_progress(
515
+ f"Code generation completed successfully in {total_time:.2f}s, generated {len(generated_files)} files",
516
+ "GENERATION",
517
+ )
518
+
519
+ # Print timing summary if verbose
520
+ if self.verbose:
521
+ self._log_progress("=== Generation Summary ===", None)
522
+ for stage, start_time in sorted(self.timings.items()):
523
+ # Only include stages that have both start and end times
524
+ if f"{stage}_COMPLETE" in self.timings or stage in self.timings:
525
+ end_time = self.timings.get(f"{stage}_COMPLETE", time.time())
526
+ duration = end_time - start_time
527
+ self._log_progress(f"{stage}: {duration:.2f}s", None)
528
+
529
+ return generated_files
530
+
531
+ def _load_spec(self, path_or_url: str) -> dict[str, Any]:
532
+ """
533
+ Load a spec from a file path or URL.
534
+
535
+ Args:
536
+ path_or_url: Path or URL to the OpenAPI spec.
537
+
538
+ Returns:
539
+ Parsed OpenAPI spec as a dictionary.
540
+
541
+ Raises:
542
+ GenerationError: If loading fails.
543
+ """
544
+ return fetch_spec(path_or_url)
545
+
546
+ def _show_diffs(self, old_dir: str, new_dir: str) -> bool:
547
+ """
548
+ Compare two directories and print diffs, returning True if any differences.
549
+ Args:
550
+ old_dir (str): Path to the old directory.
551
+ new_dir (str): Path to the new directory.
552
+ Returns:
553
+ bool: True if differences are found, False otherwise.
554
+ """
555
+ import difflib
556
+
557
+ has_diff = False
558
+ for new_file in Path(new_dir).rglob("*.py"):
559
+ old_file = Path(old_dir) / new_file.relative_to(new_dir)
560
+ if old_file.exists():
561
+ old_lines = old_file.read_text().splitlines()
562
+ new_lines = new_file.read_text().splitlines()
563
+ diff = list(difflib.unified_diff(old_lines, new_lines, fromfile=str(old_file), tofile=str(new_file)))
564
+ if diff:
565
+ has_diff = True
566
+ print("\n".join(diff))
567
+ return has_diff
@@ -0,0 +1,7 @@
1
+ """Generator-level exceptions for pyopenapi_gen."""
2
+
3
+
4
+ class GenerationError(Exception):
5
+ """Raised when client generation fails due to errors or diffs."""
6
+
7
+ pass