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
otterapi/codegen/utils.py
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import py_compile
|
|
1
3
|
import re
|
|
4
|
+
import tempfile
|
|
2
5
|
import unicodedata
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
3
8
|
from urllib.parse import urlparse
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
from upath import UPath
|
|
11
|
+
|
|
12
|
+
from otterapi.openapi.v3_2 import OpenAPI, Reference, Schema
|
|
13
|
+
|
|
14
|
+
__all__ = ('is_url', 'sanitize_identifier', 'to_snake_case')
|
|
6
15
|
|
|
7
16
|
|
|
8
17
|
def capitalize(input_string):
|
|
@@ -51,6 +60,34 @@ def sanitize_parameter_field_name(name: str) -> str:
|
|
|
51
60
|
return sanitized
|
|
52
61
|
|
|
53
62
|
|
|
63
|
+
def to_snake_case(name: str) -> str:
|
|
64
|
+
"""Convert a camelCase or PascalCase string to snake_case.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
name: The string to convert (e.g., 'getUserById', 'HTTPResponse').
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The snake_case version (e.g., 'get_user_by_id', 'http_response').
|
|
71
|
+
"""
|
|
72
|
+
if not name:
|
|
73
|
+
return name
|
|
74
|
+
|
|
75
|
+
# Handle acronyms and consecutive uppercase letters
|
|
76
|
+
# Insert underscore before uppercase letters that are followed by lowercase
|
|
77
|
+
# or before uppercase letters that follow lowercase letters
|
|
78
|
+
result = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', name)
|
|
79
|
+
# Handle consecutive uppercase followed by lowercase (e.g., HTTPResponse -> HTTP_Response)
|
|
80
|
+
result = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1_\2', result)
|
|
81
|
+
# Convert to lowercase
|
|
82
|
+
result = result.lower()
|
|
83
|
+
# Replace any non-alphanumeric characters with underscores
|
|
84
|
+
result = re.sub(r'[^a-z0-9]+', '_', result)
|
|
85
|
+
# Remove leading/trailing underscores and collapse multiple underscores
|
|
86
|
+
result = re.sub(r'_+', '_', result).strip('_')
|
|
87
|
+
|
|
88
|
+
return sanitize_name_python_keywords(result)
|
|
89
|
+
|
|
90
|
+
|
|
54
91
|
def sanitize_identifier(name: str) -> str:
|
|
55
92
|
"""Convert a string into a valid Python identifier.
|
|
56
93
|
|
|
@@ -80,3 +117,145 @@ def sanitize_identifier(name: str) -> str:
|
|
|
80
117
|
|
|
81
118
|
# If empty after sanitization, return a default
|
|
82
119
|
return sanitized or 'UnnamedType'
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def validate_python_syntax(content: str) -> None:
|
|
123
|
+
"""Validate that the content is valid Python code.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
content: Python source code as a string.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
SyntaxError: If the code is not valid Python.
|
|
130
|
+
"""
|
|
131
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
|
|
132
|
+
f.write(content)
|
|
133
|
+
f.flush()
|
|
134
|
+
temp_path = f.name
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
# Compile to check for syntax errors
|
|
138
|
+
py_compile.compile(temp_path, doraise=True)
|
|
139
|
+
finally:
|
|
140
|
+
# Clean up temp file
|
|
141
|
+
Path(temp_path).unlink(missing_ok=True)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def format_source(source: str) -> str:
|
|
145
|
+
"""Format Python source code using ruff or black if available.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
source: The source code to format.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Formatted source code, or original if formatters unavailable.
|
|
152
|
+
"""
|
|
153
|
+
# Try ruff first
|
|
154
|
+
try:
|
|
155
|
+
import subprocess
|
|
156
|
+
|
|
157
|
+
result = subprocess.run(
|
|
158
|
+
['ruff', 'format', '-'],
|
|
159
|
+
input=source,
|
|
160
|
+
capture_output=True,
|
|
161
|
+
text=True,
|
|
162
|
+
)
|
|
163
|
+
if result.returncode == 0:
|
|
164
|
+
return result.stdout
|
|
165
|
+
except (FileNotFoundError, subprocess.SubprocessError):
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
# Try black
|
|
169
|
+
try:
|
|
170
|
+
import black
|
|
171
|
+
|
|
172
|
+
return black.format_str(source, mode=black.Mode())
|
|
173
|
+
except ImportError:
|
|
174
|
+
pass
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
# Return original if no formatter available
|
|
179
|
+
return source
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def write_mod(
|
|
183
|
+
body: list[ast.stmt], path: UPath | Path | str, format_code: bool = True
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Write a list of AST statements to a Python file.
|
|
186
|
+
|
|
187
|
+
This method:
|
|
188
|
+
1. Creates an AST Module from the statements
|
|
189
|
+
2. Fixes missing locations in the AST
|
|
190
|
+
3. Unparses the AST to Python source code
|
|
191
|
+
4. Validates the code by compiling it
|
|
192
|
+
5. Optionally formats the code with ruff/black
|
|
193
|
+
6. Writes the code to the specified file
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
body: List of AST statement nodes to write.
|
|
197
|
+
path: Path where the file should be written.
|
|
198
|
+
format_code: Whether to format the code with ruff/black. Defaults to True.
|
|
199
|
+
|
|
200
|
+
Raises:
|
|
201
|
+
SyntaxError: If the generated code is not valid Python.
|
|
202
|
+
OSError: If the file cannot be written.
|
|
203
|
+
"""
|
|
204
|
+
# Convert path to string for consistency
|
|
205
|
+
path = UPath(path)
|
|
206
|
+
|
|
207
|
+
# Create and prepare the AST module
|
|
208
|
+
mod = ast.Module(body=body, type_ignores=[])
|
|
209
|
+
ast.fix_missing_locations(mod)
|
|
210
|
+
|
|
211
|
+
# Convert AST to Python source code
|
|
212
|
+
file_content = ast.unparse(mod)
|
|
213
|
+
|
|
214
|
+
# Validate the generated code by compiling it
|
|
215
|
+
validate_python_syntax(file_content)
|
|
216
|
+
|
|
217
|
+
# Format code if requested
|
|
218
|
+
if format_code:
|
|
219
|
+
file_content = format_source(file_content)
|
|
220
|
+
|
|
221
|
+
# Write to file
|
|
222
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
with open(str(path), 'w', encoding='utf-8') as f:
|
|
224
|
+
f.write(file_content)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def write_init_file(directory: UPath | Path | str) -> None:
|
|
228
|
+
"""Create an empty __init__.py file in the specified directory.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
directory: Directory where __init__.py should be created.
|
|
232
|
+
"""
|
|
233
|
+
directory = UPath(directory)
|
|
234
|
+
init_file = directory / '__init__.py'
|
|
235
|
+
|
|
236
|
+
if not init_file.exists():
|
|
237
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
init_file.touch()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass
|
|
242
|
+
class OpenAPIProcessor:
|
|
243
|
+
openapi: OpenAPI | None = None
|
|
244
|
+
|
|
245
|
+
def _resolve_reference(self, reference: Reference | Schema) -> tuple[Schema, str]:
|
|
246
|
+
if isinstance(reference, Reference):
|
|
247
|
+
if not reference.ref.startswith('#/components/schemas/'):
|
|
248
|
+
raise ValueError(f'Unsupported reference format: {reference.ref}')
|
|
249
|
+
|
|
250
|
+
schema_name = reference.ref.split('/')[-1]
|
|
251
|
+
schemas = self.openapi.components.schemas
|
|
252
|
+
|
|
253
|
+
if schema_name not in schemas:
|
|
254
|
+
raise ValueError(
|
|
255
|
+
f"Referenced schema '{schema_name}' not found in components.schemas"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
return schemas[schema_name], sanitize_identifier(schema_name)
|
|
259
|
+
return reference, sanitize_identifier(reference.title) if hasattr(
|
|
260
|
+
reference, 'title'
|
|
261
|
+
) and reference.title else None
|