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.
Files changed (52) hide show
  1. README.md +581 -8
  2. otterapi/__init__.py +73 -0
  3. otterapi/cli.py +327 -29
  4. otterapi/codegen/__init__.py +115 -0
  5. otterapi/codegen/ast_utils.py +134 -5
  6. otterapi/codegen/client.py +1271 -0
  7. otterapi/codegen/codegen.py +1736 -0
  8. otterapi/codegen/dataframes.py +392 -0
  9. otterapi/codegen/emitter.py +473 -0
  10. otterapi/codegen/endpoints.py +2597 -343
  11. otterapi/codegen/pagination.py +1026 -0
  12. otterapi/codegen/schema.py +593 -0
  13. otterapi/codegen/splitting.py +1397 -0
  14. otterapi/codegen/types.py +1345 -0
  15. otterapi/codegen/utils.py +180 -1
  16. otterapi/config.py +1017 -24
  17. otterapi/exceptions.py +231 -0
  18. otterapi/openapi/__init__.py +46 -0
  19. otterapi/openapi/v2/__init__.py +86 -0
  20. otterapi/openapi/v2/spec.json +1607 -0
  21. otterapi/openapi/v2/v2.py +1776 -0
  22. otterapi/openapi/v3/__init__.py +131 -0
  23. otterapi/openapi/v3/spec.json +1651 -0
  24. otterapi/openapi/v3/v3.py +1557 -0
  25. otterapi/openapi/v3_1/__init__.py +133 -0
  26. otterapi/openapi/v3_1/spec.json +1411 -0
  27. otterapi/openapi/v3_1/v3_1.py +798 -0
  28. otterapi/openapi/v3_2/__init__.py +133 -0
  29. otterapi/openapi/v3_2/spec.json +1666 -0
  30. otterapi/openapi/v3_2/v3_2.py +777 -0
  31. otterapi/tests/__init__.py +3 -0
  32. otterapi/tests/fixtures/__init__.py +455 -0
  33. otterapi/tests/test_ast_utils.py +680 -0
  34. otterapi/tests/test_codegen.py +610 -0
  35. otterapi/tests/test_dataframe.py +1038 -0
  36. otterapi/tests/test_exceptions.py +493 -0
  37. otterapi/tests/test_openapi_support.py +616 -0
  38. otterapi/tests/test_openapi_upgrade.py +215 -0
  39. otterapi/tests/test_pagination.py +1101 -0
  40. otterapi/tests/test_splitting_config.py +319 -0
  41. otterapi/tests/test_splitting_integration.py +427 -0
  42. otterapi/tests/test_splitting_resolver.py +512 -0
  43. otterapi/tests/test_splitting_tree.py +525 -0
  44. otterapi-0.0.6.dist-info/METADATA +627 -0
  45. otterapi-0.0.6.dist-info/RECORD +48 -0
  46. {otterapi-0.0.5.dist-info → otterapi-0.0.6.dist-info}/WHEEL +1 -1
  47. otterapi/codegen/generator.py +0 -358
  48. otterapi/codegen/openapi_processor.py +0 -27
  49. otterapi/codegen/type_generator.py +0 -559
  50. otterapi-0.0.5.dist-info/METADATA +0 -54
  51. otterapi-0.0.5.dist-info/RECORD +0 -16
  52. {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()