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
@@ -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