otterapi 0.0.5__py3-none-any.whl → 0.0.6__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.
- README.md +581 -8
- otterapi/__init__.py +73 -0
- otterapi/cli.py +327 -29
- otterapi/codegen/__init__.py +115 -0
- otterapi/codegen/ast_utils.py +134 -5
- otterapi/codegen/client.py +1271 -0
- otterapi/codegen/codegen.py +1736 -0
- otterapi/codegen/dataframes.py +392 -0
- otterapi/codegen/emitter.py +473 -0
- otterapi/codegen/endpoints.py +2597 -343
- otterapi/codegen/pagination.py +1026 -0
- otterapi/codegen/schema.py +593 -0
- otterapi/codegen/splitting.py +1397 -0
- otterapi/codegen/types.py +1345 -0
- otterapi/codegen/utils.py +180 -1
- otterapi/config.py +1017 -24
- otterapi/exceptions.py +231 -0
- otterapi/openapi/__init__.py +46 -0
- otterapi/openapi/v2/__init__.py +86 -0
- otterapi/openapi/v2/spec.json +1607 -0
- otterapi/openapi/v2/v2.py +1776 -0
- otterapi/openapi/v3/__init__.py +131 -0
- otterapi/openapi/v3/spec.json +1651 -0
- otterapi/openapi/v3/v3.py +1557 -0
- otterapi/openapi/v3_1/__init__.py +133 -0
- otterapi/openapi/v3_1/spec.json +1411 -0
- otterapi/openapi/v3_1/v3_1.py +798 -0
- otterapi/openapi/v3_2/__init__.py +133 -0
- otterapi/openapi/v3_2/spec.json +1666 -0
- otterapi/openapi/v3_2/v3_2.py +777 -0
- otterapi/tests/__init__.py +3 -0
- otterapi/tests/fixtures/__init__.py +455 -0
- otterapi/tests/test_ast_utils.py +680 -0
- otterapi/tests/test_codegen.py +610 -0
- otterapi/tests/test_dataframe.py +1038 -0
- otterapi/tests/test_exceptions.py +493 -0
- otterapi/tests/test_openapi_support.py +616 -0
- otterapi/tests/test_openapi_upgrade.py +215 -0
- otterapi/tests/test_pagination.py +1101 -0
- otterapi/tests/test_splitting_config.py +319 -0
- otterapi/tests/test_splitting_integration.py +427 -0
- otterapi/tests/test_splitting_resolver.py +512 -0
- otterapi/tests/test_splitting_tree.py +525 -0
- otterapi-0.0.6.dist-info/METADATA +627 -0
- otterapi-0.0.6.dist-info/RECORD +48 -0
- {otterapi-0.0.5.dist-info → otterapi-0.0.6.dist-info}/WHEEL +1 -1
- otterapi/codegen/generator.py +0 -358
- otterapi/codegen/openapi_processor.py +0 -27
- otterapi/codegen/type_generator.py +0 -559
- otterapi-0.0.5.dist-info/METADATA +0 -54
- otterapi-0.0.5.dist-info/RECORD +0 -16
- {otterapi-0.0.5.dist-info → otterapi-0.0.6.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""Code emitter interfaces and implementations for code generation output.
|
|
2
|
+
|
|
3
|
+
This module provides the CodeEmitter interface and concrete implementations
|
|
4
|
+
for emitting generated code in different formats (Python files, strings, etc.).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from upath import UPath
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from otterapi.codegen.types import Type
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CodeEmitter(ABC):
|
|
19
|
+
"""Abstract base class for code emitters.
|
|
20
|
+
|
|
21
|
+
A CodeEmitter is responsible for taking generated AST nodes and
|
|
22
|
+
outputting them in a specific format (files, strings, etc.).
|
|
23
|
+
|
|
24
|
+
Implementations can handle formatting, validation, and writing
|
|
25
|
+
of generated code.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def emit_module(
|
|
30
|
+
self,
|
|
31
|
+
body: list[ast.stmt],
|
|
32
|
+
name: str,
|
|
33
|
+
docstring: str | None = None,
|
|
34
|
+
) -> str | None:
|
|
35
|
+
"""Emit a complete Python module.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
body: List of AST statements forming the module body.
|
|
39
|
+
name: The module name (used for file naming or identification).
|
|
40
|
+
docstring: Optional module-level docstring.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The path to the emitted file, or the code string, depending
|
|
44
|
+
on the implementation. May return None if emission fails.
|
|
45
|
+
"""
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def emit_models(
|
|
50
|
+
self,
|
|
51
|
+
types: list['Type'],
|
|
52
|
+
module_name: str = 'models',
|
|
53
|
+
) -> str | None:
|
|
54
|
+
"""Emit a module containing model definitions.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
types: List of Type objects to emit.
|
|
58
|
+
module_name: Name for the models module.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The path or code string for the emitted models.
|
|
62
|
+
"""
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def emit_endpoints(
|
|
67
|
+
self,
|
|
68
|
+
body: list[ast.stmt],
|
|
69
|
+
module_name: str = 'endpoints',
|
|
70
|
+
) -> str | None:
|
|
71
|
+
"""Emit a module containing endpoint functions.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
body: List of AST statements for endpoint functions.
|
|
75
|
+
module_name: Name for the endpoints module.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The path or code string for the emitted endpoints.
|
|
79
|
+
"""
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FileEmitter(CodeEmitter):
|
|
84
|
+
"""Emits generated code to Python files on disk.
|
|
85
|
+
|
|
86
|
+
This emitter writes generated code to files in a specified output
|
|
87
|
+
directory, handling directory creation and file formatting.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
output_dir: str | Path | UPath,
|
|
93
|
+
format_code: bool = True,
|
|
94
|
+
validate_syntax: bool = True,
|
|
95
|
+
create_init: bool = True,
|
|
96
|
+
):
|
|
97
|
+
"""Initialize the file emitter.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
output_dir: Directory where files will be written.
|
|
101
|
+
format_code: Whether to format code with black/ruff (if available).
|
|
102
|
+
validate_syntax: Whether to validate Python syntax before writing.
|
|
103
|
+
create_init: Whether to create an __init__.py file.
|
|
104
|
+
"""
|
|
105
|
+
self.output_dir = UPath(output_dir)
|
|
106
|
+
self.format_code = format_code
|
|
107
|
+
self.validate_syntax = validate_syntax
|
|
108
|
+
self.create_init = create_init
|
|
109
|
+
self._written_files: list[str] = []
|
|
110
|
+
|
|
111
|
+
def emit_module(
|
|
112
|
+
self,
|
|
113
|
+
body: list[ast.stmt],
|
|
114
|
+
name: str,
|
|
115
|
+
docstring: str | None = None,
|
|
116
|
+
) -> str | None:
|
|
117
|
+
"""Emit a complete Python module to a file.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
body: List of AST statements forming the module body.
|
|
121
|
+
name: The module name (used for file naming).
|
|
122
|
+
docstring: Optional module-level docstring.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The path to the written file, or None if emission fails.
|
|
126
|
+
"""
|
|
127
|
+
# Add docstring if provided
|
|
128
|
+
if docstring:
|
|
129
|
+
doc_node = ast.Expr(value=ast.Constant(value=docstring))
|
|
130
|
+
body = [doc_node] + body
|
|
131
|
+
|
|
132
|
+
# Create module AST
|
|
133
|
+
module = ast.Module(body=body, type_ignores=[])
|
|
134
|
+
ast.fix_missing_locations(module)
|
|
135
|
+
|
|
136
|
+
# Convert to source code
|
|
137
|
+
try:
|
|
138
|
+
source = ast.unparse(module)
|
|
139
|
+
except Exception as e:
|
|
140
|
+
raise RuntimeError(f'Failed to unparse AST for module {name}: {e}')
|
|
141
|
+
|
|
142
|
+
# Validate syntax if requested
|
|
143
|
+
if self.validate_syntax:
|
|
144
|
+
self._validate_syntax(source, name)
|
|
145
|
+
|
|
146
|
+
# Format code if requested
|
|
147
|
+
if self.format_code:
|
|
148
|
+
source = self._format_source(source)
|
|
149
|
+
|
|
150
|
+
# Write to file
|
|
151
|
+
return self._write_file(f'{name}.py', source)
|
|
152
|
+
|
|
153
|
+
def emit_models(
|
|
154
|
+
self,
|
|
155
|
+
types: list['Type'],
|
|
156
|
+
module_name: str = 'models',
|
|
157
|
+
) -> str | None:
|
|
158
|
+
"""Emit a module containing model definitions.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
types: List of Type objects to emit.
|
|
162
|
+
module_name: Name for the models module.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
The path to the written models file.
|
|
166
|
+
"""
|
|
167
|
+
# Collect all AST nodes and imports
|
|
168
|
+
body: list[ast.stmt] = []
|
|
169
|
+
imports: dict[str, set[str]] = {}
|
|
170
|
+
|
|
171
|
+
for type_obj in types:
|
|
172
|
+
if type_obj.implementation_ast:
|
|
173
|
+
body.append(type_obj.implementation_ast)
|
|
174
|
+
|
|
175
|
+
# Collect imports from type
|
|
176
|
+
if hasattr(type_obj, 'implementation_imports'):
|
|
177
|
+
for module, names in type_obj.implementation_imports.items():
|
|
178
|
+
if module not in imports:
|
|
179
|
+
imports[module] = set()
|
|
180
|
+
imports[module].update(names)
|
|
181
|
+
|
|
182
|
+
# Build import statements
|
|
183
|
+
import_stmts = self._build_imports(imports)
|
|
184
|
+
|
|
185
|
+
# Combine imports and body
|
|
186
|
+
full_body = import_stmts + body
|
|
187
|
+
|
|
188
|
+
return self.emit_module(
|
|
189
|
+
full_body, module_name, docstring='Generated models from OpenAPI schema.'
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def emit_endpoints(
|
|
193
|
+
self,
|
|
194
|
+
body: list[ast.stmt],
|
|
195
|
+
module_name: str = 'endpoints',
|
|
196
|
+
) -> str | None:
|
|
197
|
+
"""Emit a module containing endpoint functions.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
body: List of AST statements for endpoint functions.
|
|
201
|
+
module_name: Name for the endpoints module.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
The path to the written endpoints file.
|
|
205
|
+
"""
|
|
206
|
+
return self.emit_module(
|
|
207
|
+
body, module_name, docstring='Generated API endpoints from OpenAPI schema.'
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def emit_init(self, exports: list[str] | None = None) -> str | None:
|
|
211
|
+
"""Emit an __init__.py file.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
exports: Optional list of names to include in __all__.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
The path to the written __init__.py file.
|
|
218
|
+
"""
|
|
219
|
+
body: list[ast.stmt] = []
|
|
220
|
+
|
|
221
|
+
if exports:
|
|
222
|
+
# Create __all__ = [...]
|
|
223
|
+
all_assign = ast.Assign(
|
|
224
|
+
targets=[ast.Name(id='__all__', ctx=ast.Store())],
|
|
225
|
+
value=ast.List(
|
|
226
|
+
elts=[ast.Constant(value=name) for name in exports],
|
|
227
|
+
ctx=ast.Load(),
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
body.append(all_assign)
|
|
231
|
+
|
|
232
|
+
if not body:
|
|
233
|
+
# Empty __init__.py
|
|
234
|
+
return self._write_file('__init__.py', '')
|
|
235
|
+
|
|
236
|
+
module = ast.Module(body=body, type_ignores=[])
|
|
237
|
+
ast.fix_missing_locations(module)
|
|
238
|
+
source = ast.unparse(module)
|
|
239
|
+
|
|
240
|
+
return self._write_file('__init__.py', source)
|
|
241
|
+
|
|
242
|
+
def emit_py_typed(self) -> str | None:
|
|
243
|
+
"""Emit a py.typed marker file for PEP 561.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
The path to the written py.typed file.
|
|
247
|
+
"""
|
|
248
|
+
return self._write_file('py.typed', '')
|
|
249
|
+
|
|
250
|
+
def _write_file(self, filename: str, content: str) -> str:
|
|
251
|
+
"""Write content to a file in the output directory.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
filename: Name of the file to write.
|
|
255
|
+
content: Content to write to the file.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
The path to the written file.
|
|
259
|
+
"""
|
|
260
|
+
# Ensure output directory exists
|
|
261
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
262
|
+
|
|
263
|
+
file_path = self.output_dir / filename
|
|
264
|
+
file_path.write_text(content, encoding='utf-8')
|
|
265
|
+
self._written_files.append(str(file_path))
|
|
266
|
+
|
|
267
|
+
return str(file_path)
|
|
268
|
+
|
|
269
|
+
def _validate_syntax(self, source: str, name: str) -> None:
|
|
270
|
+
"""Validate that source code has valid Python syntax.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
source: The source code to validate.
|
|
274
|
+
name: Name of the module (for error messages).
|
|
275
|
+
|
|
276
|
+
Raises:
|
|
277
|
+
SyntaxError: If the source code is not valid Python.
|
|
278
|
+
"""
|
|
279
|
+
try:
|
|
280
|
+
compile(source, f'{name}.py', 'exec')
|
|
281
|
+
except SyntaxError as e:
|
|
282
|
+
raise SyntaxError(f'Generated code for {name} has invalid syntax: {e}')
|
|
283
|
+
|
|
284
|
+
def _format_source(self, source: str) -> str:
|
|
285
|
+
"""Format source code using black or ruff if available.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
source: The source code to format.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
Formatted source code, or original if formatters unavailable.
|
|
292
|
+
"""
|
|
293
|
+
# Try ruff first
|
|
294
|
+
try:
|
|
295
|
+
import subprocess
|
|
296
|
+
|
|
297
|
+
result = subprocess.run(
|
|
298
|
+
['ruff', 'format', '-'],
|
|
299
|
+
input=source,
|
|
300
|
+
capture_output=True,
|
|
301
|
+
text=True,
|
|
302
|
+
)
|
|
303
|
+
if result.returncode == 0:
|
|
304
|
+
return result.stdout
|
|
305
|
+
except (FileNotFoundError, subprocess.SubprocessError):
|
|
306
|
+
pass
|
|
307
|
+
|
|
308
|
+
# Try black
|
|
309
|
+
try:
|
|
310
|
+
import black
|
|
311
|
+
|
|
312
|
+
return black.format_str(source, mode=black.Mode())
|
|
313
|
+
except ImportError:
|
|
314
|
+
pass
|
|
315
|
+
except Exception:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
# Return original if no formatter available
|
|
319
|
+
return source
|
|
320
|
+
|
|
321
|
+
def _build_imports(self, imports: dict[str, set[str]]) -> list[ast.ImportFrom]:
|
|
322
|
+
"""Build import statements from an imports dictionary.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
imports: Dictionary mapping module names to sets of names to import.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
List of ImportFrom AST nodes.
|
|
329
|
+
"""
|
|
330
|
+
import_stmts = []
|
|
331
|
+
for module, names in sorted(imports.items()):
|
|
332
|
+
import_stmt = ast.ImportFrom(
|
|
333
|
+
module=module,
|
|
334
|
+
names=[ast.alias(name=name, asname=None) for name in sorted(names)],
|
|
335
|
+
level=0,
|
|
336
|
+
)
|
|
337
|
+
import_stmts.append(import_stmt)
|
|
338
|
+
return import_stmts
|
|
339
|
+
|
|
340
|
+
def get_written_files(self) -> list[str]:
|
|
341
|
+
"""Get list of all files written by this emitter.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
List of file paths that have been written.
|
|
345
|
+
"""
|
|
346
|
+
return self._written_files.copy()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class StringEmitter(CodeEmitter):
|
|
350
|
+
"""Emits generated code as strings.
|
|
351
|
+
|
|
352
|
+
This emitter is useful for testing or when you need to manipulate
|
|
353
|
+
the generated code before writing it.
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
def __init__(self, format_code: bool = False):
|
|
357
|
+
"""Initialize the string emitter.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
format_code: Whether to format code with black (if available).
|
|
361
|
+
"""
|
|
362
|
+
self.format_code = format_code
|
|
363
|
+
self._modules: dict[str, str] = {}
|
|
364
|
+
|
|
365
|
+
def emit_module(
|
|
366
|
+
self,
|
|
367
|
+
body: list[ast.stmt],
|
|
368
|
+
name: str,
|
|
369
|
+
docstring: str | None = None,
|
|
370
|
+
) -> str:
|
|
371
|
+
"""Emit a complete Python module as a string.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
body: List of AST statements forming the module body.
|
|
375
|
+
name: The module name (used for identification).
|
|
376
|
+
docstring: Optional module-level docstring.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
The generated source code as a string.
|
|
380
|
+
"""
|
|
381
|
+
# Add docstring if provided
|
|
382
|
+
if docstring:
|
|
383
|
+
doc_node = ast.Expr(value=ast.Constant(value=docstring))
|
|
384
|
+
body = [doc_node] + body
|
|
385
|
+
|
|
386
|
+
# Create module AST
|
|
387
|
+
module = ast.Module(body=body, type_ignores=[])
|
|
388
|
+
ast.fix_missing_locations(module)
|
|
389
|
+
|
|
390
|
+
# Convert to source code
|
|
391
|
+
source = ast.unparse(module)
|
|
392
|
+
|
|
393
|
+
# Format if requested
|
|
394
|
+
if self.format_code:
|
|
395
|
+
source = self._format_source(source)
|
|
396
|
+
|
|
397
|
+
self._modules[name] = source
|
|
398
|
+
return source
|
|
399
|
+
|
|
400
|
+
def emit_models(
|
|
401
|
+
self,
|
|
402
|
+
types: list['Type'],
|
|
403
|
+
module_name: str = 'models',
|
|
404
|
+
) -> str:
|
|
405
|
+
"""Emit a module containing model definitions as a string.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
types: List of Type objects to emit.
|
|
409
|
+
module_name: Name for the models module.
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
The generated source code as a string.
|
|
413
|
+
"""
|
|
414
|
+
body: list[ast.stmt] = []
|
|
415
|
+
|
|
416
|
+
for type_obj in types:
|
|
417
|
+
if type_obj.implementation_ast:
|
|
418
|
+
body.append(type_obj.implementation_ast)
|
|
419
|
+
|
|
420
|
+
return self.emit_module(body, module_name)
|
|
421
|
+
|
|
422
|
+
def emit_endpoints(
|
|
423
|
+
self,
|
|
424
|
+
body: list[ast.stmt],
|
|
425
|
+
module_name: str = 'endpoints',
|
|
426
|
+
) -> str:
|
|
427
|
+
"""Emit a module containing endpoint functions as a string.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
body: List of AST statements for endpoint functions.
|
|
431
|
+
module_name: Name for the endpoints module.
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
The generated source code as a string.
|
|
435
|
+
"""
|
|
436
|
+
return self.emit_module(body, module_name)
|
|
437
|
+
|
|
438
|
+
def _format_source(self, source: str) -> str:
|
|
439
|
+
"""Format source code using black if available.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
source: The source code to format.
|
|
443
|
+
|
|
444
|
+
Returns:
|
|
445
|
+
Formatted source code, or original if black unavailable.
|
|
446
|
+
"""
|
|
447
|
+
try:
|
|
448
|
+
import black
|
|
449
|
+
|
|
450
|
+
return black.format_str(source, mode=black.Mode())
|
|
451
|
+
except ImportError:
|
|
452
|
+
return source
|
|
453
|
+
except Exception:
|
|
454
|
+
return source
|
|
455
|
+
|
|
456
|
+
def get_module(self, name: str) -> str | None:
|
|
457
|
+
"""Get a previously emitted module by name.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
name: The module name.
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
The source code string, or None if not found.
|
|
464
|
+
"""
|
|
465
|
+
return self._modules.get(name)
|
|
466
|
+
|
|
467
|
+
def get_all_modules(self) -> dict[str, str]:
|
|
468
|
+
"""Get all emitted modules.
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
Dictionary mapping module names to source code strings.
|
|
472
|
+
"""
|
|
473
|
+
return self._modules.copy()
|