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