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
|
@@ -0,0 +1,1038 @@
|
|
|
1
|
+
"""Tests for DataFrame configuration and code generation."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from otterapi.config import DataFrameConfig, EndpointDataFrameConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestEndpointDataFrameConfig:
|
|
11
|
+
"""Tests for EndpointDataFrameConfig model."""
|
|
12
|
+
|
|
13
|
+
def test_default_values(self):
|
|
14
|
+
"""Test that default values are None (inherit from parent)."""
|
|
15
|
+
config = EndpointDataFrameConfig()
|
|
16
|
+
assert config.enabled is None
|
|
17
|
+
assert config.path is None
|
|
18
|
+
assert config.pandas is None
|
|
19
|
+
assert config.polars is None
|
|
20
|
+
|
|
21
|
+
def test_explicit_values(self):
|
|
22
|
+
"""Test setting explicit values."""
|
|
23
|
+
config = EndpointDataFrameConfig(
|
|
24
|
+
enabled=True,
|
|
25
|
+
path='data.items',
|
|
26
|
+
pandas=True,
|
|
27
|
+
polars=False,
|
|
28
|
+
)
|
|
29
|
+
assert config.enabled is True
|
|
30
|
+
assert config.path == 'data.items'
|
|
31
|
+
assert config.pandas is True
|
|
32
|
+
assert config.polars is False
|
|
33
|
+
|
|
34
|
+
def test_rejects_extra_fields(self):
|
|
35
|
+
"""Test that extra fields are rejected."""
|
|
36
|
+
with pytest.raises(Exception): # Pydantic ValidationError
|
|
37
|
+
EndpointDataFrameConfig(unknown_field='value')
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestDataFrameConfig:
|
|
41
|
+
"""Tests for DataFrameConfig model."""
|
|
42
|
+
|
|
43
|
+
def test_default_values(self):
|
|
44
|
+
"""Test default configuration values."""
|
|
45
|
+
config = DataFrameConfig()
|
|
46
|
+
assert config.enabled is False
|
|
47
|
+
assert config.pandas is True
|
|
48
|
+
assert config.polars is False
|
|
49
|
+
assert config.default_path is None
|
|
50
|
+
assert config.include_all is True
|
|
51
|
+
assert config.endpoints == {}
|
|
52
|
+
|
|
53
|
+
def test_enabled_with_defaults(self):
|
|
54
|
+
"""Test enabled config uses correct defaults."""
|
|
55
|
+
config = DataFrameConfig(enabled=True)
|
|
56
|
+
assert config.enabled is True
|
|
57
|
+
assert config.pandas is True # Default to pandas
|
|
58
|
+
assert config.polars is False
|
|
59
|
+
|
|
60
|
+
def test_both_libraries_enabled(self):
|
|
61
|
+
"""Test enabling both pandas and polars."""
|
|
62
|
+
config = DataFrameConfig(
|
|
63
|
+
enabled=True,
|
|
64
|
+
pandas=True,
|
|
65
|
+
polars=True,
|
|
66
|
+
)
|
|
67
|
+
assert config.pandas is True
|
|
68
|
+
assert config.polars is True
|
|
69
|
+
|
|
70
|
+
def test_polars_only(self):
|
|
71
|
+
"""Test enabling only polars."""
|
|
72
|
+
config = DataFrameConfig(
|
|
73
|
+
enabled=True,
|
|
74
|
+
pandas=False,
|
|
75
|
+
polars=True,
|
|
76
|
+
)
|
|
77
|
+
assert config.pandas is False
|
|
78
|
+
assert config.polars is True
|
|
79
|
+
|
|
80
|
+
def test_with_endpoints(self):
|
|
81
|
+
"""Test configuration with endpoint overrides."""
|
|
82
|
+
config = DataFrameConfig(
|
|
83
|
+
enabled=True,
|
|
84
|
+
endpoints={
|
|
85
|
+
'get_users': EndpointDataFrameConfig(
|
|
86
|
+
path='data.users',
|
|
87
|
+
pandas=True,
|
|
88
|
+
polars=True,
|
|
89
|
+
),
|
|
90
|
+
'get_items': EndpointDataFrameConfig(
|
|
91
|
+
enabled=False,
|
|
92
|
+
),
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
assert 'get_users' in config.endpoints
|
|
96
|
+
assert config.endpoints['get_users'].path == 'data.users'
|
|
97
|
+
assert config.endpoints['get_items'].enabled is False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TestShouldGenerateForEndpoint:
|
|
101
|
+
"""Tests for DataFrameConfig.should_generate_for_endpoint method."""
|
|
102
|
+
|
|
103
|
+
def test_disabled_returns_false(self):
|
|
104
|
+
"""Test that disabled config returns False for all."""
|
|
105
|
+
config = DataFrameConfig(enabled=False)
|
|
106
|
+
gen_pandas, gen_polars, path = config.should_generate_for_endpoint(
|
|
107
|
+
'get_users', returns_list=True
|
|
108
|
+
)
|
|
109
|
+
assert gen_pandas is False
|
|
110
|
+
assert gen_polars is False
|
|
111
|
+
assert path is None
|
|
112
|
+
|
|
113
|
+
def test_default_generates_for_list_endpoints(self):
|
|
114
|
+
"""Test that default config generates for list endpoints."""
|
|
115
|
+
config = DataFrameConfig(enabled=True)
|
|
116
|
+
gen_pandas, gen_polars, path = config.should_generate_for_endpoint(
|
|
117
|
+
'get_users', returns_list=True
|
|
118
|
+
)
|
|
119
|
+
assert gen_pandas is True
|
|
120
|
+
assert gen_polars is False # polars is False by default
|
|
121
|
+
assert path is None
|
|
122
|
+
|
|
123
|
+
def test_does_not_generate_for_non_list(self):
|
|
124
|
+
"""Test that config doesn't generate for non-list endpoints."""
|
|
125
|
+
config = DataFrameConfig(enabled=True)
|
|
126
|
+
gen_pandas, gen_polars, path = config.should_generate_for_endpoint(
|
|
127
|
+
'get_user', returns_list=False
|
|
128
|
+
)
|
|
129
|
+
assert gen_pandas is False
|
|
130
|
+
assert gen_polars is False
|
|
131
|
+
|
|
132
|
+
def test_both_libraries(self):
|
|
133
|
+
"""Test generating for both libraries."""
|
|
134
|
+
config = DataFrameConfig(enabled=True, pandas=True, polars=True)
|
|
135
|
+
gen_pandas, gen_polars, path = config.should_generate_for_endpoint(
|
|
136
|
+
'get_users', returns_list=True
|
|
137
|
+
)
|
|
138
|
+
assert gen_pandas is True
|
|
139
|
+
assert gen_polars is True
|
|
140
|
+
|
|
141
|
+
def test_default_path_used(self):
|
|
142
|
+
"""Test that default_path is used."""
|
|
143
|
+
config = DataFrameConfig(enabled=True, default_path='data.items')
|
|
144
|
+
gen_pandas, gen_polars, path = config.should_generate_for_endpoint(
|
|
145
|
+
'get_users', returns_list=True
|
|
146
|
+
)
|
|
147
|
+
assert path == 'data.items'
|
|
148
|
+
|
|
149
|
+
def test_endpoint_override_path(self):
|
|
150
|
+
"""Test endpoint-specific path override."""
|
|
151
|
+
config = DataFrameConfig(
|
|
152
|
+
enabled=True,
|
|
153
|
+
default_path='data.items',
|
|
154
|
+
endpoints={
|
|
155
|
+
'get_users': EndpointDataFrameConfig(path='data.users'),
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
gen_pandas, gen_polars, path = config.should_generate_for_endpoint(
|
|
159
|
+
'get_users', returns_list=True
|
|
160
|
+
)
|
|
161
|
+
assert path == 'data.users'
|
|
162
|
+
|
|
163
|
+
def test_endpoint_override_disabled(self):
|
|
164
|
+
"""Test endpoint-specific disable override."""
|
|
165
|
+
config = DataFrameConfig(
|
|
166
|
+
enabled=True,
|
|
167
|
+
pandas=True,
|
|
168
|
+
polars=True,
|
|
169
|
+
endpoints={
|
|
170
|
+
'get_users': EndpointDataFrameConfig(enabled=False),
|
|
171
|
+
},
|
|
172
|
+
)
|
|
173
|
+
gen_pandas, gen_polars, path = config.should_generate_for_endpoint(
|
|
174
|
+
'get_users', returns_list=True
|
|
175
|
+
)
|
|
176
|
+
assert gen_pandas is False
|
|
177
|
+
assert gen_polars is False
|
|
178
|
+
|
|
179
|
+
def test_endpoint_override_libraries(self):
|
|
180
|
+
"""Test endpoint-specific library override."""
|
|
181
|
+
config = DataFrameConfig(
|
|
182
|
+
enabled=True,
|
|
183
|
+
pandas=True,
|
|
184
|
+
polars=True,
|
|
185
|
+
endpoints={
|
|
186
|
+
'get_users': EndpointDataFrameConfig(
|
|
187
|
+
pandas=False, # Disable pandas for this endpoint
|
|
188
|
+
polars=True,
|
|
189
|
+
),
|
|
190
|
+
},
|
|
191
|
+
)
|
|
192
|
+
gen_pandas, gen_polars, path = config.should_generate_for_endpoint(
|
|
193
|
+
'get_users', returns_list=True
|
|
194
|
+
)
|
|
195
|
+
assert gen_pandas is False
|
|
196
|
+
assert gen_polars is True
|
|
197
|
+
|
|
198
|
+
def test_include_all_false_without_endpoint_config(self):
|
|
199
|
+
"""Test include_all=False skips endpoints without config."""
|
|
200
|
+
config = DataFrameConfig(
|
|
201
|
+
enabled=True,
|
|
202
|
+
include_all=False,
|
|
203
|
+
)
|
|
204
|
+
gen_pandas, gen_polars, path = config.should_generate_for_endpoint(
|
|
205
|
+
'get_users', returns_list=True
|
|
206
|
+
)
|
|
207
|
+
assert gen_pandas is False
|
|
208
|
+
assert gen_polars is False
|
|
209
|
+
|
|
210
|
+
def test_include_all_false_with_endpoint_enabled(self):
|
|
211
|
+
"""Test include_all=False but endpoint explicitly enabled."""
|
|
212
|
+
config = DataFrameConfig(
|
|
213
|
+
enabled=True,
|
|
214
|
+
include_all=False,
|
|
215
|
+
endpoints={
|
|
216
|
+
'get_users': EndpointDataFrameConfig(enabled=True),
|
|
217
|
+
},
|
|
218
|
+
)
|
|
219
|
+
gen_pandas, gen_polars, path = config.should_generate_for_endpoint(
|
|
220
|
+
'get_users', returns_list=True
|
|
221
|
+
)
|
|
222
|
+
assert gen_pandas is True # Uses default pandas=True
|
|
223
|
+
assert gen_polars is False
|
|
224
|
+
|
|
225
|
+
def test_explicitly_enabled_ignores_returns_list(self):
|
|
226
|
+
"""Test that explicitly enabled endpoints generate even for non-list."""
|
|
227
|
+
config = DataFrameConfig(
|
|
228
|
+
enabled=True,
|
|
229
|
+
endpoints={
|
|
230
|
+
'get_user': EndpointDataFrameConfig(enabled=True),
|
|
231
|
+
},
|
|
232
|
+
)
|
|
233
|
+
gen_pandas, gen_polars, path = config.should_generate_for_endpoint(
|
|
234
|
+
'get_user', returns_list=False
|
|
235
|
+
)
|
|
236
|
+
assert gen_pandas is True
|
|
237
|
+
|
|
238
|
+
def test_inherits_parent_libraries_when_not_overridden(self):
|
|
239
|
+
"""Test that endpoint inherits parent library settings."""
|
|
240
|
+
config = DataFrameConfig(
|
|
241
|
+
enabled=True,
|
|
242
|
+
pandas=True,
|
|
243
|
+
polars=True,
|
|
244
|
+
endpoints={
|
|
245
|
+
'get_users': EndpointDataFrameConfig(
|
|
246
|
+
path='data.users',
|
|
247
|
+
# pandas and polars not set, should inherit
|
|
248
|
+
),
|
|
249
|
+
},
|
|
250
|
+
)
|
|
251
|
+
gen_pandas, gen_polars, path = config.should_generate_for_endpoint(
|
|
252
|
+
'get_users', returns_list=True
|
|
253
|
+
)
|
|
254
|
+
assert gen_pandas is True
|
|
255
|
+
assert gen_polars is True
|
|
256
|
+
assert path == 'data.users'
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class TestDataFrameModuleGenerator:
|
|
260
|
+
"""Tests for the DataFrame module generator."""
|
|
261
|
+
|
|
262
|
+
def test_generate_dataframe_module(self, tmp_path):
|
|
263
|
+
"""Test generating the _dataframe.py module."""
|
|
264
|
+
from otterapi.codegen.dataframes import generate_dataframe_module
|
|
265
|
+
|
|
266
|
+
output_path = generate_dataframe_module(tmp_path)
|
|
267
|
+
|
|
268
|
+
assert output_path.exists()
|
|
269
|
+
assert output_path.name == '_dataframe.py'
|
|
270
|
+
|
|
271
|
+
content = output_path.read_text()
|
|
272
|
+
assert 'def extract_path' in content
|
|
273
|
+
assert 'def to_pandas' in content
|
|
274
|
+
assert 'def to_polars' in content
|
|
275
|
+
assert 'import pandas as pd' in content
|
|
276
|
+
assert 'import polars as pl' in content
|
|
277
|
+
assert 'def _normalize_data' in content
|
|
278
|
+
assert 'def _to_dict' in content
|
|
279
|
+
|
|
280
|
+
def test_dataframe_module_is_valid_python(self, tmp_path):
|
|
281
|
+
"""Test that generated module is valid Python."""
|
|
282
|
+
from otterapi.codegen.dataframes import generate_dataframe_module
|
|
283
|
+
|
|
284
|
+
output_path = generate_dataframe_module(tmp_path)
|
|
285
|
+
content = output_path.read_text()
|
|
286
|
+
|
|
287
|
+
# Should not raise SyntaxError
|
|
288
|
+
ast.parse(content)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class TestDataFrameMethodConfig:
|
|
292
|
+
"""Tests for DataFrameMethodConfig dataclass."""
|
|
293
|
+
|
|
294
|
+
def test_default_values(self):
|
|
295
|
+
"""Test default values are all False/None."""
|
|
296
|
+
from otterapi.codegen.dataframes import DataFrameMethodConfig
|
|
297
|
+
|
|
298
|
+
config = DataFrameMethodConfig()
|
|
299
|
+
assert config.generate_pandas is False
|
|
300
|
+
assert config.generate_polars is False
|
|
301
|
+
assert config.path is None
|
|
302
|
+
|
|
303
|
+
def test_explicit_values(self):
|
|
304
|
+
"""Test setting explicit values."""
|
|
305
|
+
from otterapi.codegen.dataframes import DataFrameMethodConfig
|
|
306
|
+
|
|
307
|
+
config = DataFrameMethodConfig(
|
|
308
|
+
generate_pandas=True,
|
|
309
|
+
generate_polars=True,
|
|
310
|
+
path='data.items',
|
|
311
|
+
)
|
|
312
|
+
assert config.generate_pandas is True
|
|
313
|
+
assert config.generate_polars is True
|
|
314
|
+
assert config.path == 'data.items'
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class TestDataFrameDelegatingFunction:
|
|
318
|
+
"""Tests for DataFrame delegating function generation."""
|
|
319
|
+
|
|
320
|
+
def test_build_delegating_dataframe_fn_pandas(self):
|
|
321
|
+
"""Test building a pandas DataFrame delegating function."""
|
|
322
|
+
from otterapi.codegen.endpoints import build_delegating_dataframe_fn
|
|
323
|
+
|
|
324
|
+
fn_ast, imports = build_delegating_dataframe_fn(
|
|
325
|
+
fn_name='get_users_df',
|
|
326
|
+
client_method_name='get_users_df',
|
|
327
|
+
parameters=None,
|
|
328
|
+
request_body_info=None,
|
|
329
|
+
library='pandas',
|
|
330
|
+
default_path=None,
|
|
331
|
+
docs='Get all users.',
|
|
332
|
+
is_async=False,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
assert isinstance(fn_ast, ast.FunctionDef)
|
|
336
|
+
assert fn_ast.name == 'get_users_df'
|
|
337
|
+
# Return type should be string annotation
|
|
338
|
+
assert isinstance(fn_ast.returns, ast.Constant)
|
|
339
|
+
assert fn_ast.returns.value == 'pd.DataFrame'
|
|
340
|
+
|
|
341
|
+
def test_build_delegating_dataframe_fn_polars(self):
|
|
342
|
+
"""Test building a polars DataFrame delegating function."""
|
|
343
|
+
from otterapi.codegen.endpoints import build_delegating_dataframe_fn
|
|
344
|
+
|
|
345
|
+
fn_ast, imports = build_delegating_dataframe_fn(
|
|
346
|
+
fn_name='get_users_pl',
|
|
347
|
+
client_method_name='get_users_pl',
|
|
348
|
+
parameters=None,
|
|
349
|
+
request_body_info=None,
|
|
350
|
+
library='polars',
|
|
351
|
+
default_path=None,
|
|
352
|
+
docs='Get all users.',
|
|
353
|
+
is_async=False,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
assert isinstance(fn_ast, ast.FunctionDef)
|
|
357
|
+
assert fn_ast.name == 'get_users_pl'
|
|
358
|
+
assert isinstance(fn_ast.returns, ast.Constant)
|
|
359
|
+
assert fn_ast.returns.value == 'pl.DataFrame'
|
|
360
|
+
|
|
361
|
+
def test_build_delegating_dataframe_fn_async(self):
|
|
362
|
+
"""Test building an async DataFrame delegating function."""
|
|
363
|
+
from otterapi.codegen.endpoints import build_delegating_dataframe_fn
|
|
364
|
+
|
|
365
|
+
fn_ast, imports = build_delegating_dataframe_fn(
|
|
366
|
+
fn_name='aget_users_df',
|
|
367
|
+
client_method_name='aget_users_df',
|
|
368
|
+
parameters=None,
|
|
369
|
+
request_body_info=None,
|
|
370
|
+
library='pandas',
|
|
371
|
+
default_path=None,
|
|
372
|
+
docs='Get all users.',
|
|
373
|
+
is_async=True,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
assert isinstance(fn_ast, ast.AsyncFunctionDef)
|
|
377
|
+
assert fn_ast.name == 'aget_users_df'
|
|
378
|
+
|
|
379
|
+
def test_build_delegating_dataframe_fn_with_path(self):
|
|
380
|
+
"""Test DataFrame function with default path."""
|
|
381
|
+
from otterapi.codegen.endpoints import build_delegating_dataframe_fn
|
|
382
|
+
|
|
383
|
+
fn_ast, imports = build_delegating_dataframe_fn(
|
|
384
|
+
fn_name='get_users_df',
|
|
385
|
+
client_method_name='get_users_df',
|
|
386
|
+
parameters=None,
|
|
387
|
+
request_body_info=None,
|
|
388
|
+
library='pandas',
|
|
389
|
+
default_path='data.users',
|
|
390
|
+
docs='Get all users.',
|
|
391
|
+
is_async=False,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
# Find the path parameter default value
|
|
395
|
+
path_default = None
|
|
396
|
+
for i, kwarg in enumerate(fn_ast.args.kwonlyargs):
|
|
397
|
+
if kwarg.arg == 'path':
|
|
398
|
+
path_default = fn_ast.args.kw_defaults[i]
|
|
399
|
+
break
|
|
400
|
+
|
|
401
|
+
assert path_default is not None
|
|
402
|
+
assert isinstance(path_default, ast.Constant)
|
|
403
|
+
assert path_default.value == 'data.users'
|
|
404
|
+
|
|
405
|
+
def test_build_delegating_dataframe_fn_includes_path_param(self):
|
|
406
|
+
"""Test that DataFrame function includes path parameter."""
|
|
407
|
+
from otterapi.codegen.endpoints import build_delegating_dataframe_fn
|
|
408
|
+
|
|
409
|
+
fn_ast, imports = build_delegating_dataframe_fn(
|
|
410
|
+
fn_name='get_users_df',
|
|
411
|
+
client_method_name='get_users_df',
|
|
412
|
+
parameters=None,
|
|
413
|
+
request_body_info=None,
|
|
414
|
+
library='pandas',
|
|
415
|
+
default_path=None,
|
|
416
|
+
docs='Get all users.',
|
|
417
|
+
is_async=False,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
# Check that 'path' is in kwonlyargs
|
|
421
|
+
kwarg_names = [arg.arg for arg in fn_ast.args.kwonlyargs]
|
|
422
|
+
assert 'path' in kwarg_names
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
class TestDocumentConfigWithDataFrame:
|
|
426
|
+
"""Tests for DocumentConfig with DataFrame settings."""
|
|
427
|
+
|
|
428
|
+
def test_document_config_has_dataframe(self):
|
|
429
|
+
"""Test that DocumentConfig has dataframe field."""
|
|
430
|
+
from otterapi.config import DocumentConfig
|
|
431
|
+
|
|
432
|
+
config = DocumentConfig(
|
|
433
|
+
source='https://example.com/openapi.json',
|
|
434
|
+
output='./client',
|
|
435
|
+
)
|
|
436
|
+
assert hasattr(config, 'dataframe')
|
|
437
|
+
assert isinstance(config.dataframe, DataFrameConfig)
|
|
438
|
+
|
|
439
|
+
def test_document_config_with_dataframe_enabled(self):
|
|
440
|
+
"""Test DocumentConfig with DataFrame enabled."""
|
|
441
|
+
from otterapi.config import DocumentConfig
|
|
442
|
+
|
|
443
|
+
config = DocumentConfig(
|
|
444
|
+
source='https://example.com/openapi.json',
|
|
445
|
+
output='./client',
|
|
446
|
+
dataframe=DataFrameConfig(
|
|
447
|
+
enabled=True,
|
|
448
|
+
pandas=True,
|
|
449
|
+
polars=True,
|
|
450
|
+
),
|
|
451
|
+
)
|
|
452
|
+
assert config.dataframe.enabled is True
|
|
453
|
+
assert config.dataframe.pandas is True
|
|
454
|
+
assert config.dataframe.polars is True
|
|
455
|
+
|
|
456
|
+
def test_document_config_with_endpoint_overrides(self):
|
|
457
|
+
"""Test DocumentConfig with DataFrame endpoint overrides."""
|
|
458
|
+
from otterapi.config import DocumentConfig
|
|
459
|
+
|
|
460
|
+
config = DocumentConfig(
|
|
461
|
+
source='https://example.com/openapi.json',
|
|
462
|
+
output='./client',
|
|
463
|
+
dataframe=DataFrameConfig(
|
|
464
|
+
enabled=True,
|
|
465
|
+
endpoints={
|
|
466
|
+
'get_users': EndpointDataFrameConfig(
|
|
467
|
+
path='data.users',
|
|
468
|
+
),
|
|
469
|
+
},
|
|
470
|
+
),
|
|
471
|
+
)
|
|
472
|
+
assert 'get_users' in config.dataframe.endpoints
|
|
473
|
+
assert config.dataframe.endpoints['get_users'].path == 'data.users'
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
class TestDataFrameIntegration:
|
|
477
|
+
"""Integration tests for DataFrame code generation."""
|
|
478
|
+
|
|
479
|
+
@pytest.fixture
|
|
480
|
+
def simple_openapi_spec(self):
|
|
481
|
+
"""A simple OpenAPI spec with list-returning endpoints."""
|
|
482
|
+
return {
|
|
483
|
+
'openapi': '3.0.0',
|
|
484
|
+
'info': {'title': 'Test API', 'version': '1.0.0'},
|
|
485
|
+
'servers': [{'url': 'https://api.example.com'}],
|
|
486
|
+
'paths': {
|
|
487
|
+
'/users': {
|
|
488
|
+
'get': {
|
|
489
|
+
'operationId': 'getUsers',
|
|
490
|
+
'summary': 'Get all users',
|
|
491
|
+
'responses': {
|
|
492
|
+
'200': {
|
|
493
|
+
'description': 'Success',
|
|
494
|
+
'content': {
|
|
495
|
+
'application/json': {
|
|
496
|
+
'schema': {
|
|
497
|
+
'type': 'array',
|
|
498
|
+
'items': {
|
|
499
|
+
'$ref': '#/components/schemas/User'
|
|
500
|
+
},
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
},
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
}
|
|
507
|
+
},
|
|
508
|
+
'/users/{id}': {
|
|
509
|
+
'get': {
|
|
510
|
+
'operationId': 'getUserById',
|
|
511
|
+
'summary': 'Get user by ID',
|
|
512
|
+
'parameters': [
|
|
513
|
+
{
|
|
514
|
+
'name': 'id',
|
|
515
|
+
'in': 'path',
|
|
516
|
+
'required': True,
|
|
517
|
+
'schema': {'type': 'integer'},
|
|
518
|
+
}
|
|
519
|
+
],
|
|
520
|
+
'responses': {
|
|
521
|
+
'200': {
|
|
522
|
+
'description': 'Success',
|
|
523
|
+
'content': {
|
|
524
|
+
'application/json': {
|
|
525
|
+
'schema': {'$ref': '#/components/schemas/User'}
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
'components': {
|
|
534
|
+
'schemas': {
|
|
535
|
+
'User': {
|
|
536
|
+
'type': 'object',
|
|
537
|
+
'properties': {
|
|
538
|
+
'id': {'type': 'integer'},
|
|
539
|
+
'name': {'type': 'string'},
|
|
540
|
+
'email': {'type': 'string'},
|
|
541
|
+
},
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
},
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
def test_generate_with_dataframe_enabled(self, tmp_path, simple_openapi_spec):
|
|
548
|
+
"""Test generating a client with DataFrame methods enabled."""
|
|
549
|
+
import json
|
|
550
|
+
|
|
551
|
+
from otterapi.codegen.codegen import Codegen
|
|
552
|
+
from otterapi.config import DataFrameConfig, DocumentConfig
|
|
553
|
+
|
|
554
|
+
# Write the spec to a file
|
|
555
|
+
spec_file = tmp_path / 'openapi.json'
|
|
556
|
+
spec_file.write_text(json.dumps(simple_openapi_spec))
|
|
557
|
+
|
|
558
|
+
output_dir = tmp_path / 'client'
|
|
559
|
+
|
|
560
|
+
config = DocumentConfig(
|
|
561
|
+
source=str(spec_file),
|
|
562
|
+
output=str(output_dir),
|
|
563
|
+
dataframe=DataFrameConfig(
|
|
564
|
+
enabled=True,
|
|
565
|
+
pandas=True,
|
|
566
|
+
polars=True,
|
|
567
|
+
),
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
codegen = Codegen(config)
|
|
571
|
+
codegen.generate()
|
|
572
|
+
|
|
573
|
+
# Check that _dataframe.py was generated
|
|
574
|
+
dataframe_file = output_dir / '_dataframe.py'
|
|
575
|
+
assert dataframe_file.exists()
|
|
576
|
+
|
|
577
|
+
# Check _client.py exists (now contains only infrastructure)
|
|
578
|
+
client_file = output_dir / '_client.py'
|
|
579
|
+
assert client_file.exists()
|
|
580
|
+
|
|
581
|
+
# Check endpoints.py for DataFrame methods (new architecture)
|
|
582
|
+
# DataFrame methods are now in the endpoints file with full implementations
|
|
583
|
+
endpoints_file = output_dir / 'endpoints.py'
|
|
584
|
+
assert endpoints_file.exists()
|
|
585
|
+
endpoints_content = endpoints_file.read_text()
|
|
586
|
+
|
|
587
|
+
# Should have DataFrame imports in endpoints file
|
|
588
|
+
assert 'to_pandas' in endpoints_content
|
|
589
|
+
assert 'to_polars' in endpoints_content
|
|
590
|
+
assert 'TYPE_CHECKING' in endpoints_content
|
|
591
|
+
|
|
592
|
+
# Should have _df methods for list-returning endpoint
|
|
593
|
+
assert 'get_users_df' in endpoints_content
|
|
594
|
+
assert 'aget_users_df' in endpoints_content
|
|
595
|
+
|
|
596
|
+
# Should have _pl methods for list-returning endpoint
|
|
597
|
+
assert 'get_users_pl' in endpoints_content
|
|
598
|
+
assert 'aget_users_pl' in endpoints_content
|
|
599
|
+
|
|
600
|
+
# Should NOT have DataFrame methods for non-list endpoint (getUserById)
|
|
601
|
+
# because it returns a single User, not a list
|
|
602
|
+
assert 'get_user_by_id_df' not in endpoints_content
|
|
603
|
+
assert 'get_user_by_id_pl' not in endpoints_content
|
|
604
|
+
|
|
605
|
+
def test_generate_with_dataframe_pandas_only(self, tmp_path, simple_openapi_spec):
|
|
606
|
+
"""Test generating with only pandas enabled."""
|
|
607
|
+
import json
|
|
608
|
+
|
|
609
|
+
from otterapi.codegen.codegen import Codegen
|
|
610
|
+
from otterapi.config import DataFrameConfig, DocumentConfig
|
|
611
|
+
|
|
612
|
+
spec_file = tmp_path / 'openapi.json'
|
|
613
|
+
spec_file.write_text(json.dumps(simple_openapi_spec))
|
|
614
|
+
|
|
615
|
+
output_dir = tmp_path / 'client'
|
|
616
|
+
|
|
617
|
+
config = DocumentConfig(
|
|
618
|
+
source=str(spec_file),
|
|
619
|
+
output=str(output_dir),
|
|
620
|
+
dataframe=DataFrameConfig(
|
|
621
|
+
enabled=True,
|
|
622
|
+
pandas=True,
|
|
623
|
+
polars=False,
|
|
624
|
+
),
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
codegen = Codegen(config)
|
|
628
|
+
codegen.generate()
|
|
629
|
+
|
|
630
|
+
endpoints_file = output_dir / 'endpoints.py'
|
|
631
|
+
endpoints_content = endpoints_file.read_text()
|
|
632
|
+
|
|
633
|
+
# Should have _df methods
|
|
634
|
+
assert 'get_users_df' in endpoints_content
|
|
635
|
+
|
|
636
|
+
# Should NOT have _pl methods
|
|
637
|
+
assert 'get_users_pl' not in endpoints_content
|
|
638
|
+
|
|
639
|
+
def test_generate_with_dataframe_polars_only(self, tmp_path, simple_openapi_spec):
|
|
640
|
+
"""Test generating with only polars enabled."""
|
|
641
|
+
import json
|
|
642
|
+
|
|
643
|
+
from otterapi.codegen.codegen import Codegen
|
|
644
|
+
from otterapi.config import DataFrameConfig, DocumentConfig
|
|
645
|
+
|
|
646
|
+
spec_file = tmp_path / 'openapi.json'
|
|
647
|
+
spec_file.write_text(json.dumps(simple_openapi_spec))
|
|
648
|
+
|
|
649
|
+
output_dir = tmp_path / 'client'
|
|
650
|
+
|
|
651
|
+
config = DocumentConfig(
|
|
652
|
+
source=str(spec_file),
|
|
653
|
+
output=str(output_dir),
|
|
654
|
+
dataframe=DataFrameConfig(
|
|
655
|
+
enabled=True,
|
|
656
|
+
pandas=False,
|
|
657
|
+
polars=True,
|
|
658
|
+
),
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
codegen = Codegen(config)
|
|
662
|
+
codegen.generate()
|
|
663
|
+
|
|
664
|
+
endpoints_file = output_dir / 'endpoints.py'
|
|
665
|
+
endpoints_content = endpoints_file.read_text()
|
|
666
|
+
|
|
667
|
+
# Should NOT have _df methods
|
|
668
|
+
assert 'get_users_df' not in endpoints_content
|
|
669
|
+
|
|
670
|
+
# Should have _pl methods
|
|
671
|
+
assert 'get_users_pl' in endpoints_content
|
|
672
|
+
|
|
673
|
+
def test_generate_with_endpoint_path_config(self, tmp_path, simple_openapi_spec):
|
|
674
|
+
"""Test generating with endpoint-specific path configuration."""
|
|
675
|
+
import json
|
|
676
|
+
|
|
677
|
+
from otterapi.codegen.codegen import Codegen
|
|
678
|
+
from otterapi.config import (
|
|
679
|
+
DataFrameConfig,
|
|
680
|
+
DocumentConfig,
|
|
681
|
+
EndpointDataFrameConfig,
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
spec_file = tmp_path / 'openapi.json'
|
|
685
|
+
spec_file.write_text(json.dumps(simple_openapi_spec))
|
|
686
|
+
|
|
687
|
+
output_dir = tmp_path / 'client'
|
|
688
|
+
|
|
689
|
+
config = DocumentConfig(
|
|
690
|
+
source=str(spec_file),
|
|
691
|
+
output=str(output_dir),
|
|
692
|
+
dataframe=DataFrameConfig(
|
|
693
|
+
enabled=True,
|
|
694
|
+
pandas=True,
|
|
695
|
+
polars=False,
|
|
696
|
+
endpoints={
|
|
697
|
+
'get_users': EndpointDataFrameConfig(
|
|
698
|
+
path='data.users',
|
|
699
|
+
),
|
|
700
|
+
},
|
|
701
|
+
),
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
codegen = Codegen(config)
|
|
705
|
+
codegen.generate()
|
|
706
|
+
|
|
707
|
+
endpoints_file = output_dir / 'endpoints.py'
|
|
708
|
+
endpoints_content = endpoints_file.read_text()
|
|
709
|
+
|
|
710
|
+
# Should have the configured path as default
|
|
711
|
+
# The generated code uses Union[str, None] format
|
|
712
|
+
assert "'data.users'" in endpoints_content
|
|
713
|
+
assert 'get_users_df' in endpoints_content
|
|
714
|
+
|
|
715
|
+
def test_generate_without_dataframe(self, tmp_path, simple_openapi_spec):
|
|
716
|
+
"""Test generating without DataFrame methods (default behavior)."""
|
|
717
|
+
import json
|
|
718
|
+
|
|
719
|
+
from otterapi.codegen.codegen import Codegen
|
|
720
|
+
from otterapi.config import DocumentConfig
|
|
721
|
+
|
|
722
|
+
spec_file = tmp_path / 'openapi.json'
|
|
723
|
+
spec_file.write_text(json.dumps(simple_openapi_spec))
|
|
724
|
+
|
|
725
|
+
output_dir = tmp_path / 'client'
|
|
726
|
+
|
|
727
|
+
config = DocumentConfig(
|
|
728
|
+
source=str(spec_file),
|
|
729
|
+
output=str(output_dir),
|
|
730
|
+
# dataframe not configured, defaults to disabled
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
codegen = Codegen(config)
|
|
734
|
+
codegen.generate()
|
|
735
|
+
|
|
736
|
+
# Should NOT have _dataframe.py
|
|
737
|
+
dataframe_file = output_dir / '_dataframe.py'
|
|
738
|
+
assert not dataframe_file.exists()
|
|
739
|
+
|
|
740
|
+
client_file = output_dir / '_client.py'
|
|
741
|
+
client_content = client_file.read_text()
|
|
742
|
+
|
|
743
|
+
# Should NOT have DataFrame methods
|
|
744
|
+
assert '_df' not in client_content
|
|
745
|
+
assert '_pl' not in client_content
|
|
746
|
+
assert 'to_pandas' not in client_content
|
|
747
|
+
assert 'to_polars' not in client_content
|
|
748
|
+
|
|
749
|
+
def test_generated_code_is_valid_python(self, tmp_path, simple_openapi_spec):
|
|
750
|
+
"""Test that all generated code is valid Python."""
|
|
751
|
+
import json
|
|
752
|
+
|
|
753
|
+
from otterapi.codegen.codegen import Codegen
|
|
754
|
+
from otterapi.config import DataFrameConfig, DocumentConfig
|
|
755
|
+
|
|
756
|
+
spec_file = tmp_path / 'openapi.json'
|
|
757
|
+
spec_file.write_text(json.dumps(simple_openapi_spec))
|
|
758
|
+
|
|
759
|
+
output_dir = tmp_path / 'client'
|
|
760
|
+
|
|
761
|
+
config = DocumentConfig(
|
|
762
|
+
source=str(spec_file),
|
|
763
|
+
output=str(output_dir),
|
|
764
|
+
dataframe=DataFrameConfig(
|
|
765
|
+
enabled=True,
|
|
766
|
+
pandas=True,
|
|
767
|
+
polars=True,
|
|
768
|
+
),
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
codegen = Codegen(config)
|
|
772
|
+
codegen.generate()
|
|
773
|
+
|
|
774
|
+
# Check all generated Python files are valid
|
|
775
|
+
for py_file in output_dir.glob('*.py'):
|
|
776
|
+
content = py_file.read_text()
|
|
777
|
+
try:
|
|
778
|
+
ast.parse(content)
|
|
779
|
+
except SyntaxError as e:
|
|
780
|
+
pytest.fail(f'Invalid Python in {py_file.name}: {e}')
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
class TestDataFrameNormalization:
|
|
784
|
+
"""Tests for DataFrame normalization handling Pydantic models."""
|
|
785
|
+
|
|
786
|
+
def test_normalize_data_with_pydantic_models(self, tmp_path):
|
|
787
|
+
"""Test that _normalize_data correctly converts Pydantic models to dicts."""
|
|
788
|
+
from pydantic import BaseModel
|
|
789
|
+
|
|
790
|
+
from otterapi.codegen.dataframes import generate_dataframe_module
|
|
791
|
+
|
|
792
|
+
# Generate the module
|
|
793
|
+
output_path = generate_dataframe_module(tmp_path)
|
|
794
|
+
|
|
795
|
+
# Import the generated module
|
|
796
|
+
import importlib.util
|
|
797
|
+
|
|
798
|
+
spec = importlib.util.spec_from_file_location('_dataframe', output_path)
|
|
799
|
+
module = importlib.util.module_from_spec(spec)
|
|
800
|
+
spec.loader.exec_module(module)
|
|
801
|
+
|
|
802
|
+
# Define a test Pydantic model
|
|
803
|
+
class TestModel(BaseModel):
|
|
804
|
+
id: int
|
|
805
|
+
name: str
|
|
806
|
+
|
|
807
|
+
# Test with list of Pydantic models
|
|
808
|
+
models = [TestModel(id=1, name='Alice'), TestModel(id=2, name='Bob')]
|
|
809
|
+
normalized = module._normalize_data(models)
|
|
810
|
+
|
|
811
|
+
assert isinstance(normalized, list)
|
|
812
|
+
assert len(normalized) == 2
|
|
813
|
+
assert normalized[0] == {'id': 1, 'name': 'Alice'}
|
|
814
|
+
assert normalized[1] == {'id': 2, 'name': 'Bob'}
|
|
815
|
+
|
|
816
|
+
def test_normalize_data_with_dicts(self, tmp_path):
|
|
817
|
+
"""Test that _normalize_data passes through dicts unchanged."""
|
|
818
|
+
from otterapi.codegen.dataframes import generate_dataframe_module
|
|
819
|
+
|
|
820
|
+
output_path = generate_dataframe_module(tmp_path)
|
|
821
|
+
|
|
822
|
+
import importlib.util
|
|
823
|
+
|
|
824
|
+
spec = importlib.util.spec_from_file_location('_dataframe', output_path)
|
|
825
|
+
module = importlib.util.module_from_spec(spec)
|
|
826
|
+
spec.loader.exec_module(module)
|
|
827
|
+
|
|
828
|
+
# Test with list of dicts
|
|
829
|
+
dicts = [{'id': 1, 'name': 'Alice'}, {'id': 2, 'name': 'Bob'}]
|
|
830
|
+
normalized = module._normalize_data(dicts)
|
|
831
|
+
|
|
832
|
+
assert normalized == dicts
|
|
833
|
+
|
|
834
|
+
def test_normalize_data_with_single_dict(self, tmp_path):
|
|
835
|
+
"""Test that _normalize_data wraps single dict in a list."""
|
|
836
|
+
from otterapi.codegen.dataframes import generate_dataframe_module
|
|
837
|
+
|
|
838
|
+
output_path = generate_dataframe_module(tmp_path)
|
|
839
|
+
|
|
840
|
+
import importlib.util
|
|
841
|
+
|
|
842
|
+
spec = importlib.util.spec_from_file_location('_dataframe', output_path)
|
|
843
|
+
module = importlib.util.module_from_spec(spec)
|
|
844
|
+
spec.loader.exec_module(module)
|
|
845
|
+
|
|
846
|
+
single_dict = {'id': 1, 'name': 'Alice'}
|
|
847
|
+
normalized = module._normalize_data(single_dict)
|
|
848
|
+
|
|
849
|
+
assert normalized == [single_dict]
|
|
850
|
+
|
|
851
|
+
def test_normalize_data_with_empty_list(self, tmp_path):
|
|
852
|
+
"""Test that _normalize_data handles empty list."""
|
|
853
|
+
from otterapi.codegen.dataframes import generate_dataframe_module
|
|
854
|
+
|
|
855
|
+
output_path = generate_dataframe_module(tmp_path)
|
|
856
|
+
|
|
857
|
+
import importlib.util
|
|
858
|
+
|
|
859
|
+
spec = importlib.util.spec_from_file_location('_dataframe', output_path)
|
|
860
|
+
module = importlib.util.module_from_spec(spec)
|
|
861
|
+
spec.loader.exec_module(module)
|
|
862
|
+
|
|
863
|
+
normalized = module._normalize_data([])
|
|
864
|
+
assert normalized == []
|
|
865
|
+
|
|
866
|
+
def test_to_pandas_with_pydantic_models(self, tmp_path):
|
|
867
|
+
"""Test that to_pandas correctly handles Pydantic models."""
|
|
868
|
+
pytest.importorskip('pandas')
|
|
869
|
+
from pydantic import BaseModel
|
|
870
|
+
|
|
871
|
+
from otterapi.codegen.dataframes import generate_dataframe_module
|
|
872
|
+
|
|
873
|
+
output_path = generate_dataframe_module(tmp_path)
|
|
874
|
+
|
|
875
|
+
import importlib.util
|
|
876
|
+
|
|
877
|
+
spec = importlib.util.spec_from_file_location('_dataframe', output_path)
|
|
878
|
+
module = importlib.util.module_from_spec(spec)
|
|
879
|
+
spec.loader.exec_module(module)
|
|
880
|
+
|
|
881
|
+
class TestModel(BaseModel):
|
|
882
|
+
id: int
|
|
883
|
+
name: str
|
|
884
|
+
|
|
885
|
+
models = [TestModel(id=1, name='Alice'), TestModel(id=2, name='Bob')]
|
|
886
|
+
df = module.to_pandas(models)
|
|
887
|
+
|
|
888
|
+
assert len(df) == 2
|
|
889
|
+
assert list(df.columns) == ['id', 'name']
|
|
890
|
+
assert df['id'].tolist() == [1, 2]
|
|
891
|
+
assert df['name'].tolist() == ['Alice', 'Bob']
|
|
892
|
+
|
|
893
|
+
def test_to_polars_with_pydantic_models(self, tmp_path):
|
|
894
|
+
"""Test that to_polars correctly handles Pydantic models."""
|
|
895
|
+
pytest.importorskip('polars')
|
|
896
|
+
from pydantic import BaseModel
|
|
897
|
+
|
|
898
|
+
from otterapi.codegen.dataframes import generate_dataframe_module
|
|
899
|
+
|
|
900
|
+
output_path = generate_dataframe_module(tmp_path)
|
|
901
|
+
|
|
902
|
+
import importlib.util
|
|
903
|
+
|
|
904
|
+
spec = importlib.util.spec_from_file_location('_dataframe', output_path)
|
|
905
|
+
module = importlib.util.module_from_spec(spec)
|
|
906
|
+
spec.loader.exec_module(module)
|
|
907
|
+
|
|
908
|
+
class TestModel(BaseModel):
|
|
909
|
+
id: int
|
|
910
|
+
name: str
|
|
911
|
+
|
|
912
|
+
models = [TestModel(id=1, name='Alice'), TestModel(id=2, name='Bob')]
|
|
913
|
+
df = module.to_polars(models)
|
|
914
|
+
|
|
915
|
+
assert len(df) == 2
|
|
916
|
+
assert df.columns == ['id', 'name']
|
|
917
|
+
assert df['id'].to_list() == [1, 2]
|
|
918
|
+
assert df['name'].to_list() == ['Alice', 'Bob']
|
|
919
|
+
|
|
920
|
+
def test_paginated_endpoint_generates_dataframe_module(self, tmp_path):
|
|
921
|
+
"""Test that _dataframe.py is generated when pagination + dataframe is enabled.
|
|
922
|
+
|
|
923
|
+
This tests the case where an endpoint doesn't return a list by itself,
|
|
924
|
+
but pagination is configured which makes it return lists, and thus
|
|
925
|
+
dataframe methods should be generated.
|
|
926
|
+
"""
|
|
927
|
+
import json
|
|
928
|
+
|
|
929
|
+
from otterapi.codegen.codegen import Codegen
|
|
930
|
+
from otterapi.config import (
|
|
931
|
+
DataFrameConfig,
|
|
932
|
+
DocumentConfig,
|
|
933
|
+
EndpointPaginationConfig,
|
|
934
|
+
PaginationConfig,
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
# OpenAPI spec with an endpoint that returns an object (not a list)
|
|
938
|
+
# but will be paginated
|
|
939
|
+
spec = {
|
|
940
|
+
'openapi': '3.0.0',
|
|
941
|
+
'info': {'title': 'Test API', 'version': '1.0.0'},
|
|
942
|
+
'servers': [{'url': 'https://api.example.com'}],
|
|
943
|
+
'paths': {
|
|
944
|
+
'/items': {
|
|
945
|
+
'get': {
|
|
946
|
+
'operationId': 'getItems',
|
|
947
|
+
'summary': 'Get paginated items',
|
|
948
|
+
'parameters': [
|
|
949
|
+
{
|
|
950
|
+
'name': 'offset',
|
|
951
|
+
'in': 'query',
|
|
952
|
+
'schema': {'type': 'integer'},
|
|
953
|
+
},
|
|
954
|
+
{
|
|
955
|
+
'name': 'limit',
|
|
956
|
+
'in': 'query',
|
|
957
|
+
'schema': {'type': 'integer'},
|
|
958
|
+
},
|
|
959
|
+
],
|
|
960
|
+
'responses': {
|
|
961
|
+
'200': {
|
|
962
|
+
'description': 'Success',
|
|
963
|
+
'content': {
|
|
964
|
+
'application/json': {
|
|
965
|
+
'schema': {
|
|
966
|
+
# Returns an object, not a list
|
|
967
|
+
'$ref': '#/components/schemas/PaginatedResponse'
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
},
|
|
971
|
+
}
|
|
972
|
+
},
|
|
973
|
+
}
|
|
974
|
+
},
|
|
975
|
+
},
|
|
976
|
+
'components': {
|
|
977
|
+
'schemas': {
|
|
978
|
+
'PaginatedResponse': {
|
|
979
|
+
'type': 'object',
|
|
980
|
+
'properties': {
|
|
981
|
+
'data': {
|
|
982
|
+
'type': 'array',
|
|
983
|
+
'items': {'$ref': '#/components/schemas/Item'},
|
|
984
|
+
},
|
|
985
|
+
'total': {'type': 'integer'},
|
|
986
|
+
},
|
|
987
|
+
},
|
|
988
|
+
'Item': {
|
|
989
|
+
'type': 'object',
|
|
990
|
+
'properties': {
|
|
991
|
+
'id': {'type': 'integer'},
|
|
992
|
+
'name': {'type': 'string'},
|
|
993
|
+
},
|
|
994
|
+
},
|
|
995
|
+
}
|
|
996
|
+
},
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
spec_file = tmp_path / 'openapi.json'
|
|
1000
|
+
spec_file.write_text(json.dumps(spec))
|
|
1001
|
+
|
|
1002
|
+
output_dir = tmp_path / 'client'
|
|
1003
|
+
|
|
1004
|
+
config = DocumentConfig(
|
|
1005
|
+
source=str(spec_file),
|
|
1006
|
+
output=str(output_dir),
|
|
1007
|
+
dataframe=DataFrameConfig(
|
|
1008
|
+
enabled=True,
|
|
1009
|
+
pandas=True,
|
|
1010
|
+
),
|
|
1011
|
+
pagination=PaginationConfig(
|
|
1012
|
+
enabled=True,
|
|
1013
|
+
endpoints={
|
|
1014
|
+
'get_items': EndpointPaginationConfig(
|
|
1015
|
+
style='offset',
|
|
1016
|
+
offset_param='offset',
|
|
1017
|
+
limit_param='limit',
|
|
1018
|
+
data_path='data',
|
|
1019
|
+
total_path='total',
|
|
1020
|
+
),
|
|
1021
|
+
},
|
|
1022
|
+
),
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
codegen = Codegen(config)
|
|
1026
|
+
codegen.generate()
|
|
1027
|
+
|
|
1028
|
+
# The key assertion: _dataframe.py should be generated
|
|
1029
|
+
# even though the endpoint doesn't return a list by itself
|
|
1030
|
+
dataframe_file = output_dir / '_dataframe.py'
|
|
1031
|
+
assert dataframe_file.exists(), (
|
|
1032
|
+
'_dataframe.py should be generated when pagination + dataframe is enabled'
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
# Verify the content is correct
|
|
1036
|
+
content = dataframe_file.read_text()
|
|
1037
|
+
assert 'def to_pandas' in content
|
|
1038
|
+
assert 'def to_polars' in content
|