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/generator.py
DELETED
|
@@ -1,358 +0,0 @@
|
|
|
1
|
-
import ast
|
|
2
|
-
import http
|
|
3
|
-
import json
|
|
4
|
-
import logging
|
|
5
|
-
import os
|
|
6
|
-
import py_compile
|
|
7
|
-
import tempfile
|
|
8
|
-
|
|
9
|
-
import httpx
|
|
10
|
-
from openapi_pydantic import Operation
|
|
11
|
-
from openapi_pydantic.v3.parser import OpenAPIv3
|
|
12
|
-
from pydantic import TypeAdapter
|
|
13
|
-
from upath import UPath
|
|
14
|
-
|
|
15
|
-
from otterapi.codegen.ast_utils import _all, _assign, _call, _name, _union_expr
|
|
16
|
-
from otterapi.codegen.endpoints import async_request_fn, request_fn
|
|
17
|
-
from otterapi.codegen.openapi_processor import OpenAPIProcessor
|
|
18
|
-
from otterapi.codegen.type_generator import Endpoint, Parameter, Type, TypeGen
|
|
19
|
-
from otterapi.codegen.utils import (
|
|
20
|
-
is_url,
|
|
21
|
-
sanitize_identifier,
|
|
22
|
-
sanitize_parameter_field_name,
|
|
23
|
-
)
|
|
24
|
-
from otterapi.config import DocumentConfig
|
|
25
|
-
|
|
26
|
-
HTTP_METHODS = [method.value.lower() for method in http.HTTPMethod]
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
class Codegen(OpenAPIProcessor):
|
|
30
|
-
def __init__(self, config: DocumentConfig):
|
|
31
|
-
super().__init__(None)
|
|
32
|
-
self.config = config
|
|
33
|
-
self.typegen: TypeGen | None = None
|
|
34
|
-
|
|
35
|
-
def _load_schema(self):
|
|
36
|
-
content = b''
|
|
37
|
-
if is_url(self.config.source):
|
|
38
|
-
response = httpx.get(self.config.source)
|
|
39
|
-
response.raise_for_status()
|
|
40
|
-
content = response.json()
|
|
41
|
-
else:
|
|
42
|
-
with open(self.config.source, 'rb') as f:
|
|
43
|
-
content = json.loads(f.read())
|
|
44
|
-
self.openapi = TypeAdapter(OpenAPIv3).validate_python(content)
|
|
45
|
-
self.typegen = TypeGen(self.openapi)
|
|
46
|
-
|
|
47
|
-
def _get_response_models_by_status_code(
|
|
48
|
-
self, operation: Operation
|
|
49
|
-
) -> tuple[list[int] | None, Type | None]:
|
|
50
|
-
if not operation.responses:
|
|
51
|
-
return None, None
|
|
52
|
-
|
|
53
|
-
types: dict[int, Type] = {}
|
|
54
|
-
for status_code, response in operation.responses.items():
|
|
55
|
-
if response.content:
|
|
56
|
-
for content_type, media_type in response.content.items():
|
|
57
|
-
if media_type.media_type_schema:
|
|
58
|
-
if status_code in types:
|
|
59
|
-
raise ValueError(
|
|
60
|
-
'Multiple response schemas for the same status code are not supported'
|
|
61
|
-
)
|
|
62
|
-
types[int(status_code)] = self.typegen.schema_to_type(
|
|
63
|
-
media_type.media_type_schema,
|
|
64
|
-
base_name=f'{sanitize_identifier(operation.operationId)}Response',
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
type_len = len(types)
|
|
68
|
-
if type_len == 0:
|
|
69
|
-
return None, None
|
|
70
|
-
elif type_len == 1:
|
|
71
|
-
return list(types.keys()), next(iter(types.values()))
|
|
72
|
-
else:
|
|
73
|
-
type_values = list(types.values())
|
|
74
|
-
union_type = Type(
|
|
75
|
-
None,
|
|
76
|
-
None,
|
|
77
|
-
annotation_ast=_union_expr([t.annotation_ast for t in type_values]),
|
|
78
|
-
implementation_ast=None,
|
|
79
|
-
type='primitive',
|
|
80
|
-
)
|
|
81
|
-
# Aggregate imports from all types
|
|
82
|
-
union_type.copy_imports_from_sub_types(type_values)
|
|
83
|
-
union_type.add_annotation_import('typing', 'Union')
|
|
84
|
-
return list(types.keys()), union_type
|
|
85
|
-
|
|
86
|
-
def _get_param_model(self, operation: Operation) -> list[Parameter]:
|
|
87
|
-
params = []
|
|
88
|
-
for param in operation.parameters or []:
|
|
89
|
-
param_type = None
|
|
90
|
-
if param.param_schema:
|
|
91
|
-
param_type = self.typegen.schema_to_type(param.param_schema)
|
|
92
|
-
|
|
93
|
-
params.append(
|
|
94
|
-
Parameter(
|
|
95
|
-
name=param.name,
|
|
96
|
-
name_sanitized=sanitize_parameter_field_name(param.name),
|
|
97
|
-
location=param.param_in, # query, path, header, cookie
|
|
98
|
-
required=param.required or False,
|
|
99
|
-
type=param_type,
|
|
100
|
-
description=param.description,
|
|
101
|
-
)
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
if operation.requestBody:
|
|
105
|
-
body, _ = self._resolve_reference(operation.requestBody)
|
|
106
|
-
if body.content:
|
|
107
|
-
for content_type, media_type in body.content.items():
|
|
108
|
-
if content_type != 'application/json':
|
|
109
|
-
logging.warning(
|
|
110
|
-
f'Skipping non-JSON request body content type: {content_type}'
|
|
111
|
-
)
|
|
112
|
-
continue
|
|
113
|
-
|
|
114
|
-
if media_type.media_type_schema:
|
|
115
|
-
body_type = self.typegen.schema_to_type(
|
|
116
|
-
media_type.media_type_schema,
|
|
117
|
-
base_name=f'{sanitize_identifier(operation.operationId)}RequestBody',
|
|
118
|
-
)
|
|
119
|
-
params.append(
|
|
120
|
-
Parameter(
|
|
121
|
-
name='body',
|
|
122
|
-
name_sanitized='body',
|
|
123
|
-
location='body',
|
|
124
|
-
required=body.required or False,
|
|
125
|
-
type=body_type,
|
|
126
|
-
description=body.description,
|
|
127
|
-
)
|
|
128
|
-
)
|
|
129
|
-
|
|
130
|
-
return params
|
|
131
|
-
|
|
132
|
-
def _generate_endpoint(self, path: str, method: str, operation: Operation):
|
|
133
|
-
fn_name = (
|
|
134
|
-
operation.operationId
|
|
135
|
-
or f'{method}_{path.replace("/", "_").replace("{", "").replace("}", "")}'
|
|
136
|
-
)
|
|
137
|
-
async_fn_name = f'a{fn_name}'
|
|
138
|
-
|
|
139
|
-
parameters = self._get_param_model(operation)
|
|
140
|
-
supported_status_codes, response_model = (
|
|
141
|
-
self._get_response_models_by_status_code(operation)
|
|
142
|
-
)
|
|
143
|
-
|
|
144
|
-
async_fn, async_imports = async_request_fn(
|
|
145
|
-
name=async_fn_name,
|
|
146
|
-
method=method,
|
|
147
|
-
path=path,
|
|
148
|
-
response_model=response_model,
|
|
149
|
-
docs=operation.description,
|
|
150
|
-
parameters=parameters,
|
|
151
|
-
supported_status_codes=supported_status_codes,
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
sync_fn, imports = request_fn(
|
|
155
|
-
name=fn_name,
|
|
156
|
-
method=method,
|
|
157
|
-
path=path,
|
|
158
|
-
response_model=response_model,
|
|
159
|
-
docs=operation.description,
|
|
160
|
-
parameters=parameters,
|
|
161
|
-
supported_status_codes=supported_status_codes,
|
|
162
|
-
)
|
|
163
|
-
|
|
164
|
-
ep = Endpoint(
|
|
165
|
-
sync_ast=sync_fn,
|
|
166
|
-
sync_fn_name=fn_name,
|
|
167
|
-
async_fn_name=async_fn_name,
|
|
168
|
-
async_ast=async_fn,
|
|
169
|
-
name=fn_name,
|
|
170
|
-
)
|
|
171
|
-
|
|
172
|
-
ep.add_imports([imports, async_imports])
|
|
173
|
-
|
|
174
|
-
for param in parameters:
|
|
175
|
-
if param.type and param.type.annotation_imports:
|
|
176
|
-
ep.add_imports([param.type.annotation_imports])
|
|
177
|
-
|
|
178
|
-
return ep
|
|
179
|
-
|
|
180
|
-
def _generate_endpoints(self):
|
|
181
|
-
endpoints: list[Endpoint] = []
|
|
182
|
-
for path, path_item in self.openapi.paths.items():
|
|
183
|
-
for method in HTTP_METHODS:
|
|
184
|
-
operation = getattr(path_item, method, None)
|
|
185
|
-
if operation:
|
|
186
|
-
ep = self._generate_endpoint(path, method, operation)
|
|
187
|
-
endpoints.append(ep)
|
|
188
|
-
return endpoints
|
|
189
|
-
|
|
190
|
-
def _generate_file(self, body: list[ast.stmt], path: UPath) -> None:
|
|
191
|
-
mod = ast.Module(body=body, type_ignores=[])
|
|
192
|
-
ast.fix_missing_locations(mod)
|
|
193
|
-
|
|
194
|
-
file_content = ast.unparse(mod)
|
|
195
|
-
|
|
196
|
-
with tempfile.NamedTemporaryFile() as f:
|
|
197
|
-
f.write(file_content.encode('utf-8'))
|
|
198
|
-
f.flush()
|
|
199
|
-
# check if the generated file is valid python
|
|
200
|
-
py_compile.compile(f.name)
|
|
201
|
-
|
|
202
|
-
with open(str(path), 'wb') as f:
|
|
203
|
-
f.write(file_content.encode('utf-8'))
|
|
204
|
-
|
|
205
|
-
def _generate_endpoint_file(
|
|
206
|
-
self, path: UPath, models_file: UPath, endpoints: list[Endpoint]
|
|
207
|
-
):
|
|
208
|
-
from otterapi.codegen.endpoints import base_async_request_fn, base_request_fn
|
|
209
|
-
|
|
210
|
-
baseurl = None
|
|
211
|
-
if not self.openapi.servers or self.config.base_url:
|
|
212
|
-
baseurl = self.config.base_url
|
|
213
|
-
elif self.openapi.servers:
|
|
214
|
-
if len(self.openapi.servers) > 1:
|
|
215
|
-
raise ValueError(
|
|
216
|
-
'Multiple servers are not supported. Set the base_url in the config.'
|
|
217
|
-
)
|
|
218
|
-
|
|
219
|
-
# TODO: handle variables
|
|
220
|
-
baseurl = self.openapi.servers[0].url
|
|
221
|
-
|
|
222
|
-
if not baseurl:
|
|
223
|
-
raise ValueError(
|
|
224
|
-
'No base url provided. Make sure you specify the base_url in the otterapi config or the OpenAPI document contains a valid servers section'
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
body: list[ast.stmt] = [
|
|
228
|
-
_assign(_name('BASE_URL'), ast.Constant(baseurl)),
|
|
229
|
-
_assign(_name('T'), _call(_name('TypeVar'), [ast.Constant('T')])),
|
|
230
|
-
]
|
|
231
|
-
imports: dict[str, set[str]] = {}
|
|
232
|
-
model_names: set[str] = set()
|
|
233
|
-
|
|
234
|
-
# Add base request functions
|
|
235
|
-
sync_base_fn, sync_base_imports = base_request_fn()
|
|
236
|
-
async_base_fn, async_base_imports = base_async_request_fn()
|
|
237
|
-
|
|
238
|
-
body.append(sync_base_fn)
|
|
239
|
-
body.append(async_base_fn)
|
|
240
|
-
|
|
241
|
-
# Collect imports from base functions
|
|
242
|
-
for module, names in sync_base_imports.items():
|
|
243
|
-
if module not in imports:
|
|
244
|
-
imports[module] = set()
|
|
245
|
-
imports[module].update(names)
|
|
246
|
-
|
|
247
|
-
for module, names in async_base_imports.items():
|
|
248
|
-
if module not in imports:
|
|
249
|
-
imports[module] = set()
|
|
250
|
-
imports[module].update(names)
|
|
251
|
-
|
|
252
|
-
endpoint_names: set[str] = set()
|
|
253
|
-
# Add endpoint functions and collect their imports
|
|
254
|
-
for endpoint in endpoints:
|
|
255
|
-
endpoint_names.update([endpoint.sync_fn_name, endpoint.async_fn_name])
|
|
256
|
-
body.append(endpoint.sync_ast)
|
|
257
|
-
body.append(endpoint.async_ast)
|
|
258
|
-
for module, names in endpoint.imports.items():
|
|
259
|
-
if module not in imports:
|
|
260
|
-
imports[module] = set()
|
|
261
|
-
imports[module].update(names)
|
|
262
|
-
|
|
263
|
-
# Collect all model names used in endpoints (from typegen.types)
|
|
264
|
-
for type_name, type_ in self.typegen.types.items():
|
|
265
|
-
if type_.name and type_.implementation_ast:
|
|
266
|
-
model_names.add(type_.name)
|
|
267
|
-
|
|
268
|
-
body.insert(0, _all(sorted(endpoint_names)))
|
|
269
|
-
|
|
270
|
-
# Add import for models from the generated models file
|
|
271
|
-
if model_names:
|
|
272
|
-
model_import = ast.ImportFrom(
|
|
273
|
-
module=self.config.models_import_path or models_file.stem,
|
|
274
|
-
names=[
|
|
275
|
-
ast.alias(name=name, asname=None) for name in sorted(model_names)
|
|
276
|
-
],
|
|
277
|
-
level=1 if not self.config.models_import_path else 0, # relative import
|
|
278
|
-
)
|
|
279
|
-
body.insert(0, model_import)
|
|
280
|
-
|
|
281
|
-
# Add all other imports at the beginning
|
|
282
|
-
for module, names in sorted(imports.items(), reverse=True):
|
|
283
|
-
import_stmt = ast.ImportFrom(
|
|
284
|
-
module=module,
|
|
285
|
-
names=[ast.alias(name=name, asname=None) for name in sorted(names)],
|
|
286
|
-
level=0,
|
|
287
|
-
)
|
|
288
|
-
body.insert(0, import_stmt)
|
|
289
|
-
|
|
290
|
-
self._generate_file(body, path)
|
|
291
|
-
|
|
292
|
-
def _generate_models_file(self, path: UPath):
|
|
293
|
-
assert self.typegen is not None
|
|
294
|
-
|
|
295
|
-
body: list[ast.stmt] = []
|
|
296
|
-
imports: dict[str, set[str]] = {}
|
|
297
|
-
|
|
298
|
-
all_names = set()
|
|
299
|
-
|
|
300
|
-
for type_name, type_ in self.typegen.types.items():
|
|
301
|
-
if type_.implementation_ast:
|
|
302
|
-
body.append(type_.implementation_ast)
|
|
303
|
-
if type_.name:
|
|
304
|
-
all_names.add(type_.name)
|
|
305
|
-
|
|
306
|
-
# Collect imports from implementation
|
|
307
|
-
for module, names in type_.implementation_imports.items():
|
|
308
|
-
if module not in imports:
|
|
309
|
-
imports[module] = set()
|
|
310
|
-
imports[module].update(names)
|
|
311
|
-
|
|
312
|
-
# Collect imports from annotations (List, Dict, Any, Union, Optional, etc.)
|
|
313
|
-
for module, names in type_.annotation_imports.items():
|
|
314
|
-
if module not in imports:
|
|
315
|
-
imports[module] = set()
|
|
316
|
-
imports[module].update(names)
|
|
317
|
-
|
|
318
|
-
body.insert(0, _all(sorted(all_names)))
|
|
319
|
-
|
|
320
|
-
# Add all imports at the beginning
|
|
321
|
-
for module, names in sorted(imports.items(), reverse=True):
|
|
322
|
-
import_stmt = ast.ImportFrom(
|
|
323
|
-
module=module,
|
|
324
|
-
names=[ast.alias(name=name, asname=None) for name in sorted(names)],
|
|
325
|
-
level=0,
|
|
326
|
-
)
|
|
327
|
-
body.insert(0, import_stmt)
|
|
328
|
-
|
|
329
|
-
self._generate_file(body, path)
|
|
330
|
-
|
|
331
|
-
def _generate_init_file(self, directory: UPath):
|
|
332
|
-
init_file = directory / '__init__.py'
|
|
333
|
-
if not init_file.exists():
|
|
334
|
-
init_file.touch()
|
|
335
|
-
|
|
336
|
-
def generate(self):
|
|
337
|
-
self._load_schema()
|
|
338
|
-
|
|
339
|
-
assert self.openapi is not None
|
|
340
|
-
|
|
341
|
-
if not self.openapi.paths:
|
|
342
|
-
raise ValueError('OpenAPI spec has no paths to generate endpoints from')
|
|
343
|
-
|
|
344
|
-
directory = UPath(self.config.output)
|
|
345
|
-
directory.mkdir(parents=True, exist_ok=True)
|
|
346
|
-
|
|
347
|
-
if not os.access(str(directory), os.W_OK):
|
|
348
|
-
raise RuntimeError(f'Directory {directory} is not writable')
|
|
349
|
-
|
|
350
|
-
endpoints = self._generate_endpoints()
|
|
351
|
-
|
|
352
|
-
models_file = directory / self.config.models_file
|
|
353
|
-
self._generate_models_file(models_file)
|
|
354
|
-
|
|
355
|
-
endpoints_file = directory / self.config.endpoints_file
|
|
356
|
-
self._generate_endpoint_file(endpoints_file, models_file, endpoints)
|
|
357
|
-
|
|
358
|
-
self._generate_init_file(directory)
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
from openapi_pydantic import Reference, Schema
|
|
2
|
-
from openapi_pydantic.v3.parser import OpenAPIv3
|
|
3
|
-
|
|
4
|
-
from otterapi.codegen.utils import sanitize_identifier
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class OpenAPIProcessor:
|
|
8
|
-
def __init__(self, openapi: OpenAPIv3 | None):
|
|
9
|
-
self.openapi: OpenAPIv3 | None = openapi
|
|
10
|
-
|
|
11
|
-
def _resolve_reference(self, reference: Reference | Schema) -> tuple[Schema, str]:
|
|
12
|
-
if isinstance(reference, Reference):
|
|
13
|
-
if not reference.ref.startswith('#/components/schemas/'):
|
|
14
|
-
raise ValueError(f'Unsupported reference format: {reference.ref}')
|
|
15
|
-
|
|
16
|
-
schema_name = reference.ref.split('/')[-1]
|
|
17
|
-
schemas = self.openapi.components.schemas
|
|
18
|
-
|
|
19
|
-
if schema_name not in schemas:
|
|
20
|
-
raise ValueError(
|
|
21
|
-
f"Referenced schema '{schema_name}' not found in components.schemas"
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
return schemas[schema_name], sanitize_identifier(schema_name)
|
|
25
|
-
return reference, sanitize_identifier(reference.title) if hasattr(
|
|
26
|
-
reference, 'title'
|
|
27
|
-
) and reference.title else None
|