otterapi 0.0.3__py3-none-any.whl → 0.0.5__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 +0 -0
- otterapi/__main__.py +4 -0
- otterapi/cli.py +84 -0
- otterapi/codegen/__init__.py +0 -0
- otterapi/codegen/ast_utils.py +121 -0
- otterapi/codegen/endpoints.py +501 -0
- otterapi/codegen/generator.py +358 -0
- otterapi/codegen/openapi_processor.py +27 -0
- otterapi/codegen/type_generator.py +559 -0
- otterapi/codegen/utils.py +82 -0
- otterapi/config.py +74 -0
- {otterapi-0.0.3.dist-info → otterapi-0.0.5.dist-info}/METADATA +1 -1
- otterapi-0.0.5.dist-info/RECORD +16 -0
- otterapi-0.0.3.dist-info/RECORD +0 -5
- {otterapi-0.0.3.dist-info → otterapi-0.0.5.dist-info}/WHEEL +0 -0
- {otterapi-0.0.3.dist-info → otterapi-0.0.5.dist-info}/entry_points.txt +0 -0
otterapi/__init__.py
ADDED
|
File without changes
|
otterapi/__main__.py
ADDED
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()
|
|
File without changes
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import keyword
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
5
|
+
PYTHON_KEYWORDS = set(keyword.kwlist)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _name(name: str) -> ast.Name:
|
|
9
|
+
return ast.Name(id=name, ctx=ast.Load())
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _attr(value: str | ast.expr, attr: str) -> ast.Attribute:
|
|
13
|
+
return ast.Attribute(
|
|
14
|
+
value=_name(value) if isinstance(value, str) else value, attr=attr
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _subscript(generic: str, inner: ast.expr) -> ast.Subscript:
|
|
19
|
+
return ast.Subscript(value=_name(generic), slice=inner)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _union_expr(types: list[ast.expr]) -> ast.Subscript:
|
|
23
|
+
# Union[A, B, C]
|
|
24
|
+
return _subscript('Union', ast.Tuple(elts=types))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _optional_expr(inner: ast.expr) -> ast.Subscript:
|
|
28
|
+
return _subscript('Optional', inner)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _argument(name: str, value: ast.expr | None = None) -> ast.arg:
|
|
32
|
+
return ast.arg(
|
|
33
|
+
arg=name,
|
|
34
|
+
annotation=value,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _assign(target: ast.expr, value: ast.expr) -> ast.Assign:
|
|
39
|
+
return ast.Assign(
|
|
40
|
+
targets=[target],
|
|
41
|
+
value=value,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _import(module: str, names: list[str]) -> ast.ImportFrom:
|
|
46
|
+
return ast.ImportFrom(
|
|
47
|
+
module=module,
|
|
48
|
+
names=[ast.alias(name=name) for name in names],
|
|
49
|
+
level=0,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _call(
|
|
54
|
+
func: ast.expr,
|
|
55
|
+
args: list[ast.expr] | None = None,
|
|
56
|
+
keywords: list[ast.keyword] | None = None,
|
|
57
|
+
) -> ast.Call:
|
|
58
|
+
return ast.Call(
|
|
59
|
+
func=func,
|
|
60
|
+
args=args or [],
|
|
61
|
+
keywords=keywords or [],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _func(
|
|
66
|
+
name: str,
|
|
67
|
+
args: list[ast.arg],
|
|
68
|
+
body: list[ast.stmt],
|
|
69
|
+
returns: ast.expr | None = None,
|
|
70
|
+
kwargs: ast.arg = None,
|
|
71
|
+
kwonlyargs: list[ast.arg] = None,
|
|
72
|
+
kw_defaults: list[ast.expr] = None,
|
|
73
|
+
) -> ast.FunctionDef:
|
|
74
|
+
return ast.FunctionDef(
|
|
75
|
+
name=name,
|
|
76
|
+
args=ast.arguments(
|
|
77
|
+
posonlyargs=[],
|
|
78
|
+
args=args,
|
|
79
|
+
kwarg=kwargs,
|
|
80
|
+
kwonlyargs=kwonlyargs or [],
|
|
81
|
+
kw_defaults=kw_defaults or [],
|
|
82
|
+
defaults=[],
|
|
83
|
+
),
|
|
84
|
+
body=body,
|
|
85
|
+
decorator_list=[],
|
|
86
|
+
returns=returns,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _async_func(
|
|
91
|
+
name: str,
|
|
92
|
+
args: list[ast.arg],
|
|
93
|
+
body: list[ast.stmt],
|
|
94
|
+
returns: ast.expr | None = None,
|
|
95
|
+
kwargs: ast.arg = None,
|
|
96
|
+
kwonlyargs: list[ast.arg] = None,
|
|
97
|
+
kw_defaults: list[ast.expr] = None,
|
|
98
|
+
) -> ast.AsyncFunctionDef:
|
|
99
|
+
return ast.AsyncFunctionDef(
|
|
100
|
+
name=name,
|
|
101
|
+
args=ast.arguments(
|
|
102
|
+
posonlyargs=[],
|
|
103
|
+
args=args,
|
|
104
|
+
kwarg=kwargs,
|
|
105
|
+
kwonlyargs=kwonlyargs or [],
|
|
106
|
+
kw_defaults=kw_defaults or [],
|
|
107
|
+
defaults=[],
|
|
108
|
+
),
|
|
109
|
+
body=body,
|
|
110
|
+
decorator_list=[],
|
|
111
|
+
returns=returns,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _all(names: Iterable[str]) -> ast.Assign:
|
|
116
|
+
return _assign(
|
|
117
|
+
target=_name('__all__'),
|
|
118
|
+
value=ast.Tuple(
|
|
119
|
+
elts=[ast.Constant(value=name) for name in names], ctx=ast.Load()
|
|
120
|
+
),
|
|
121
|
+
)
|
|
@@ -0,0 +1,501 @@
|
|
|
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], dict[str, set[str]]]:
|
|
248
|
+
args = []
|
|
249
|
+
kwonlyargs = []
|
|
250
|
+
kw_defaults = []
|
|
251
|
+
imports = {}
|
|
252
|
+
|
|
253
|
+
for param in parameters:
|
|
254
|
+
if param.name == 'user-id':
|
|
255
|
+
print()
|
|
256
|
+
param_name = param.name_sanitized
|
|
257
|
+
param_type = param.type.annotation_ast if param.type else None
|
|
258
|
+
param_required = param.required
|
|
259
|
+
|
|
260
|
+
if param_type is None:
|
|
261
|
+
param_type = _name('Any')
|
|
262
|
+
imports.setdefault('typing', set()).add('Any')
|
|
263
|
+
|
|
264
|
+
if param_required:
|
|
265
|
+
# Required parameters go in regular args
|
|
266
|
+
args.append(_argument(param_name, param_type))
|
|
267
|
+
else:
|
|
268
|
+
# Optional parameters go in kwonlyargs with None default
|
|
269
|
+
kwonlyargs.append(_argument(param_name, param_type))
|
|
270
|
+
kw_defaults.append(ast.Constant(None))
|
|
271
|
+
|
|
272
|
+
return args, kwonlyargs, kw_defaults, imports
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def get_base_call_keywords(
|
|
276
|
+
method, path, response_model: Type, supported_status_codes: list | None = None
|
|
277
|
+
) -> list[ast.keyword]:
|
|
278
|
+
return [
|
|
279
|
+
ast.keyword(arg='method', value=ast.Constant(value=method)),
|
|
280
|
+
ast.keyword(arg='path', value=path),
|
|
281
|
+
ast.keyword(
|
|
282
|
+
arg='response_model',
|
|
283
|
+
value=response_model.annotation_ast
|
|
284
|
+
if response_model
|
|
285
|
+
else ast.Constant(None),
|
|
286
|
+
),
|
|
287
|
+
ast.keyword(
|
|
288
|
+
arg='supported_status_codes', value=ast.Constant(supported_status_codes)
|
|
289
|
+
),
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def build_header_params(headers: list[Parameter]) -> ast.Dict | None:
|
|
294
|
+
if not headers:
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
return ast.Dict(
|
|
298
|
+
keys=[ast.Constant(value=header.name) for header in headers],
|
|
299
|
+
values=[_name(header.name_sanitized) for header in headers],
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def build_query_params(queries: list[Parameter]) -> ast.Dict | None:
|
|
304
|
+
if not queries:
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
return ast.Dict(
|
|
308
|
+
keys=[ast.Constant(value=query.name) for query in queries],
|
|
309
|
+
values=[_name(query.name_sanitized) for query in queries],
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def build_path_params(
|
|
314
|
+
paths: list[Parameter], path: str
|
|
315
|
+
) -> ast.JoinedStr | ast.Constant:
|
|
316
|
+
"""Build an f-string AST that interpolates path parameters."""
|
|
317
|
+
if not paths:
|
|
318
|
+
return ast.Constant(value=path)
|
|
319
|
+
|
|
320
|
+
# Split the path into parts and build the f-string
|
|
321
|
+
values = []
|
|
322
|
+
current_pos = 0
|
|
323
|
+
|
|
324
|
+
for path_param in paths:
|
|
325
|
+
param_placeholder = f'{{{path_param.name}}}'
|
|
326
|
+
param_pos = path.find(param_placeholder, current_pos)
|
|
327
|
+
|
|
328
|
+
if param_pos != -1:
|
|
329
|
+
# Add any literal text before the parameter
|
|
330
|
+
if param_pos > current_pos:
|
|
331
|
+
literal_text = path[current_pos:param_pos]
|
|
332
|
+
values.append(ast.Constant(value=literal_text))
|
|
333
|
+
|
|
334
|
+
# Add the formatted value for the parameter
|
|
335
|
+
values.append(
|
|
336
|
+
ast.FormattedValue(
|
|
337
|
+
value=_name(path_param.name_sanitized),
|
|
338
|
+
conversion=-1, # No conversion (default)
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
current_pos = param_pos + len(param_placeholder)
|
|
343
|
+
|
|
344
|
+
# Add any remaining literal text after the last parameter
|
|
345
|
+
if current_pos < len(path):
|
|
346
|
+
remaining_text = path[current_pos:]
|
|
347
|
+
values.append(ast.Constant(value=remaining_text))
|
|
348
|
+
|
|
349
|
+
return ast.JoinedStr(values=values)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def build_body_params(body: Parameter | None) -> ast.expr | None:
|
|
353
|
+
if not body:
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
if body.type.type == 'model' or body.type.type == 'root_model':
|
|
357
|
+
return _call(
|
|
358
|
+
func=_attr(_name(body.name_sanitized), 'model_dump'),
|
|
359
|
+
args=[],
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
return _name(body.name)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def prepare_call_from_parameters(
|
|
366
|
+
parameters: list[Parameter] | None, path: str
|
|
367
|
+
) -> tuple[ast.expr, ast.expr, ast.expr, ast.expr]:
|
|
368
|
+
if not parameters:
|
|
369
|
+
parameters = []
|
|
370
|
+
|
|
371
|
+
query_params = [p for p in parameters if p.location == 'query']
|
|
372
|
+
path_params = [p for p in parameters if p.location == 'path']
|
|
373
|
+
header_params = [p for p in parameters if p.location == 'header']
|
|
374
|
+
body_params = [p for p in parameters if p.location == 'body']
|
|
375
|
+
|
|
376
|
+
if len(body_params) > 1:
|
|
377
|
+
raise ValueError('Multiple body parameters are not supported.')
|
|
378
|
+
|
|
379
|
+
return (
|
|
380
|
+
build_query_params(query_params),
|
|
381
|
+
build_header_params(header_params),
|
|
382
|
+
build_body_params(body_params[0] if len(body_params) == 1 else None),
|
|
383
|
+
build_path_params(path_params, path),
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def request_fn(
|
|
388
|
+
name: str,
|
|
389
|
+
method: str,
|
|
390
|
+
path: str,
|
|
391
|
+
response_model: Type,
|
|
392
|
+
docs: str | None = None,
|
|
393
|
+
parameters: list | None = None,
|
|
394
|
+
supported_status_codes: list | None = None,
|
|
395
|
+
):
|
|
396
|
+
args, kwonlyargs, kw_defaults, imports = get_parameters(parameters)
|
|
397
|
+
|
|
398
|
+
query_params, header_params, body_params, processed_path = (
|
|
399
|
+
prepare_call_from_parameters(parameters, path)
|
|
400
|
+
)
|
|
401
|
+
call_keywords = get_base_call_keywords(
|
|
402
|
+
method, processed_path, response_model, supported_status_codes
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
if query_params:
|
|
406
|
+
call_keywords.append(ast.keyword(arg='params', value=query_params))
|
|
407
|
+
if header_params:
|
|
408
|
+
call_keywords.append(ast.keyword(arg='headers', value=header_params))
|
|
409
|
+
if body_params:
|
|
410
|
+
call_keywords.append(ast.keyword(arg='json', value=body_params))
|
|
411
|
+
|
|
412
|
+
call_keywords.append(ast.keyword(arg=None, value=_name('kwargs')))
|
|
413
|
+
|
|
414
|
+
body = [
|
|
415
|
+
ast.Return(
|
|
416
|
+
value=_call(
|
|
417
|
+
func=_name('request_sync'),
|
|
418
|
+
args=[],
|
|
419
|
+
keywords=call_keywords,
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
]
|
|
423
|
+
|
|
424
|
+
if docs:
|
|
425
|
+
docs = textwrap.dedent(f'\n{docs}\n').strip()
|
|
426
|
+
body.insert(0, ast.Expr(value=ast.Constant(value=docs)))
|
|
427
|
+
|
|
428
|
+
response_model_ast = response_model.annotation_ast if response_model else None
|
|
429
|
+
if not response_model_ast:
|
|
430
|
+
response_model_ast = _name('Any')
|
|
431
|
+
imports.setdefault('typing', set()).add('Any')
|
|
432
|
+
|
|
433
|
+
return _func(
|
|
434
|
+
name=name,
|
|
435
|
+
args=args,
|
|
436
|
+
body=body,
|
|
437
|
+
kwargs=_argument('kwargs', _name('dict')),
|
|
438
|
+
kwonlyargs=kwonlyargs,
|
|
439
|
+
kw_defaults=kw_defaults,
|
|
440
|
+
returns=response_model_ast,
|
|
441
|
+
), ({'httpx': ['request'], **imports})
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def async_request_fn(
|
|
445
|
+
name: str,
|
|
446
|
+
method: str,
|
|
447
|
+
path: str,
|
|
448
|
+
response_model: Type,
|
|
449
|
+
docs: str | None = None,
|
|
450
|
+
parameters: list | None = None,
|
|
451
|
+
supported_status_codes: list | None = None,
|
|
452
|
+
):
|
|
453
|
+
args, kwonlyargs, kw_defaults, imports = get_parameters(parameters)
|
|
454
|
+
|
|
455
|
+
query_params, header_params, body_params, processed_path = (
|
|
456
|
+
prepare_call_from_parameters(parameters, path)
|
|
457
|
+
)
|
|
458
|
+
call_keywords = get_base_call_keywords(
|
|
459
|
+
method, processed_path, response_model, supported_status_codes
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
if query_params:
|
|
463
|
+
call_keywords.append(ast.keyword(arg='params', value=query_params))
|
|
464
|
+
if header_params:
|
|
465
|
+
call_keywords.append(ast.keyword(arg='headers', value=header_params))
|
|
466
|
+
if body_params:
|
|
467
|
+
call_keywords.append(ast.keyword(arg='json', value=body_params))
|
|
468
|
+
|
|
469
|
+
# Add **kwargs to the call
|
|
470
|
+
call_keywords.append(ast.keyword(arg=None, value=_name('kwargs')))
|
|
471
|
+
|
|
472
|
+
body = [
|
|
473
|
+
ast.Return(
|
|
474
|
+
value=ast.Await(
|
|
475
|
+
value=_call(
|
|
476
|
+
func=_name('request_async'),
|
|
477
|
+
args=[],
|
|
478
|
+
keywords=call_keywords,
|
|
479
|
+
)
|
|
480
|
+
)
|
|
481
|
+
)
|
|
482
|
+
]
|
|
483
|
+
|
|
484
|
+
if docs:
|
|
485
|
+
docs = textwrap.dedent(f'\n{docs}\n').strip()
|
|
486
|
+
body.insert(0, ast.Expr(value=ast.Constant(value=docs)))
|
|
487
|
+
|
|
488
|
+
response_model_ast = response_model.annotation_ast if response_model else None
|
|
489
|
+
if not response_model_ast:
|
|
490
|
+
response_model_ast = _name('Any')
|
|
491
|
+
imports.setdefault('typing', set()).add('Any')
|
|
492
|
+
|
|
493
|
+
return _async_func(
|
|
494
|
+
name=name,
|
|
495
|
+
args=args,
|
|
496
|
+
body=body,
|
|
497
|
+
kwargs=_argument('kwargs', _name('dict')),
|
|
498
|
+
kwonlyargs=kwonlyargs,
|
|
499
|
+
kw_defaults=kw_defaults,
|
|
500
|
+
returns=response_model_ast,
|
|
501
|
+
), {'httpx': ['AsyncClient'], **imports}
|