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,274 @@
1
+ """
2
+ PythonConstructRenderer: Renders Python language constructs like classes, enums, and type aliases.
3
+
4
+ This module provides the PythonConstructRenderer class, which is responsible for
5
+ generating well-formatted Python code for common constructs used in the generated client.
6
+ It handles all the details of formatting, import registration, and docstring generation
7
+ for these constructs.
8
+ """
9
+
10
+ from typing import List, Optional, Tuple
11
+
12
+ from pyopenapi_gen.context.render_context import RenderContext
13
+
14
+ from .code_writer import CodeWriter
15
+ from .documentation_writer import DocumentationBlock, DocumentationWriter
16
+
17
+
18
+ class PythonConstructRenderer:
19
+ """
20
+ Generates Python code for common constructs like dataclasses, enums, and type aliases.
21
+
22
+ This class provides methods to render different Python language constructs with
23
+ proper formatting, documentation, and import handling. It uses CodeWriter and
24
+ DocumentationWriter internally to ensure consistent output and automatically
25
+ registers necessary imports into the provided RenderContext.
26
+
27
+ The renderer handles:
28
+ - Type aliases (e.g., UserId = str)
29
+ - Enums (with str or int values)
30
+ - Dataclasses (with required and optional fields)
31
+ - Generic classes (with bases, docstrings, and body)
32
+ """
33
+
34
+ def render_alias(
35
+ self,
36
+ alias_name: str,
37
+ target_type: str,
38
+ description: Optional[str],
39
+ context: RenderContext,
40
+ ) -> str:
41
+ """
42
+ Render a type alias assignment as Python code.
43
+
44
+ Args:
45
+ alias_name: The name for the type alias
46
+ target_type: The target type expression
47
+ description: Optional description for the docstring
48
+ context: The rendering context for import registration
49
+
50
+ Returns:
51
+ Formatted Python code for the type alias
52
+
53
+ Example:
54
+ ```python
55
+ # Assuming: from typing import TypeAlias
56
+ UserId: TypeAlias = str
57
+ '''Alias for a user identifier''' # Reverted to triple-single for example
58
+ ```
59
+ """
60
+ writer = CodeWriter()
61
+ # Add TypeAlias import
62
+ context.add_import("typing", "TypeAlias")
63
+ # Register imports needed by the target type itself
64
+ context.add_typing_imports_for_type(target_type)
65
+
66
+ # Add __all__ export
67
+ writer.write_line(f'__all__ = ["{alias_name}"]')
68
+ writer.write_line("") # Add a blank line for separation
69
+
70
+ writer.write_line(f"{alias_name}: TypeAlias = {target_type}")
71
+ if description:
72
+ # Sanitize description for use within a triple-double-quoted string for the actual docstring
73
+ safe_desc_content = description.replace("\\", "\\\\") # Escape backslashes first
74
+ safe_desc_content = safe_desc_content.replace('"""', '\\"\\"\\"') # Escape triple-double-quotes
75
+ writer.write_line(f'"""Alias for {safe_desc_content}"""') # Actual generated docstring uses """
76
+ return writer.get_code()
77
+
78
+ def render_enum(
79
+ self,
80
+ enum_name: str,
81
+ base_type: str, # 'str' or 'int'
82
+ values: List[Tuple[str, str | int]], # List of (MEMBER_NAME, value)
83
+ description: Optional[str],
84
+ context: RenderContext,
85
+ ) -> str:
86
+ """
87
+ Render an Enum class as Python code.
88
+
89
+ Args:
90
+ enum_name: The name of the enum class
91
+ base_type: The base type, either 'str' or 'int'
92
+ values: List of (member_name, value) pairs
93
+ description: Optional description for the docstring
94
+ context: The rendering context for import registration
95
+
96
+ Returns:
97
+ Formatted Python code for the enum class
98
+
99
+ Example:
100
+ ```python
101
+ @unique
102
+ class Color(str, Enum):
103
+ \"\"\"Color options available in the API\"\"\"
104
+ RED = "red"
105
+ GREEN = "green"
106
+ BLUE = "blue"
107
+ ```
108
+ """
109
+ writer = CodeWriter()
110
+ context.add_import("enum", "Enum")
111
+ context.add_import("enum", "unique")
112
+
113
+ # Add __all__ export
114
+ writer.write_line(f'__all__ = ["{enum_name}"]')
115
+ writer.write_line("") # Add a blank line for separation
116
+
117
+ writer.write_line("@unique")
118
+ writer.write_line(f"class {enum_name}(" + base_type + ", Enum):")
119
+ writer.indent()
120
+
121
+ # Build and write docstring
122
+ doc_args: list[tuple[str, str, str] | tuple[str, str]] = []
123
+ for member_name, value in values:
124
+ doc_args.append((str(value), base_type, f"Value for {member_name}"))
125
+ doc_block = DocumentationBlock(
126
+ summary=description or f"{enum_name} Enum",
127
+ args=doc_args if doc_args else None,
128
+ )
129
+ docstring = DocumentationWriter(width=88).render_docstring(doc_block, indent=0)
130
+ for line in docstring.splitlines():
131
+ writer.write_line(line)
132
+
133
+ # Write Enum members
134
+ for member_name, value in values:
135
+ if base_type == "str":
136
+ writer.write_line(f'{member_name} = "{value}"')
137
+ else: # int
138
+ writer.write_line(f"{member_name} = {value}")
139
+
140
+ writer.dedent()
141
+ return writer.get_code()
142
+
143
+ def render_dataclass(
144
+ self,
145
+ class_name: str,
146
+ fields: List[Tuple[str, str, Optional[str], Optional[str]]], # name, type_hint, default_expr, description
147
+ description: Optional[str],
148
+ context: RenderContext,
149
+ ) -> str:
150
+ """
151
+ Render a dataclass as Python code.
152
+
153
+ Args:
154
+ class_name: The name of the dataclass
155
+ fields: List of (name, type_hint, default_expr, description) tuples for each field
156
+ description: Optional description for the class docstring
157
+ context: The rendering context for import registration
158
+
159
+ Returns:
160
+ Formatted Python code for the dataclass
161
+
162
+ Example:
163
+ ```python
164
+ @dataclass
165
+ class User:
166
+ \"\"\"User information.\"\"\"
167
+ id: str
168
+ name: str
169
+ email: Optional[str] = None
170
+ is_active: bool = True
171
+ ```
172
+ """
173
+ writer = CodeWriter()
174
+ context.add_import("dataclasses", "dataclass")
175
+
176
+ # Add __all__ export
177
+ writer.write_line(f'__all__ = ["{class_name}"]')
178
+ writer.write_line("") # Add a blank line for separation
179
+
180
+ writer.write_line("@dataclass")
181
+ writer.write_line(f"class {class_name}:")
182
+ writer.indent()
183
+
184
+ # Build and write docstring
185
+ field_args: list[tuple[str, str, str] | tuple[str, str]] = []
186
+ for name, type_hint, _, field_desc in fields:
187
+ field_args.append((name, type_hint, field_desc or ""))
188
+ doc_block = DocumentationBlock(
189
+ summary=description or f"{class_name} dataclass.",
190
+ args=field_args if field_args else None,
191
+ )
192
+ docstring = DocumentationWriter(width=88).render_docstring(doc_block, indent=0)
193
+ for line in docstring.splitlines():
194
+ writer.write_line(line)
195
+
196
+ # Write fields
197
+ if not fields:
198
+ writer.write_line("# No properties defined in schema")
199
+ writer.write_line("pass")
200
+ else:
201
+ # Separate required and optional fields for correct ordering (no defaults first)
202
+ required_fields = [f for f in fields if f[2] is None] # default_expr is None
203
+ optional_fields = [f for f in fields if f[2] is not None] # default_expr is not None
204
+
205
+ # Required fields
206
+ for name, type_hint, _, field_desc in required_fields:
207
+ line = f"{name}: {type_hint}"
208
+ if field_desc:
209
+ comment_text = field_desc.replace("\n", " ")
210
+ line += f" # {comment_text}"
211
+ writer.write_line(line)
212
+
213
+ # Optional fields
214
+ for name, type_hint, default_expr, field_desc in optional_fields:
215
+ if default_expr and "default_factory" in default_expr:
216
+ context.add_import("dataclasses", "field") # Ensure field is imported
217
+ line = f"{name}: {type_hint} = {default_expr}"
218
+ if field_desc:
219
+ comment_text = field_desc.replace("\n", " ")
220
+ line += f" # {comment_text}"
221
+ writer.write_line(line)
222
+
223
+ writer.dedent()
224
+ return writer.get_code()
225
+
226
+ def render_class(
227
+ self,
228
+ class_name: str,
229
+ base_classes: Optional[List[str]],
230
+ docstring: Optional[str],
231
+ body_lines: Optional[List[str]],
232
+ context: RenderContext,
233
+ ) -> str:
234
+ """
235
+ Render a generic class definition as Python code.
236
+
237
+ Args:
238
+ class_name: The name of the class
239
+ base_classes: Optional list of base class names (for inheritance)
240
+ docstring: Optional class docstring content
241
+ body_lines: Optional list of code lines for the class body
242
+ context: The rendering context (not used for generic classes)
243
+
244
+ Returns:
245
+ Formatted Python code for the class
246
+
247
+ Example:
248
+ ```python
249
+ class CustomError(Exception):
250
+ \"\"\"Raised when a custom error occurs.\"\"\"
251
+ def __init__(self, message: str, code: int):
252
+ self.code = code
253
+ super().__init__(message)
254
+ ```
255
+ """
256
+ writer = CodeWriter()
257
+ bases = f"({', '.join(base_classes)})" if base_classes else ""
258
+ writer.write_line(f"class {class_name}{bases}:")
259
+ writer.indent()
260
+ has_content = False
261
+ if docstring:
262
+ # Simple triple-quoted docstring is sufficient for exceptions
263
+ writer.write_line(f'"""{docstring}"""')
264
+ has_content = True
265
+ if body_lines:
266
+ for line in body_lines:
267
+ writer.write_line(line)
268
+ has_content = True
269
+
270
+ if not has_content:
271
+ writer.write_line("pass") # Need pass if class is completely empty
272
+
273
+ writer.dedent()
274
+ return writer.get_code()
@@ -0,0 +1,21 @@
1
+ # Core Runtime Components
2
+
3
+ This directory contains the core runtime components required by the generated API client.
4
+
5
+ ## Client Independence
6
+
7
+ **The generated API client is fully independent and does NOT require the `pyopenapi_gen` package to be installed at runtime.**
8
+
9
+ All necessary base classes, protocols, transport implementations, and utility functions are included directly within this `core` package. You can safely use the generated client in any Python environment without installing the generator itself.
10
+
11
+ ## Shared Core (Optional)
12
+
13
+ In scenarios where multiple API clients are generated (perhaps for different services within the same ecosystem), you might want to share a single instance of this `core` package to avoid duplication.
14
+
15
+ To achieve this:
16
+ 1. Generate the first client normally. This will create the `core` package.
17
+ 2. Move or copy this `core` package to a location accessible by all your client packages (e.g., a shared `libs` directory).
18
+ 3. Ensure this shared location is included in your Python environment's `PYTHONPATH`.
19
+ 4. When generating subsequent clients, use the `--core-import-path <path_to_shared_core>` option with `pyopenapi-gen`. This tells the generator *not* to create a new `core` directory but instead to generate imports relative to the specified shared path (e.g., `from shared_libs.core.http_transport import HttpTransport`).
20
+
21
+ This allows multiple clients to reuse the same base implementation, reducing code size and ensuring consistency.
@@ -0,0 +1,143 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import List, Optional
4
+
5
+ from pyopenapi_gen.context.render_context import RenderContext
6
+ from pyopenapi_gen.core.utils import NameSanitizer
7
+ from pyopenapi_gen.core.writers.code_writer import CodeWriter
8
+ from pyopenapi_gen.ir import IRSchema
9
+ from pyopenapi_gen.visit.model.model_visitor import ModelVisitor
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class ModelsEmitter:
15
+ """Emits model files (dataclasses, enums) from IRSchema definitions."""
16
+
17
+ def __init__(self, context: RenderContext):
18
+ self.context = context
19
+ # self.import_collector is available via self.context.import_collector
20
+ # self.writer an instance CodeWriter() here seems unused globally for this emitter.
21
+ # Each file generation part either writes directly or uses a local CodeWriter.
22
+
23
+ def _generate_model_file(self, schema_ir: IRSchema, models_dir: Path) -> Optional[str]:
24
+ """Generates a single Python file for a given IRSchema. Returns file path if generated."""
25
+ if not schema_ir.name:
26
+ logger.warning(f"Skipping model generation for schema without a name: {schema_ir}")
27
+ return None
28
+
29
+ # Ensure context has parsed_schemas before ModelVisitor uses it
30
+ if not self.context.parsed_schemas:
31
+ logger.error(
32
+ "[ModelsEmitter._generate_model_file] RenderContext is missing parsed_schemas. Cannot generate model."
33
+ )
34
+ return None
35
+
36
+ module_name = NameSanitizer.sanitize_module_name(schema_ir.name)
37
+ # class_name = NameSanitizer.sanitize_class_name(schema_ir.name) # Not directly used here for file content
38
+ file_path = models_dir / f"{module_name}.py"
39
+
40
+ # Set current file on RenderContext. This also resets its internal ImportCollector.
41
+ self.context.set_current_file(str(file_path))
42
+
43
+ # ModelsEmitter's import_collector should be the same instance as RenderContext's.
44
+ # The line `self.import_collector = self.context.import_collector` was added in __init__
45
+ # or after set_current_file previously. Let's ensure it's correctly synced if there was any doubt.
46
+ current_ic = self.context.import_collector # Use the collector from the context
47
+
48
+ # Instantiate ModelVisitor
49
+ # ModelVisitor will use self.context (and thus current_ic) to add imports.
50
+ visitor = ModelVisitor(schemas=self.context.parsed_schemas)
51
+ rendered_model_str = visitor.visit(schema_ir, self.context)
52
+
53
+ # Get collected imports for the current file.
54
+ imports_list = current_ic.get_import_statements()
55
+ imports_code = "\n".join(imports_list)
56
+
57
+ # The model_code is what the visitor returned.
58
+ model_code = rendered_model_str
59
+
60
+ full_code = imports_code + "\n\n" + model_code if imports_code else model_code
61
+
62
+ file_path.parent.mkdir(parents=True, exist_ok=True)
63
+ with file_path.open("w", encoding="utf-8") as f:
64
+ f.write(full_code)
65
+ # self.writer.clear() # ModelsEmitter's self.writer is not used for individual model file body
66
+ # logger.debug(f"Generated model file: {file_path} for schema: {schema_ir.name}")
67
+ return str(file_path)
68
+
69
+ def _generate_init_py(self, models_dir: Path) -> str:
70
+ """Generates the models/__init__.py file and returns its path."""
71
+ init_writer = CodeWriter()
72
+ # ... (content generation as before) ...
73
+ all_class_names: List[str] = []
74
+ # Ensure self.context.parsed_schemas is not None before iterating
75
+ if not self.context.parsed_schemas:
76
+ logger.warning("No parsed schemas found in context for generating models/__init__.py")
77
+ # Still create an empty __init__.py with basic imports
78
+ init_writer.write_line("from typing import List, Optional, Union, Any, Dict, Generic, TypeVar")
79
+ init_writer.write_line("from dataclasses import dataclass, field")
80
+ init_writer.write_line("__all__ = []")
81
+ init_content = str(init_writer)
82
+ init_file_path = models_dir / "__init__.py"
83
+ with init_file_path.open("w", encoding="utf-8") as f:
84
+ f.write(init_content)
85
+ return str(init_file_path)
86
+
87
+ # This point onwards, self.context.parsed_schemas is guaranteed to be non-None
88
+ sorted_schema_items = sorted(self.context.parsed_schemas.items())
89
+
90
+ for schema_key, s in sorted_schema_items:
91
+ if not s.name:
92
+ logger.warning(f"Schema with key '{schema_key}' has no name, skipping for __init__.py")
93
+ continue
94
+ module_name = NameSanitizer.sanitize_module_name(s.name)
95
+ class_name = NameSanitizer.sanitize_class_name(s.name)
96
+ if module_name == "__init__":
97
+ logger.warning(f"Skipping import for schema '{s.name}' as its module name is __init__.")
98
+ continue
99
+ init_writer.write_line(f"from .{module_name} import {class_name}")
100
+ all_class_names.append(class_name)
101
+
102
+ init_writer.write_line("from typing import List, Optional, Union, Any, Dict, Generic, TypeVar")
103
+ init_writer.write_line("from dataclasses import dataclass, field")
104
+ init_writer.write_line("")
105
+
106
+ # Re-add imports for each class to ensure they are structured correctly by CodeWriter
107
+ # This is a bit redundant if CodeWriter handles sections, but safe.
108
+ # For __init__.py, it might be simpler to just list exports.
109
+
110
+ init_writer.write_line("")
111
+ init_writer.write_line("__all__ = [")
112
+ for name in sorted(all_class_names):
113
+ init_writer.write_line(f" '{name}',")
114
+ init_writer.write_line("]")
115
+ init_content = str(init_writer) # This needs to be CodeWriter.render() or similar if sections are used
116
+
117
+ init_file_path = models_dir / "__init__.py"
118
+ with init_file_path.open("w", encoding="utf-8") as f:
119
+ f.write(init_content)
120
+ return str(init_file_path)
121
+
122
+ def emit(self, output_base_dir: Path) -> List[str]:
123
+ """Emits all model files and the models/__init__.py file into <output_base_dir>/models/."""
124
+ models_dir = output_base_dir / "models"
125
+ models_dir.mkdir(parents=True, exist_ok=True)
126
+ generated_files: List[str] = []
127
+
128
+ if not self.context.parsed_schemas:
129
+ logger.warning("No parsed schemas found in context. Skipping model file generation.")
130
+ else:
131
+ sorted_schemas = sorted(self.context.parsed_schemas.values(), key=lambda s: s.name or "")
132
+ for schema_ir in sorted_schemas:
133
+ if schema_ir.name:
134
+ file_path = self._generate_model_file(schema_ir, models_dir)
135
+ if file_path:
136
+ generated_files.append(file_path)
137
+ else:
138
+ # logger.debug(f"Skipping file generation for unnamed schema: {schema_ir.type}")
139
+ pass # Schema has no name, already warned in _generate_model_file or skipped if truly unnamed
140
+
141
+ init_py_path = self._generate_init_py(models_dir)
142
+ generated_files.append(init_py_path)
143
+ return generated_files
@@ -0,0 +1,51 @@
1
+ import tempfile
2
+ import traceback
3
+ from pathlib import Path
4
+
5
+ from pyopenapi_gen import IRSpec
6
+ from pyopenapi_gen.context.render_context import RenderContext
7
+
8
+ from ..visit.client_visitor import ClientVisitor
9
+
10
+ # NOTE: ClientConfig and transports are only referenced in template strings, not at runtime
11
+ # hence we avoid importing config and http_transport modules to prevent runtime errors
12
+
13
+ # Jinja template for base async client file with tag-specific clients
14
+ # CLIENT_TEMPLATE = ''' ... removed ... '''
15
+
16
+
17
+ class ClientEmitter:
18
+ """Generates core client files (client.py) from IRSpec using visitor/context."""
19
+
20
+ def __init__(self, context: RenderContext) -> None:
21
+ self.visitor = ClientVisitor()
22
+ self.context = context
23
+
24
+ def emit(self, spec: IRSpec, output_dir_str: str) -> list[str]:
25
+ error_log = Path(tempfile.gettempdir()) / "pyopenapi_gen_error.log"
26
+ generated_files = []
27
+ try:
28
+ output_dir_abs = Path(output_dir_str)
29
+ output_dir_abs.mkdir(parents=True, exist_ok=True)
30
+
31
+ client_path = output_dir_abs / "client.py"
32
+
33
+ self.context.set_current_file(str(client_path))
34
+
35
+ client_code = self.visitor.visit(spec, self.context)
36
+ imports_code = self.context.render_imports()
37
+ file_content = imports_code + "\n\n" + client_code
38
+
39
+ self.context.file_manager.write_file(str(client_path), file_content)
40
+ generated_files.append(str(client_path))
41
+
42
+ pytyped_path = output_dir_abs / "py.typed"
43
+ if not pytyped_path.exists():
44
+ self.context.file_manager.write_file(str(pytyped_path), "")
45
+ generated_files.append(str(pytyped_path))
46
+ return generated_files
47
+ except Exception as e:
48
+ with open(error_log, "a") as f:
49
+ f.write(f"ERROR in ClientEmitter.emit: {e}\n")
50
+ f.write(traceback.format_exc())
51
+ raise
@@ -0,0 +1,181 @@
1
+ import importlib.resources
2
+ import os
3
+ from typing import List, Optional
4
+
5
+ from pyopenapi_gen.context.file_manager import FileManager
6
+
7
+ # Each tuple: (module, filename, destination)
8
+ RUNTIME_FILES = [
9
+ ("pyopenapi_gen.core", "http_transport.py", "core/http_transport.py"),
10
+ ("pyopenapi_gen.core", "exceptions.py", "core/exceptions.py"),
11
+ ("pyopenapi_gen.core", "streaming_helpers.py", "core/streaming_helpers.py"),
12
+ ("pyopenapi_gen.core", "pagination.py", "core/pagination.py"),
13
+ ("pyopenapi_gen.core", "schemas.py", "core/schemas.py"),
14
+ ("pyopenapi_gen.core", "utils.py", "core/utils.py"),
15
+ ("pyopenapi_gen.core.auth", "base.py", "core/auth/base.py"),
16
+ ("pyopenapi_gen.core.auth", "plugins.py", "core/auth/plugins.py"),
17
+ ]
18
+
19
+ # +++ Add template README location +++
20
+ CORE_README_TEMPLATE_MODULE = "pyopenapi_gen.core_package_template"
21
+ CORE_README_TEMPLATE_FILENAME = "README.md"
22
+
23
+ CONFIG_TEMPLATE = """
24
+ from dataclasses import dataclass
25
+ from typing import Optional
26
+
27
+ @dataclass
28
+ class ClientConfig:
29
+ base_url: str
30
+ timeout: Optional[float] = 30.0
31
+ """
32
+
33
+
34
+ class CoreEmitter:
35
+ """Copies all required runtime files into the generated core module."""
36
+
37
+ def __init__(
38
+ self, core_dir: str = "core", core_package: str = "core", exception_alias_names: Optional[List[str]] = None
39
+ ):
40
+ # core_dir is the relative path WITHIN the output package, e.g., "core" or "shared/core"
41
+ # core_package is the Python import name, e.g., "core" or "shared.core"
42
+ self.core_dir_name = os.path.basename(core_dir) # e.g., "core"
43
+ self.core_dir_relative = core_dir # e.g., "core" or "shared/core"
44
+ self.core_package = core_package
45
+ self.exception_alias_names = exception_alias_names if exception_alias_names is not None else []
46
+ self.file_manager = FileManager()
47
+
48
+ def emit(self, package_output_dir: str) -> list[str]:
49
+ """
50
+ Emits the core files into the specified core directory within the package output directory.
51
+ Args:
52
+ package_output_dir: The root directory where the generated package is being placed.
53
+ e.g., /path/to/gen/my_client
54
+ Returns:
55
+ List of generated file paths relative to the workspace root.
56
+ """
57
+ # Determine the absolute path for the core directory, e.g., /path/to/gen/my_client/core
58
+ actual_core_dir = os.path.join(package_output_dir, self.core_dir_relative)
59
+
60
+ generated_files = []
61
+ # Ensure the core directory exists (e.g., my_client/core or my_client/shared/core)
62
+ self.file_manager.ensure_dir(actual_core_dir)
63
+
64
+ for module, filename, rel_dst in RUNTIME_FILES:
65
+ # rel_dst is like "core/http_transport.py" or "core/auth/base.py"
66
+ # We want the part after "core/", e.g., "http_transport.py" or "auth/base.py"
67
+ # And join it with the actual_core_dir
68
+ destination_relative_to_core = rel_dst.replace("core/", "", 1)
69
+ dst = os.path.join(actual_core_dir, destination_relative_to_core)
70
+
71
+ self.file_manager.ensure_dir(os.path.dirname(dst))
72
+ # Use importlib.resources to read the file from the package
73
+ try:
74
+ # Read from pyopenapi_gen.core... or pyopenapi_gen.core.auth...
75
+ with importlib.resources.files(module).joinpath(filename).open("r") as f:
76
+ content = f.read()
77
+ self.file_manager.write_file(dst, content)
78
+ generated_files.append(dst)
79
+ except FileNotFoundError:
80
+ print(f"Warning: Could not find runtime file {filename} in module {module}. Skipping.")
81
+
82
+ # Always create __init__.py files for core and subfolders within the actual core dir
83
+ core_init_path = os.path.join(actual_core_dir, "__init__.py")
84
+ core_init_content = [
85
+ "# Re-export core exceptions and generated aliases",
86
+ "from .exceptions import HTTPError, ClientError, ServerError",
87
+ "from .exception_aliases import * # noqa: F403",
88
+ "",
89
+ "# Re-export other commonly used core components",
90
+ "from .http_transport import HttpTransport, HttpxTransport",
91
+ "from .config import ClientConfig",
92
+ "from .schemas import BaseSchema",
93
+ "from .utils import DataclassSerializer",
94
+ "from .auth.base import BaseAuth",
95
+ "from .auth.plugins import ApiKeyAuth, BearerAuth, OAuth2Auth",
96
+ "",
97
+ "__all__ = [",
98
+ " # Base exceptions",
99
+ ' "HTTPError",',
100
+ ' "ClientError",',
101
+ ' "ServerError",',
102
+ " # All ErrorXXX from exception_aliases are implicitly in __all__ due to star import",
103
+ "",
104
+ " # Transport layer",
105
+ ' "HttpTransport",',
106
+ ' "HttpxTransport",',
107
+ "",
108
+ " # Configuration",
109
+ ' "ClientConfig",',
110
+ "",
111
+ " # Schemas",
112
+ ' "BaseSchema",',
113
+ "",
114
+ " # Utilities",
115
+ ' "DataclassSerializer",',
116
+ "",
117
+ " # Authentication",
118
+ ' "BaseAuth",',
119
+ ' "ApiKeyAuth",',
120
+ ' "BearerAuth",',
121
+ ' "OAuth2Auth",',
122
+ ]
123
+ # Add discovered exception alias names to __all__
124
+ if self.exception_alias_names:
125
+ core_init_content.append(" # Generated exception aliases")
126
+ for alias_name in sorted(list(set(self.exception_alias_names))): # Sort and unique
127
+ core_init_content.append(f' "{alias_name}",')
128
+
129
+ core_init_content.append("]")
130
+
131
+ self.file_manager.write_file(core_init_path, "\n".join(core_init_content))
132
+ generated_files.append(core_init_path)
133
+
134
+ auth_dir = os.path.join(actual_core_dir, "auth")
135
+ if os.path.exists(auth_dir): # Only create auth/__init__.py if auth files were copied
136
+ auth_init_path = os.path.join(auth_dir, "__init__.py")
137
+ self.file_manager.ensure_dir(os.path.dirname(auth_init_path))
138
+ auth_init_content = [
139
+ "# Core Auth __init__",
140
+ "from .base import BaseAuth",
141
+ "from .plugins import ApiKeyAuth, BearerAuth, OAuth2Auth",
142
+ "",
143
+ "__all__ = [",
144
+ ' "BaseAuth",',
145
+ ' "ApiKeyAuth",',
146
+ ' "BearerAuth",',
147
+ ' "OAuth2Auth",',
148
+ "]",
149
+ ]
150
+ self.file_manager.write_file(auth_init_path, "\n".join(auth_init_content) + "\n")
151
+ generated_files.append(auth_init_path)
152
+
153
+ # Ensure py.typed marker for mypy in the actual core directory
154
+ pytyped_path = os.path.join(actual_core_dir, "py.typed")
155
+ if not os.path.exists(pytyped_path):
156
+ self.file_manager.write_file(pytyped_path, "") # Create empty py.typed
157
+ generated_files.append(pytyped_path)
158
+
159
+ # Copy the core README template into the actual core directory
160
+ readme_dst = os.path.join(actual_core_dir, "README.md")
161
+ try:
162
+ with (
163
+ importlib.resources.files(CORE_README_TEMPLATE_MODULE)
164
+ .joinpath(CORE_README_TEMPLATE_FILENAME)
165
+ .open("r") as f
166
+ ):
167
+ readme_content = f.read()
168
+ self.file_manager.write_file(readme_dst, readme_content)
169
+ generated_files.append(readme_dst)
170
+ except FileNotFoundError:
171
+ print(
172
+ f"Warning: Could not find core README template {CORE_README_TEMPLATE_FILENAME} "
173
+ f"in {CORE_README_TEMPLATE_MODULE}. Skipping."
174
+ )
175
+
176
+ # Generate config.py from template inside the actual core directory
177
+ config_path = os.path.join(actual_core_dir, "config.py")
178
+ self.file_manager.write_file(config_path, CONFIG_TEMPLATE)
179
+ generated_files.append(config_path)
180
+
181
+ return generated_files