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.
- pyopenapi_gen/__init__.py +224 -0
- pyopenapi_gen/__main__.py +6 -0
- pyopenapi_gen/cli.py +62 -0
- pyopenapi_gen/context/CLAUDE.md +284 -0
- pyopenapi_gen/context/file_manager.py +52 -0
- pyopenapi_gen/context/import_collector.py +382 -0
- pyopenapi_gen/context/render_context.py +726 -0
- pyopenapi_gen/core/CLAUDE.md +224 -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/cattrs_converter.py +810 -0
- pyopenapi_gen/core/exceptions.py +20 -0
- pyopenapi_gen/core/http_status_codes.py +218 -0
- pyopenapi_gen/core/http_transport.py +222 -0
- pyopenapi_gen/core/loader/__init__.py +12 -0
- pyopenapi_gen/core/loader/loader.py +174 -0
- pyopenapi_gen/core/loader/operations/__init__.py +12 -0
- pyopenapi_gen/core/loader/operations/parser.py +161 -0
- pyopenapi_gen/core/loader/operations/post_processor.py +62 -0
- pyopenapi_gen/core/loader/operations/request_body.py +90 -0
- pyopenapi_gen/core/loader/parameters/__init__.py +10 -0
- pyopenapi_gen/core/loader/parameters/parser.py +186 -0
- pyopenapi_gen/core/loader/responses/__init__.py +10 -0
- pyopenapi_gen/core/loader/responses/parser.py +111 -0
- pyopenapi_gen/core/loader/schemas/__init__.py +11 -0
- pyopenapi_gen/core/loader/schemas/extractor.py +275 -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 +73 -0
- pyopenapi_gen/core/parsing/context.py +187 -0
- pyopenapi_gen/core/parsing/cycle_helpers.py +126 -0
- pyopenapi_gen/core/parsing/keywords/__init__.py +1 -0
- pyopenapi_gen/core/parsing/keywords/all_of_parser.py +81 -0
- pyopenapi_gen/core/parsing/keywords/any_of_parser.py +84 -0
- pyopenapi_gen/core/parsing/keywords/array_items_parser.py +72 -0
- pyopenapi_gen/core/parsing/keywords/one_of_parser.py +77 -0
- pyopenapi_gen/core/parsing/keywords/properties_parser.py +98 -0
- pyopenapi_gen/core/parsing/schema_finalizer.py +169 -0
- pyopenapi_gen/core/parsing/schema_parser.py +804 -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 +120 -0
- pyopenapi_gen/core/parsing/unified_cycle_detection.py +293 -0
- pyopenapi_gen/core/postprocess_manager.py +260 -0
- pyopenapi_gen/core/spec_fetcher.py +148 -0
- pyopenapi_gen/core/streaming_helpers.py +84 -0
- pyopenapi_gen/core/telemetry.py +69 -0
- pyopenapi_gen/core/utils.py +456 -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 +321 -0
- pyopenapi_gen/core_package_template/README.md +21 -0
- pyopenapi_gen/emit/models_emitter.py +143 -0
- pyopenapi_gen/emitters/CLAUDE.md +286 -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 +247 -0
- pyopenapi_gen/emitters/exceptions_emitter.py +187 -0
- pyopenapi_gen/emitters/mocks_emitter.py +185 -0
- pyopenapi_gen/emitters/models_emitter.py +426 -0
- pyopenapi_gen/generator/CLAUDE.md +352 -0
- pyopenapi_gen/generator/client_generator.py +567 -0
- pyopenapi_gen/generator/exceptions.py +7 -0
- pyopenapi_gen/helpers/CLAUDE.md +325 -0
- pyopenapi_gen/helpers/__init__.py +1 -0
- pyopenapi_gen/helpers/endpoint_utils.py +532 -0
- pyopenapi_gen/helpers/type_cleaner.py +334 -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 +105 -0
- pyopenapi_gen/helpers/type_resolution/named_resolver.py +172 -0
- pyopenapi_gen/helpers/type_resolution/object_resolver.py +216 -0
- pyopenapi_gen/helpers/type_resolution/primitive_resolver.py +109 -0
- pyopenapi_gen/helpers/type_resolution/resolver.py +47 -0
- pyopenapi_gen/helpers/url_utils.py +14 -0
- pyopenapi_gen/http_types.py +20 -0
- pyopenapi_gen/ir.py +165 -0
- pyopenapi_gen/py.typed +1 -0
- pyopenapi_gen/types/CLAUDE.md +140 -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 +28 -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 +177 -0
- pyopenapi_gen/types/resolvers/schema_resolver.py +498 -0
- pyopenapi_gen/types/services/__init__.py +5 -0
- pyopenapi_gen/types/services/type_service.py +165 -0
- pyopenapi_gen/types/strategies/__init__.py +5 -0
- pyopenapi_gen/types/strategies/response_strategy.py +310 -0
- pyopenapi_gen/visit/CLAUDE.md +272 -0
- pyopenapi_gen/visit/client_visitor.py +477 -0
- pyopenapi_gen/visit/docs_visitor.py +38 -0
- pyopenapi_gen/visit/endpoint/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/endpoint_visitor.py +292 -0
- pyopenapi_gen/visit/endpoint/generators/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/generators/docstring_generator.py +123 -0
- pyopenapi_gen/visit/endpoint/generators/endpoint_method_generator.py +222 -0
- pyopenapi_gen/visit/endpoint/generators/mock_generator.py +140 -0
- pyopenapi_gen/visit/endpoint/generators/overload_generator.py +252 -0
- pyopenapi_gen/visit/endpoint/generators/request_generator.py +103 -0
- pyopenapi_gen/visit/endpoint/generators/response_handler_generator.py +705 -0
- pyopenapi_gen/visit/endpoint/generators/signature_generator.py +83 -0
- pyopenapi_gen/visit/endpoint/generators/url_args_generator.py +207 -0
- pyopenapi_gen/visit/endpoint/processors/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/processors/import_analyzer.py +78 -0
- pyopenapi_gen/visit/endpoint/processors/parameter_processor.py +171 -0
- pyopenapi_gen/visit/exception_visitor.py +90 -0
- pyopenapi_gen/visit/model/__init__.py +0 -0
- pyopenapi_gen/visit/model/alias_generator.py +93 -0
- pyopenapi_gen/visit/model/dataclass_generator.py +553 -0
- pyopenapi_gen/visit/model/enum_generator.py +212 -0
- pyopenapi_gen/visit/model/model_visitor.py +198 -0
- pyopenapi_gen/visit/visitor.py +97 -0
- pyopenapi_gen-2.7.2.dist-info/METADATA +1169 -0
- pyopenapi_gen-2.7.2.dist-info/RECORD +137 -0
- pyopenapi_gen-2.7.2.dist-info/WHEEL +4 -0
- pyopenapi_gen-2.7.2.dist-info/entry_points.txt +2 -0
- pyopenapi_gen-2.7.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# emitters/ - File Organization and Output
|
|
2
|
+
|
|
3
|
+
## Why This Folder?
|
|
4
|
+
Transform visitor-generated code strings into properly structured Python packages. Handles file creation, import resolution, and package organization.
|
|
5
|
+
|
|
6
|
+
## Key Dependencies
|
|
7
|
+
- **Input**: Code strings from `../visit/` visitors
|
|
8
|
+
- **Output**: Python files in target package structure
|
|
9
|
+
- **Services**: `FileManager` from `../context/file_manager.py`
|
|
10
|
+
- **Context**: `RenderContext` for import management
|
|
11
|
+
|
|
12
|
+
## Essential Architecture
|
|
13
|
+
|
|
14
|
+
### 1. Emitter Responsibilities
|
|
15
|
+
```python
|
|
16
|
+
# Each emitter handles one aspect of the generated client
|
|
17
|
+
models_emitter.py → models/ directory with dataclasses/enums
|
|
18
|
+
endpoints_emitter.py → endpoints/ directory with operation methods
|
|
19
|
+
client_emitter.py → client.py main interface
|
|
20
|
+
core_emitter.py → core/ directory with runtime dependencies
|
|
21
|
+
exceptions_emitter.py → exceptions.py error hierarchy
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### 2. Package Structure Creation
|
|
25
|
+
```python
|
|
26
|
+
# Target structure for generated client
|
|
27
|
+
output_package/
|
|
28
|
+
├── __init__.py # Package initialization
|
|
29
|
+
├── client.py # Main client class
|
|
30
|
+
├── models/ # Data models
|
|
31
|
+
│ ├── __init__.py
|
|
32
|
+
│ ├── user.py
|
|
33
|
+
│ └── order.py
|
|
34
|
+
├── endpoints/ # Operation methods
|
|
35
|
+
│ ├── __init__.py
|
|
36
|
+
│ ├── users.py
|
|
37
|
+
│ └── orders.py
|
|
38
|
+
├── core/ # Runtime dependencies
|
|
39
|
+
│ ├── __init__.py
|
|
40
|
+
│ ├── auth/
|
|
41
|
+
│ ├── exceptions.py
|
|
42
|
+
│ └── http_transport.py
|
|
43
|
+
└── exceptions.py # Exception hierarchy
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Critical Components
|
|
47
|
+
|
|
48
|
+
### models_emitter.py
|
|
49
|
+
**Purpose**: Create models/ directory with dataclass and enum files
|
|
50
|
+
```python
|
|
51
|
+
def emit_models(self, schemas: Dict[str, IRSchema], context: RenderContext) -> None:
|
|
52
|
+
# 1. Group schemas by module (one file per schema or logical grouping)
|
|
53
|
+
# 2. Generate code for each schema using ModelVisitor
|
|
54
|
+
# 3. Create __init__.py with imports
|
|
55
|
+
# 4. Write files to models/ directory
|
|
56
|
+
|
|
57
|
+
for schema_name, schema in schemas.items():
|
|
58
|
+
module_name = self.get_module_name(schema_name)
|
|
59
|
+
file_path = self.output_path / "models" / f"{module_name}.py"
|
|
60
|
+
|
|
61
|
+
# Generate model code
|
|
62
|
+
model_code = self.model_visitor.visit_schema(schema, context)
|
|
63
|
+
|
|
64
|
+
# Write file
|
|
65
|
+
self.file_manager.write_file(file_path, model_code)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### endpoints_emitter.py
|
|
69
|
+
**Purpose**: Create endpoints/ directory with operation methods grouped by tag
|
|
70
|
+
```python
|
|
71
|
+
def emit_endpoints(self, operations: List[IROperation], context: RenderContext) -> None:
|
|
72
|
+
# 1. Group operations by OpenAPI tag
|
|
73
|
+
operations_by_tag = self.group_by_tag(operations)
|
|
74
|
+
|
|
75
|
+
# 2. Generate endpoint class for each tag
|
|
76
|
+
for tag, tag_operations in operations_by_tag.items():
|
|
77
|
+
class_name = f"{tag.capitalize()}Endpoints"
|
|
78
|
+
file_path = self.output_path / "endpoints" / f"{tag}.py"
|
|
79
|
+
|
|
80
|
+
# Generate endpoint class code
|
|
81
|
+
endpoint_code = self.endpoint_visitor.visit_tag_operations(tag_operations, context)
|
|
82
|
+
|
|
83
|
+
# Write file
|
|
84
|
+
self.file_manager.write_file(file_path, endpoint_code)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### client_emitter.py
|
|
88
|
+
**Purpose**: Create main client.py with tag-grouped properties
|
|
89
|
+
```python
|
|
90
|
+
def emit_client(self, spec: IRSpec, context: RenderContext) -> None:
|
|
91
|
+
# 1. Generate main client class
|
|
92
|
+
# 2. Create properties for each tag endpoint
|
|
93
|
+
# 3. Generate context manager methods
|
|
94
|
+
# 4. Handle authentication setup
|
|
95
|
+
|
|
96
|
+
client_code = self.client_visitor.visit_spec(spec, context)
|
|
97
|
+
self.file_manager.write_file(self.output_path / "client.py", client_code)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### core_emitter.py
|
|
101
|
+
**Purpose**: Copy runtime dependencies to core/ directory
|
|
102
|
+
```python
|
|
103
|
+
def emit_core(self, output_package: str, core_package: str) -> None:
|
|
104
|
+
# 1. Copy auth/ directory
|
|
105
|
+
# 2. Copy exceptions.py, http_transport.py, etc.
|
|
106
|
+
# 3. Update import paths for target package
|
|
107
|
+
# 4. Handle shared core vs embedded core
|
|
108
|
+
|
|
109
|
+
if self.use_shared_core:
|
|
110
|
+
# Create symlinks or references to shared core
|
|
111
|
+
pass
|
|
112
|
+
else:
|
|
113
|
+
# Copy all core files to client package
|
|
114
|
+
self.copy_core_files()
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## File Management Patterns
|
|
118
|
+
|
|
119
|
+
### 1. Import Resolution
|
|
120
|
+
```python
|
|
121
|
+
# Always resolve imports after code generation
|
|
122
|
+
def write_file_with_imports(self, file_path: Path, code: str, context: RenderContext) -> None:
|
|
123
|
+
# 1. Collect imports from context
|
|
124
|
+
imports = context.get_imports()
|
|
125
|
+
|
|
126
|
+
# 2. Sort and deduplicate imports
|
|
127
|
+
sorted_imports = self.sort_imports(imports)
|
|
128
|
+
|
|
129
|
+
# 3. Combine imports with code
|
|
130
|
+
final_code = self.combine_imports_and_code(sorted_imports, code)
|
|
131
|
+
|
|
132
|
+
# 4. Write file
|
|
133
|
+
self.file_manager.write_file(file_path, final_code)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### 2. Package Initialization
|
|
137
|
+
```python
|
|
138
|
+
# Always create __init__.py files
|
|
139
|
+
def create_package_init(self, package_path: Path, exports: List[str]) -> None:
|
|
140
|
+
init_content = []
|
|
141
|
+
|
|
142
|
+
# Add imports for all public exports
|
|
143
|
+
for export in exports:
|
|
144
|
+
init_content.append(f"from .{export} import {export}")
|
|
145
|
+
|
|
146
|
+
# Add __all__ for explicit exports
|
|
147
|
+
init_content.append(f"__all__ = {exports}")
|
|
148
|
+
|
|
149
|
+
self.file_manager.write_file(package_path / "__init__.py", "\n".join(init_content))
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### 3. Relative Import Handling
|
|
153
|
+
```python
|
|
154
|
+
# Convert absolute imports to relative for generated packages
|
|
155
|
+
def convert_to_relative_imports(self, code: str, current_package: str) -> str:
|
|
156
|
+
# Replace absolute imports with relative imports
|
|
157
|
+
# Example: "from my_client.models.user import User" → "from ..models.user import User"
|
|
158
|
+
|
|
159
|
+
import_pattern = re.compile(rf"from {re.escape(current_package)}\.(.+?) import")
|
|
160
|
+
|
|
161
|
+
def replace_import(match):
|
|
162
|
+
import_path = match.group(1)
|
|
163
|
+
depth = len(import_path.split("."))
|
|
164
|
+
relative_prefix = "." * depth
|
|
165
|
+
return f"from {relative_prefix}{import_path} import"
|
|
166
|
+
|
|
167
|
+
return import_pattern.sub(replace_import, code)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Dependencies on Other Systems
|
|
171
|
+
|
|
172
|
+
### From visit/
|
|
173
|
+
- Consumes generated code strings
|
|
174
|
+
- Coordinates with visitors for code generation
|
|
175
|
+
|
|
176
|
+
### From context/
|
|
177
|
+
- `FileManager` for file operations
|
|
178
|
+
- `RenderContext` for import management
|
|
179
|
+
- Path resolution utilities
|
|
180
|
+
|
|
181
|
+
### From core/
|
|
182
|
+
- Runtime components copied to generated clients
|
|
183
|
+
- Template files for package structure
|
|
184
|
+
|
|
185
|
+
## Testing Requirements
|
|
186
|
+
|
|
187
|
+
### File Creation Tests
|
|
188
|
+
```python
|
|
189
|
+
def test_models_emitter__simple_schema__creates_correct_file():
|
|
190
|
+
# Arrange
|
|
191
|
+
schema = IRSchema(name="User", type="object", properties={"name": {"type": "string"}})
|
|
192
|
+
emitter = ModelsEmitter(output_path="/tmp/test")
|
|
193
|
+
|
|
194
|
+
# Act
|
|
195
|
+
emitter.emit_models({"User": schema}, context)
|
|
196
|
+
|
|
197
|
+
# Assert
|
|
198
|
+
assert Path("/tmp/test/models/user.py").exists()
|
|
199
|
+
content = Path("/tmp/test/models/user.py").read_text()
|
|
200
|
+
assert "@dataclass" in content
|
|
201
|
+
assert "name: str" in content
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Import Resolution Tests
|
|
205
|
+
```python
|
|
206
|
+
def test_emitter__complex_types__resolves_imports_correctly():
|
|
207
|
+
# Test that imports are correctly collected and written
|
|
208
|
+
# Verify no duplicate imports
|
|
209
|
+
# Verify correct import sorting
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Extension Points
|
|
213
|
+
|
|
214
|
+
### Adding New Emitters
|
|
215
|
+
```python
|
|
216
|
+
# Create new emitter for new output aspects
|
|
217
|
+
class CustomEmitter:
|
|
218
|
+
def __init__(self, output_path: Path, file_manager: FileManager):
|
|
219
|
+
self.output_path = output_path
|
|
220
|
+
self.file_manager = file_manager
|
|
221
|
+
|
|
222
|
+
def emit_custom(self, data: Any, context: RenderContext) -> None:
|
|
223
|
+
# Custom file creation logic
|
|
224
|
+
pass
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Custom Package Structures
|
|
228
|
+
```python
|
|
229
|
+
# Modify emitters to create different package layouts
|
|
230
|
+
class AlternativeModelsEmitter(ModelsEmitter):
|
|
231
|
+
def get_file_path(self, schema_name: str) -> Path:
|
|
232
|
+
# Custom file organization logic
|
|
233
|
+
# Example: Group models by domain
|
|
234
|
+
domain = self.get_domain(schema_name)
|
|
235
|
+
return self.output_path / "models" / domain / f"{schema_name.lower()}.py"
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Critical Implementation Details
|
|
239
|
+
|
|
240
|
+
### File Path Resolution
|
|
241
|
+
```python
|
|
242
|
+
# Always use pathlib.Path for cross-platform compatibility
|
|
243
|
+
def get_output_path(self, package_name: str, module_name: str) -> Path:
|
|
244
|
+
# Convert package.module to file path
|
|
245
|
+
parts = package_name.split(".")
|
|
246
|
+
path = Path(self.project_root)
|
|
247
|
+
for part in parts:
|
|
248
|
+
path = path / part
|
|
249
|
+
return path / f"{module_name}.py"
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Error Handling
|
|
253
|
+
```python
|
|
254
|
+
def emit_safely(self, generator_func: Callable, context: RenderContext) -> None:
|
|
255
|
+
try:
|
|
256
|
+
generator_func(context)
|
|
257
|
+
except Exception as e:
|
|
258
|
+
# Add context to file emission errors
|
|
259
|
+
raise FileEmissionError(f"Failed to emit {self.__class__.__name__}: {e}")
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Atomic File Operations
|
|
263
|
+
```python
|
|
264
|
+
def write_file_atomically(self, file_path: Path, content: str) -> None:
|
|
265
|
+
# Write to temporary file first, then move
|
|
266
|
+
temp_path = file_path.with_suffix(f"{file_path.suffix}.tmp")
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
temp_path.write_text(content)
|
|
270
|
+
temp_path.replace(file_path) # Atomic move
|
|
271
|
+
except Exception:
|
|
272
|
+
if temp_path.exists():
|
|
273
|
+
temp_path.unlink()
|
|
274
|
+
raise
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Diff Checking
|
|
278
|
+
```python
|
|
279
|
+
def should_write_file(self, file_path: Path, new_content: str) -> bool:
|
|
280
|
+
# Only write if content changed
|
|
281
|
+
if not file_path.exists():
|
|
282
|
+
return True
|
|
283
|
+
|
|
284
|
+
existing_content = file_path.read_text()
|
|
285
|
+
return existing_content != new_content
|
|
286
|
+
```
|
|
@@ -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
|
|
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", "cattrs_converter.py", "core/cattrs_converter.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
|
+
@dataclass
|
|
26
|
+
class ClientConfig:
|
|
27
|
+
base_url: str
|
|
28
|
+
timeout: float | None = 30.0
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CoreEmitter:
|
|
33
|
+
"""Copies all required runtime files into the generated core module."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self, core_dir: str = "core", core_package: str = "core", exception_alias_names: List[str] | None = None
|
|
37
|
+
):
|
|
38
|
+
# core_dir is the relative path WITHIN the output package, e.g., "core" or "shared/core"
|
|
39
|
+
# core_package is the Python import name, e.g., "core" or "shared.core"
|
|
40
|
+
self.core_dir_name = os.path.basename(core_dir) # e.g., "core"
|
|
41
|
+
self.core_dir_relative = core_dir # e.g., "core" or "shared/core"
|
|
42
|
+
self.core_package = core_package
|
|
43
|
+
self.exception_alias_names = exception_alias_names if exception_alias_names is not None else []
|
|
44
|
+
self.file_manager = FileManager()
|
|
45
|
+
|
|
46
|
+
def emit(self, package_output_dir: str) -> list[str]:
|
|
47
|
+
"""
|
|
48
|
+
Emits the core files into the specified core directory within the package output directory.
|
|
49
|
+
Args:
|
|
50
|
+
package_output_dir: The root directory where the generated package is being placed.
|
|
51
|
+
e.g., /path/to/gen/my_client
|
|
52
|
+
Returns:
|
|
53
|
+
List of generated file paths relative to the workspace root.
|
|
54
|
+
"""
|
|
55
|
+
# Determine the absolute path for the core directory, e.g., /path/to/gen/my_client/core
|
|
56
|
+
actual_core_dir = os.path.join(package_output_dir, self.core_dir_relative)
|
|
57
|
+
|
|
58
|
+
generated_files = []
|
|
59
|
+
# Ensure the core directory exists (e.g., my_client/core or my_client/shared/core)
|
|
60
|
+
self.file_manager.ensure_dir(actual_core_dir)
|
|
61
|
+
|
|
62
|
+
for module, filename, rel_dst in RUNTIME_FILES:
|
|
63
|
+
# rel_dst is like "core/http_transport.py" or "core/auth/base.py"
|
|
64
|
+
# We want the part after "core/", e.g., "http_transport.py" or "auth/base.py"
|
|
65
|
+
# And join it with the actual_core_dir
|
|
66
|
+
destination_relative_to_core = rel_dst.replace("core/", "", 1)
|
|
67
|
+
dst = os.path.join(actual_core_dir, destination_relative_to_core)
|
|
68
|
+
|
|
69
|
+
self.file_manager.ensure_dir(os.path.dirname(dst))
|
|
70
|
+
# Use importlib.resources to read the file from the package
|
|
71
|
+
try:
|
|
72
|
+
# Read from pyopenapi_gen.core... or pyopenapi_gen.core.auth...
|
|
73
|
+
with importlib.resources.files(module).joinpath(filename).open("r") as f:
|
|
74
|
+
content = f.read()
|
|
75
|
+
self.file_manager.write_file(dst, content)
|
|
76
|
+
generated_files.append(dst)
|
|
77
|
+
except FileNotFoundError:
|
|
78
|
+
print(f"Warning: Could not find runtime file {filename} in module {module}. Skipping.")
|
|
79
|
+
|
|
80
|
+
# Always create __init__.py files for core and subfolders within the actual core dir
|
|
81
|
+
core_init_path = os.path.join(actual_core_dir, "__init__.py")
|
|
82
|
+
core_init_content = [
|
|
83
|
+
"# Re-export core exceptions and generated aliases",
|
|
84
|
+
"from .exceptions import HTTPError, ClientError, ServerError",
|
|
85
|
+
"from .exception_aliases import * # noqa: F403",
|
|
86
|
+
"",
|
|
87
|
+
"# Re-export other commonly used core components",
|
|
88
|
+
"from .http_transport import HttpTransport, HttpxTransport",
|
|
89
|
+
"from .config import ClientConfig",
|
|
90
|
+
"from .cattrs_converter import structure_from_dict, unstructure_to_dict, converter",
|
|
91
|
+
"from .utils import DataclassSerializer",
|
|
92
|
+
"from .auth.base import BaseAuth",
|
|
93
|
+
"from .auth.plugins import ApiKeyAuth, BearerAuth, OAuth2Auth",
|
|
94
|
+
"",
|
|
95
|
+
"__all__ = [",
|
|
96
|
+
" # Base exceptions",
|
|
97
|
+
' "HTTPError",',
|
|
98
|
+
' "ClientError",',
|
|
99
|
+
' "ServerError",',
|
|
100
|
+
" # All ErrorXXX from exception_aliases are implicitly in __all__ due to star import",
|
|
101
|
+
"",
|
|
102
|
+
" # Transport layer",
|
|
103
|
+
' "HttpTransport",',
|
|
104
|
+
' "HttpxTransport",',
|
|
105
|
+
"",
|
|
106
|
+
" # Configuration",
|
|
107
|
+
' "ClientConfig",',
|
|
108
|
+
"",
|
|
109
|
+
" # Serialization (cattrs)",
|
|
110
|
+
' "structure_from_dict",',
|
|
111
|
+
' "unstructure_to_dict",',
|
|
112
|
+
' "converter",',
|
|
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
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from pyopenapi_gen import IRSpec
|
|
4
|
+
from pyopenapi_gen.context.render_context import RenderContext
|
|
5
|
+
|
|
6
|
+
from ..visit.docs_visitor import DocsVisitor
|
|
7
|
+
|
|
8
|
+
"""Simple documentation emitter using markdown with Python str.format placeholders."""
|
|
9
|
+
DOCS_INDEX_TEMPLATE = """# API Documentation
|
|
10
|
+
|
|
11
|
+
Generated documentation for the API.
|
|
12
|
+
|
|
13
|
+
## Tags
|
|
14
|
+
{tags_list}
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
DOCS_TAG_TEMPLATE = """# {tag} Operations
|
|
18
|
+
|
|
19
|
+
{operations_list}
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
DOCS_OPERATION_TEMPLATE = """### {operation_id}
|
|
23
|
+
|
|
24
|
+
**Method:** `{method}`
|
|
25
|
+
**Path:** `{path}`
|
|
26
|
+
|
|
27
|
+
{description}
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DocsEmitter:
|
|
32
|
+
"""Generates markdown documentation per tag from IRSpec using visitor/context."""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self.visitor = DocsVisitor()
|
|
36
|
+
|
|
37
|
+
def emit(self, spec: IRSpec, output_dir: str) -> None:
|
|
38
|
+
"""Render docs into <output_dir> as markdown files."""
|
|
39
|
+
docs_dir = os.path.join(output_dir)
|
|
40
|
+
context = RenderContext()
|
|
41
|
+
context.file_manager.ensure_dir(docs_dir)
|
|
42
|
+
docs = self.visitor.visit(spec, context)
|
|
43
|
+
for filename, content in docs.items():
|
|
44
|
+
context.file_manager.write_file(os.path.join(docs_dir, filename), content)
|