otterapi 0.0.3__py3-none-any.whl → 0.0.4__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.
otterapi/__init__.py ADDED
File without changes
otterapi/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from otterapi.cli import app
2
+
3
+ if __name__ == '__main__':
4
+ app()
otterapi/cli.py ADDED
@@ -0,0 +1,84 @@
1
+ from typing import Annotated
2
+
3
+ import typer
4
+ from rich.console import Console
5
+ from rich.progress import Progress, SpinnerColumn, TextColumn
6
+
7
+ from otterapi.codegen.generator import Codegen
8
+ from otterapi.config import get_config
9
+
10
+ console = Console()
11
+ app = typer.Typer(
12
+ name='otterapi',
13
+ help='Generate Python client code from OpenAPI specifications',
14
+ no_args_is_help=True,
15
+ )
16
+
17
+
18
+ @app.command()
19
+ def generate(
20
+ config: Annotated[
21
+ str | None,
22
+ typer.Option(
23
+ '--config', '-c', help='Path to configuration file (YAML or JSON)'
24
+ ),
25
+ ] = None,
26
+ ) -> None:
27
+ """Generate Python client code from configuration.
28
+
29
+ If no config file is specified, will look for default config files
30
+ in the current directory or use environment variables.
31
+
32
+ Examples:
33
+ otterapi generate
34
+ otterapi generate --config my-config.yaml
35
+ otterapi generate -c config.json
36
+ """
37
+ config = get_config(config)
38
+
39
+ try:
40
+ with Progress(
41
+ SpinnerColumn(),
42
+ TextColumn('[progress.description]{task.description}'),
43
+ console=console,
44
+ ) as progress:
45
+ for document_config in config.documents:
46
+ task = progress.add_task(
47
+ f'Generating code for {document_config.source} in {document_config.output}...',
48
+ total=None,
49
+ )
50
+
51
+ codegen = Codegen(document_config)
52
+ codegen.generate()
53
+
54
+ console.print(
55
+ f"[green]✓[/green] Successfully generated code in '{document_config.output}'"
56
+ )
57
+ console.print('[dim]Generated files:[/dim]')
58
+ console.print(
59
+ f' - {document_config.output}/{document_config.models_file}'
60
+ )
61
+ console.print(
62
+ f' - {document_config.output}/{document_config.endpoints_file}'
63
+ )
64
+
65
+ progress.update(task, description='Code generation completed!')
66
+
67
+ except Exception as e:
68
+ console.print(f'[red]Error:[/red] {str(e)}')
69
+ raise typer.Exit(1)
70
+
71
+
72
+ @app.command()
73
+ def version() -> None:
74
+ """Show the version of otterapi."""
75
+ try:
76
+ from otterapi._version import version
77
+
78
+ console.print(f'otterapi version: {version}')
79
+ except ImportError:
80
+ console.print('otterapi version: unknown')
81
+
82
+
83
+ if __name__ == '__main__':
84
+ generate('/Users/PLISCD/Downloads/otterapi.yml')
File without changes
@@ -0,0 +1,118 @@
1
+ import ast
2
+ from collections.abc import Iterable
3
+
4
+
5
+ def _name(name: str) -> ast.Name:
6
+ return ast.Name(id=name, ctx=ast.Load())
7
+
8
+
9
+ def _attr(value: str | ast.expr, attr: str) -> ast.Attribute:
10
+ return ast.Attribute(
11
+ value=_name(value) if isinstance(value, str) else value, attr=attr
12
+ )
13
+
14
+
15
+ def _subscript(generic: str, inner: ast.expr) -> ast.Subscript:
16
+ return ast.Subscript(value=_name(generic), slice=inner)
17
+
18
+
19
+ def _union_expr(types: list[ast.expr]) -> ast.Subscript:
20
+ # Union[A, B, C]
21
+ return _subscript('Union', ast.Tuple(elts=types))
22
+
23
+
24
+ def _optional_expr(inner: ast.expr) -> ast.Subscript:
25
+ return _subscript('Optional', inner)
26
+
27
+
28
+ def _argument(name: str, value: ast.expr | None = None) -> ast.arg:
29
+ return ast.arg(
30
+ arg=name,
31
+ annotation=value,
32
+ )
33
+
34
+
35
+ def _assign(target: ast.expr, value: ast.expr) -> ast.Assign:
36
+ return ast.Assign(
37
+ targets=[target],
38
+ value=value,
39
+ )
40
+
41
+
42
+ def _import(module: str, names: list[str]) -> ast.ImportFrom:
43
+ return ast.ImportFrom(
44
+ module=module,
45
+ names=[ast.alias(name=name) for name in names],
46
+ level=0,
47
+ )
48
+
49
+
50
+ def _call(
51
+ func: ast.expr,
52
+ args: list[ast.expr] | None = None,
53
+ keywords: list[ast.keyword] | None = None,
54
+ ) -> ast.Call:
55
+ return ast.Call(
56
+ func=func,
57
+ args=args or [],
58
+ keywords=keywords or [],
59
+ )
60
+
61
+
62
+ def _func(
63
+ name: str,
64
+ args: list[ast.arg],
65
+ body: list[ast.stmt],
66
+ returns: ast.expr | None = None,
67
+ kwargs: ast.arg = None,
68
+ kwonlyargs: list[ast.arg] = None,
69
+ kw_defaults: list[ast.expr] = None,
70
+ ) -> ast.FunctionDef:
71
+ return ast.FunctionDef(
72
+ name=name,
73
+ args=ast.arguments(
74
+ posonlyargs=[],
75
+ args=args,
76
+ kwarg=kwargs,
77
+ kwonlyargs=kwonlyargs or [],
78
+ kw_defaults=kw_defaults or [],
79
+ defaults=[],
80
+ ),
81
+ body=body,
82
+ decorator_list=[],
83
+ returns=returns,
84
+ )
85
+
86
+
87
+ def _async_func(
88
+ name: str,
89
+ args: list[ast.arg],
90
+ body: list[ast.stmt],
91
+ returns: ast.expr | None = None,
92
+ kwargs: ast.arg = None,
93
+ kwonlyargs: list[ast.arg] = None,
94
+ kw_defaults: list[ast.expr] = None,
95
+ ) -> ast.AsyncFunctionDef:
96
+ return ast.AsyncFunctionDef(
97
+ name=name,
98
+ args=ast.arguments(
99
+ posonlyargs=[],
100
+ args=args,
101
+ kwarg=kwargs,
102
+ kwonlyargs=kwonlyargs or [],
103
+ kw_defaults=kw_defaults or [],
104
+ defaults=[],
105
+ ),
106
+ body=body,
107
+ decorator_list=[],
108
+ returns=returns,
109
+ )
110
+
111
+
112
+ def _all(names: Iterable[str]) -> ast.Assign:
113
+ return _assign(
114
+ target=_name('__all__'),
115
+ value=ast.Tuple(
116
+ elts=[ast.Constant(value=name) for name in names], ctx=ast.Load()
117
+ ),
118
+ )
@@ -0,0 +1,499 @@
1
+ import ast
2
+ import textwrap
3
+
4
+ from otterapi.codegen.ast_utils import (
5
+ _argument,
6
+ _assign,
7
+ _async_func,
8
+ _attr,
9
+ _call,
10
+ _func,
11
+ _name,
12
+ _subscript,
13
+ _union_expr,
14
+ )
15
+ from otterapi.codegen.type_generator import Parameter, Type
16
+
17
+
18
+ def clean_docstring(docstring: str) -> str:
19
+ return textwrap.dedent(f'\n{docstring}\n').strip()
20
+
21
+
22
+ def get_base_request_arguments():
23
+ args = [
24
+ _argument('method', _name('str')),
25
+ _argument('path', _name('str')),
26
+ ]
27
+ kwonlyargs = [
28
+ _argument(
29
+ 'response_model',
30
+ _union_expr([_subscript('Type', _name('T')), ast.Constant(value=None)]),
31
+ ),
32
+ _argument('supported_status_codes', _subscript('list', _name('int'))),
33
+ ]
34
+ kwargs = _argument('kwargs', _name('dict'))
35
+
36
+ return args, kwonlyargs, kwargs
37
+
38
+
39
+ def base_request_fn():
40
+ args, kwonlyargs, kwargs = get_base_request_arguments()
41
+
42
+ body = [
43
+ _assign(
44
+ target=_name('response'),
45
+ value=_call(
46
+ func=_name('request'),
47
+ args=[
48
+ _name('method'),
49
+ ast.Expr(
50
+ value=ast.JoinedStr(
51
+ values=[
52
+ ast.FormattedValue(
53
+ value=_name('BASE_URL'), conversion=-1
54
+ ),
55
+ ast.FormattedValue(value=_name('path'), conversion=-1),
56
+ ]
57
+ )
58
+ ),
59
+ ],
60
+ keywords=[ast.keyword(arg=None, value=_name('kwargs'))],
61
+ ),
62
+ ),
63
+ ast.If(
64
+ test=ast.BoolOp(
65
+ op=ast.Or(),
66
+ values=[
67
+ ast.UnaryOp(op=ast.Not(), operand=_name('supported_status_codes')),
68
+ ast.Compare(
69
+ left=_attr('response', 'status_code'),
70
+ ops=[ast.NotIn()],
71
+ comparators=[_name('supported_status_codes')],
72
+ ),
73
+ ],
74
+ ),
75
+ body=[
76
+ ast.Expr(
77
+ value=_call(
78
+ func=_attr('response', 'raise_for_status'),
79
+ )
80
+ )
81
+ ],
82
+ orelse=[],
83
+ ),
84
+ _assign(
85
+ target=_name('data'),
86
+ value=_call(
87
+ func=_attr('response', 'json'),
88
+ ),
89
+ ),
90
+ ast.If(
91
+ test=ast.UnaryOp(op=ast.Not(), operand=_name('response_model')),
92
+ body=[ast.Return(value=_name('data'))],
93
+ orelse=[],
94
+ ),
95
+ _assign(
96
+ target=_name('validated_data'),
97
+ value=_call(
98
+ func=_attr(
99
+ _call(
100
+ func=_name('TypeAdapter'),
101
+ args=[_name('response_model')],
102
+ ),
103
+ 'validate_python',
104
+ ),
105
+ args=[_name('data')],
106
+ ),
107
+ ),
108
+ ast.If(
109
+ test=_call(
110
+ func=_name('isinstance'),
111
+ args=[_name('validated_data'), _name('RootModel')],
112
+ ),
113
+ body=[ast.Return(value=_attr('validated_data', 'root'))],
114
+ orelse=[],
115
+ ),
116
+ ast.Return(value=_name('validated_data')),
117
+ ]
118
+
119
+ return _func(
120
+ name='request_sync',
121
+ args=args,
122
+ body=body,
123
+ kwargs=kwargs,
124
+ kwonlyargs=kwonlyargs,
125
+ kw_defaults=[_name('Json'), ast.Constant(value=None)],
126
+ returns=_name('T'),
127
+ ), {
128
+ 'httpx': ['request'],
129
+ 'pydantic': ['TypeAdapter', 'Json', 'RootModel'],
130
+ 'typing': ['Type', 'TypeVar', 'Union'],
131
+ }
132
+
133
+
134
+ def base_async_request_fn():
135
+ args, kwonlyargs, kwargs = get_base_request_arguments()
136
+
137
+ body = [
138
+ ast.AsyncWith(
139
+ items=[
140
+ ast.withitem(
141
+ context_expr=_call(func=_name('AsyncClient')),
142
+ optional_vars=_name('client'),
143
+ )
144
+ ],
145
+ body=[
146
+ _assign(
147
+ target=_name('response'),
148
+ value=ast.Await(
149
+ value=_call(
150
+ func=_attr('client', 'request'),
151
+ args=[
152
+ _name('method'),
153
+ ast.Expr(
154
+ value=ast.JoinedStr(
155
+ values=[
156
+ ast.FormattedValue(
157
+ value=_name('BASE_URL'), conversion=-1
158
+ ),
159
+ ast.FormattedValue(
160
+ value=_name('path'), conversion=-1
161
+ ),
162
+ ]
163
+ )
164
+ ),
165
+ ],
166
+ keywords=[ast.keyword(arg=None, value=_name('kwargs'))],
167
+ )
168
+ ),
169
+ ),
170
+ ast.If(
171
+ test=ast.BoolOp(
172
+ op=ast.Or(),
173
+ values=[
174
+ ast.UnaryOp(
175
+ op=ast.Not(), operand=_name('supported_status_codes')
176
+ ),
177
+ ast.Compare(
178
+ left=_attr('response', 'status_code'),
179
+ ops=[ast.NotIn()],
180
+ comparators=[_name('supported_status_codes')],
181
+ ),
182
+ ],
183
+ ),
184
+ body=[
185
+ ast.Expr(
186
+ value=_call(
187
+ func=_attr('response', 'raise_for_status'),
188
+ )
189
+ )
190
+ ],
191
+ orelse=[],
192
+ ),
193
+ _assign(
194
+ target=_name('data'),
195
+ value=_call(
196
+ func=_attr('response', 'json'),
197
+ ),
198
+ ),
199
+ ast.If(
200
+ test=ast.UnaryOp(op=ast.Not(), operand=_name('response_model')),
201
+ body=[ast.Return(value=_name('data'))],
202
+ orelse=[],
203
+ ),
204
+ _assign(
205
+ target=_name('validated_data'),
206
+ value=_call(
207
+ func=_attr(
208
+ _call(
209
+ func=_name('TypeAdapter'),
210
+ args=[_name('response_model')],
211
+ ),
212
+ 'validate_python',
213
+ ),
214
+ args=[_name('data')],
215
+ ),
216
+ ),
217
+ ast.If(
218
+ test=_call(
219
+ func=_name('isinstance'),
220
+ args=[_name('validated_data'), _name('RootModel')],
221
+ ),
222
+ body=[ast.Return(value=_attr('validated_data', 'root'))],
223
+ orelse=[],
224
+ ),
225
+ ast.Return(value=_name('validated_data')),
226
+ ],
227
+ )
228
+ ]
229
+
230
+ return _async_func(
231
+ name='request_async',
232
+ args=args,
233
+ body=body,
234
+ kwargs=kwargs,
235
+ kwonlyargs=kwonlyargs,
236
+ kw_defaults=[_name('Json'), ast.Constant(value=None)],
237
+ returns=_name('T'),
238
+ ), {
239
+ 'httpx': ['AsyncClient'],
240
+ 'pydantic': ['TypeAdapter', 'Json', 'RootModel'],
241
+ 'typing': ['Type', 'TypeVar', 'Union'],
242
+ }
243
+
244
+
245
+ def get_parameters(
246
+ parameters: list[Parameter],
247
+ ) -> tuple[list[ast.arg], list[ast.arg], list[ast.expr]]:
248
+ args = []
249
+ kwonlyargs = []
250
+ kw_defaults = []
251
+ imports = {}
252
+
253
+ for param in parameters:
254
+ param_name = param.name
255
+ param_type = param.type.annotation_ast if param.type else None
256
+ param_required = param.required
257
+
258
+ if param_type is None:
259
+ param_type = _name('Any')
260
+ imports.setdefault('typing', set()).add('Any')
261
+
262
+ if param_required:
263
+ # Required parameters go in regular args
264
+ args.append(_argument(param_name, param_type))
265
+ else:
266
+ # Optional parameters go in kwonlyargs with None default
267
+ kwonlyargs.append(_argument(param_name, param_type))
268
+ kw_defaults.append(ast.Constant(None))
269
+
270
+ return args, kwonlyargs, kw_defaults, imports
271
+
272
+
273
+ def get_base_call_keywords(
274
+ method, path, response_model: Type, supported_status_codes: list | None = None
275
+ ) -> list[ast.keyword]:
276
+ return [
277
+ ast.keyword(arg='method', value=ast.Constant(value=method)),
278
+ ast.keyword(arg='path', value=path),
279
+ ast.keyword(
280
+ arg='response_model',
281
+ value=response_model.annotation_ast
282
+ if response_model
283
+ else ast.Constant(None),
284
+ ),
285
+ ast.keyword(
286
+ arg='supported_status_codes', value=ast.Constant(supported_status_codes)
287
+ ),
288
+ ]
289
+
290
+
291
+ def build_header_params(headers: list[Parameter]) -> ast.Dict | None:
292
+ if not headers:
293
+ return None
294
+
295
+ return ast.Dict(
296
+ keys=[ast.Constant(value=header.name) for header in headers],
297
+ values=[_name(header.name) for header in headers],
298
+ )
299
+
300
+
301
+ def build_query_params(queries: list[Parameter]) -> ast.Dict | None:
302
+ if not queries:
303
+ return None
304
+
305
+ return ast.Dict(
306
+ keys=[ast.Constant(value=query.name) for query in queries],
307
+ values=[_name(query.name) for query in queries],
308
+ )
309
+
310
+
311
+ def build_path_params(
312
+ paths: list[Parameter], path: str
313
+ ) -> ast.JoinedStr | ast.Constant:
314
+ """Build an f-string AST that interpolates path parameters."""
315
+ if not paths:
316
+ return ast.Constant(value=path)
317
+
318
+ # Split the path into parts and build the f-string
319
+ values = []
320
+ current_pos = 0
321
+
322
+ for path_param in paths:
323
+ param_placeholder = f'{{{path_param.name}}}'
324
+ param_pos = path.find(param_placeholder, current_pos)
325
+
326
+ if param_pos != -1:
327
+ # Add any literal text before the parameter
328
+ if param_pos > current_pos:
329
+ literal_text = path[current_pos:param_pos]
330
+ values.append(ast.Constant(value=literal_text))
331
+
332
+ # Add the formatted value for the parameter
333
+ values.append(
334
+ ast.FormattedValue(
335
+ value=_name(path_param.name),
336
+ conversion=-1, # No conversion (default)
337
+ )
338
+ )
339
+
340
+ current_pos = param_pos + len(param_placeholder)
341
+
342
+ # Add any remaining literal text after the last parameter
343
+ if current_pos < len(path):
344
+ remaining_text = path[current_pos:]
345
+ values.append(ast.Constant(value=remaining_text))
346
+
347
+ return ast.JoinedStr(values=values)
348
+
349
+
350
+ def build_body_params(body: Parameter | None) -> ast.expr | None:
351
+ if not body:
352
+ return None
353
+
354
+ if body.type.type == 'model' or body.type.type == 'root_model':
355
+ return _call(
356
+ func=_attr(_name(body.name), 'model_dump'),
357
+ args=[],
358
+ )
359
+
360
+ return _name(body.name)
361
+
362
+
363
+ def prepare_call_from_parameters(
364
+ parameters: list[Parameter] | None, path: str
365
+ ) -> tuple[ast.expr, ast.expr, ast.expr, ast.expr]:
366
+ if not parameters:
367
+ parameters = []
368
+
369
+ query_params = [p for p in parameters if p.location == 'query']
370
+ path_params = [p for p in parameters if p.location == 'path']
371
+ header_params = [p for p in parameters if p.location == 'header']
372
+ body_params = [p for p in parameters if p.location == 'body']
373
+
374
+ if len(body_params) > 1:
375
+ raise ValueError('Multiple body parameters are not supported.')
376
+
377
+ return (
378
+ build_query_params(query_params),
379
+ build_header_params(header_params),
380
+ build_body_params(body_params[0] if len(body_params) == 1 else None),
381
+ build_path_params(path_params, path),
382
+ )
383
+
384
+
385
+ def request_fn(
386
+ name: str,
387
+ method: str,
388
+ path: str,
389
+ response_model: Type,
390
+ docs: str | None = None,
391
+ parameters: list | None = None,
392
+ supported_status_codes: list | None = None,
393
+ ):
394
+ args, kwonlyargs, kw_defaults, imports = get_parameters(parameters)
395
+
396
+ query_params, header_params, body_params, processed_path = (
397
+ prepare_call_from_parameters(parameters, path)
398
+ )
399
+ call_keywords = get_base_call_keywords(
400
+ method, processed_path, response_model, supported_status_codes
401
+ )
402
+
403
+ if query_params:
404
+ call_keywords.append(ast.keyword(arg='params', value=query_params))
405
+ if header_params:
406
+ call_keywords.append(ast.keyword(arg='headers', value=header_params))
407
+ if body_params:
408
+ call_keywords.append(ast.keyword(arg='json', value=body_params))
409
+
410
+ call_keywords.append(ast.keyword(arg=None, value=_name('kwargs')))
411
+
412
+ body = [
413
+ ast.Return(
414
+ value=_call(
415
+ func=_name('request_sync'),
416
+ args=[],
417
+ keywords=call_keywords,
418
+ )
419
+ )
420
+ ]
421
+
422
+ if docs:
423
+ docs = textwrap.dedent(f'\n{docs}\n').strip()
424
+ body.insert(0, ast.Expr(value=ast.Constant(value=docs)))
425
+
426
+ response_model_ast = response_model.annotation_ast if response_model else None
427
+ if not response_model_ast:
428
+ response_model_ast = _name('Any')
429
+ imports.setdefault('typing', set()).add('Any')
430
+
431
+ return _func(
432
+ name=name,
433
+ args=args,
434
+ body=body,
435
+ kwargs=_argument('kwargs', _name('dict')),
436
+ kwonlyargs=kwonlyargs,
437
+ kw_defaults=kw_defaults,
438
+ returns=response_model_ast,
439
+ ), ({'httpx': ['request'], **imports})
440
+
441
+
442
+ def async_request_fn(
443
+ name: str,
444
+ method: str,
445
+ path: str,
446
+ response_model: Type,
447
+ docs: str | None = None,
448
+ parameters: list | None = None,
449
+ supported_status_codes: list | None = None,
450
+ ):
451
+ args, kwonlyargs, kw_defaults, imports = get_parameters(parameters)
452
+
453
+ query_params, header_params, body_params, processed_path = (
454
+ prepare_call_from_parameters(parameters, path)
455
+ )
456
+ call_keywords = get_base_call_keywords(
457
+ method, processed_path, response_model, supported_status_codes
458
+ )
459
+
460
+ if query_params:
461
+ call_keywords.append(ast.keyword(arg='params', value=query_params))
462
+ if header_params:
463
+ call_keywords.append(ast.keyword(arg='headers', value=header_params))
464
+ if body_params:
465
+ call_keywords.append(ast.keyword(arg='json', value=body_params))
466
+
467
+ # Add **kwargs to the call
468
+ call_keywords.append(ast.keyword(arg=None, value=_name('kwargs')))
469
+
470
+ body = [
471
+ ast.Return(
472
+ value=ast.Await(
473
+ value=_call(
474
+ func=_name('request_async'),
475
+ args=[],
476
+ keywords=call_keywords,
477
+ )
478
+ )
479
+ )
480
+ ]
481
+
482
+ if docs:
483
+ docs = textwrap.dedent(f'\n{docs}\n').strip()
484
+ body.insert(0, ast.Expr(value=ast.Constant(value=docs)))
485
+
486
+ response_model_ast = response_model.annotation_ast if response_model else None
487
+ if not response_model_ast:
488
+ response_model_ast = _name('Any')
489
+ imports.setdefault('typing', set()).add('Any')
490
+
491
+ return _async_func(
492
+ name=name,
493
+ args=args,
494
+ body=body,
495
+ kwargs=_argument('kwargs', _name('dict')),
496
+ kwonlyargs=kwonlyargs,
497
+ kw_defaults=kw_defaults,
498
+ returns=response_model_ast,
499
+ ), {'httpx': ['AsyncClient'], **imports}