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.
- pyopenapi_gen/__init__.py +114 -0
- pyopenapi_gen/__main__.py +6 -0
- pyopenapi_gen/cli.py +86 -0
- pyopenapi_gen/context/file_manager.py +52 -0
- pyopenapi_gen/context/import_collector.py +382 -0
- pyopenapi_gen/context/render_context.py +630 -0
- pyopenapi_gen/core/__init__.py +0 -0
- pyopenapi_gen/core/auth/base.py +22 -0
- pyopenapi_gen/core/auth/plugins.py +89 -0
- pyopenapi_gen/core/exceptions.py +25 -0
- pyopenapi_gen/core/http_transport.py +219 -0
- pyopenapi_gen/core/loader/__init__.py +12 -0
- pyopenapi_gen/core/loader/loader.py +158 -0
- pyopenapi_gen/core/loader/operations/__init__.py +12 -0
- pyopenapi_gen/core/loader/operations/parser.py +155 -0
- pyopenapi_gen/core/loader/operations/post_processor.py +60 -0
- pyopenapi_gen/core/loader/operations/request_body.py +85 -0
- pyopenapi_gen/core/loader/parameters/__init__.py +10 -0
- pyopenapi_gen/core/loader/parameters/parser.py +121 -0
- pyopenapi_gen/core/loader/responses/__init__.py +10 -0
- pyopenapi_gen/core/loader/responses/parser.py +104 -0
- pyopenapi_gen/core/loader/schemas/__init__.py +11 -0
- pyopenapi_gen/core/loader/schemas/extractor.py +184 -0
- pyopenapi_gen/core/pagination.py +64 -0
- pyopenapi_gen/core/parsing/__init__.py +13 -0
- pyopenapi_gen/core/parsing/common/__init__.py +1 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/__init__.py +9 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/__init__.py +0 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/cyclic_properties.py +66 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/direct_cycle.py +33 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/existing_schema.py +22 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/list_response.py +54 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/missing_ref.py +52 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/new_schema.py +50 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/stripped_suffix.py +51 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/resolve_schema_ref.py +86 -0
- pyopenapi_gen/core/parsing/common/type_parser.py +74 -0
- pyopenapi_gen/core/parsing/context.py +184 -0
- pyopenapi_gen/core/parsing/cycle_helpers.py +123 -0
- pyopenapi_gen/core/parsing/keywords/__init__.py +1 -0
- pyopenapi_gen/core/parsing/keywords/all_of_parser.py +77 -0
- pyopenapi_gen/core/parsing/keywords/any_of_parser.py +79 -0
- pyopenapi_gen/core/parsing/keywords/array_items_parser.py +69 -0
- pyopenapi_gen/core/parsing/keywords/one_of_parser.py +72 -0
- pyopenapi_gen/core/parsing/keywords/properties_parser.py +98 -0
- pyopenapi_gen/core/parsing/schema_finalizer.py +166 -0
- pyopenapi_gen/core/parsing/schema_parser.py +610 -0
- pyopenapi_gen/core/parsing/transformers/__init__.py +0 -0
- pyopenapi_gen/core/parsing/transformers/inline_enum_extractor.py +285 -0
- pyopenapi_gen/core/parsing/transformers/inline_object_promoter.py +117 -0
- pyopenapi_gen/core/parsing/unified_cycle_detection.py +293 -0
- pyopenapi_gen/core/postprocess_manager.py +161 -0
- pyopenapi_gen/core/schemas.py +40 -0
- pyopenapi_gen/core/streaming_helpers.py +86 -0
- pyopenapi_gen/core/telemetry.py +67 -0
- pyopenapi_gen/core/utils.py +409 -0
- pyopenapi_gen/core/warning_collector.py +83 -0
- pyopenapi_gen/core/writers/code_writer.py +135 -0
- pyopenapi_gen/core/writers/documentation_writer.py +222 -0
- pyopenapi_gen/core/writers/line_writer.py +217 -0
- pyopenapi_gen/core/writers/python_construct_renderer.py +274 -0
- pyopenapi_gen/core_package_template/README.md +21 -0
- pyopenapi_gen/emit/models_emitter.py +143 -0
- pyopenapi_gen/emitters/client_emitter.py +51 -0
- pyopenapi_gen/emitters/core_emitter.py +181 -0
- pyopenapi_gen/emitters/docs_emitter.py +44 -0
- pyopenapi_gen/emitters/endpoints_emitter.py +223 -0
- pyopenapi_gen/emitters/exceptions_emitter.py +52 -0
- pyopenapi_gen/emitters/models_emitter.py +428 -0
- pyopenapi_gen/generator/client_generator.py +562 -0
- pyopenapi_gen/helpers/__init__.py +1 -0
- pyopenapi_gen/helpers/endpoint_utils.py +552 -0
- pyopenapi_gen/helpers/type_cleaner.py +341 -0
- pyopenapi_gen/helpers/type_helper.py +112 -0
- pyopenapi_gen/helpers/type_resolution/__init__.py +1 -0
- pyopenapi_gen/helpers/type_resolution/array_resolver.py +57 -0
- pyopenapi_gen/helpers/type_resolution/composition_resolver.py +79 -0
- pyopenapi_gen/helpers/type_resolution/finalizer.py +89 -0
- pyopenapi_gen/helpers/type_resolution/named_resolver.py +174 -0
- pyopenapi_gen/helpers/type_resolution/object_resolver.py +212 -0
- pyopenapi_gen/helpers/type_resolution/primitive_resolver.py +57 -0
- pyopenapi_gen/helpers/type_resolution/resolver.py +48 -0
- pyopenapi_gen/helpers/url_utils.py +14 -0
- pyopenapi_gen/http_types.py +20 -0
- pyopenapi_gen/ir.py +167 -0
- pyopenapi_gen/py.typed +1 -0
- pyopenapi_gen/types/__init__.py +11 -0
- pyopenapi_gen/types/contracts/__init__.py +13 -0
- pyopenapi_gen/types/contracts/protocols.py +106 -0
- pyopenapi_gen/types/contracts/types.py +30 -0
- pyopenapi_gen/types/resolvers/__init__.py +7 -0
- pyopenapi_gen/types/resolvers/reference_resolver.py +71 -0
- pyopenapi_gen/types/resolvers/response_resolver.py +203 -0
- pyopenapi_gen/types/resolvers/schema_resolver.py +367 -0
- pyopenapi_gen/types/services/__init__.py +5 -0
- pyopenapi_gen/types/services/type_service.py +133 -0
- pyopenapi_gen/visit/client_visitor.py +228 -0
- pyopenapi_gen/visit/docs_visitor.py +38 -0
- pyopenapi_gen/visit/endpoint/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/endpoint_visitor.py +103 -0
- pyopenapi_gen/visit/endpoint/generators/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/generators/docstring_generator.py +121 -0
- pyopenapi_gen/visit/endpoint/generators/endpoint_method_generator.py +87 -0
- pyopenapi_gen/visit/endpoint/generators/request_generator.py +103 -0
- pyopenapi_gen/visit/endpoint/generators/response_handler_generator.py +497 -0
- pyopenapi_gen/visit/endpoint/generators/signature_generator.py +88 -0
- pyopenapi_gen/visit/endpoint/generators/url_args_generator.py +183 -0
- pyopenapi_gen/visit/endpoint/processors/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/processors/import_analyzer.py +76 -0
- pyopenapi_gen/visit/endpoint/processors/parameter_processor.py +171 -0
- pyopenapi_gen/visit/exception_visitor.py +52 -0
- pyopenapi_gen/visit/model/__init__.py +0 -0
- pyopenapi_gen/visit/model/alias_generator.py +89 -0
- pyopenapi_gen/visit/model/dataclass_generator.py +197 -0
- pyopenapi_gen/visit/model/enum_generator.py +200 -0
- pyopenapi_gen/visit/model/model_visitor.py +197 -0
- pyopenapi_gen/visit/visitor.py +97 -0
- pyopenapi_gen-0.8.3.dist-info/METADATA +224 -0
- pyopenapi_gen-0.8.3.dist-info/RECORD +122 -0
- pyopenapi_gen-0.8.3.dist-info/WHEEL +4 -0
- pyopenapi_gen-0.8.3.dist-info/entry_points.txt +2 -0
- 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
|