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,1101 @@
1
+ """Tests for pagination configuration and code generation."""
2
+
3
+ import ast
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from otterapi.codegen.endpoints import (
10
+ build_standalone_paginated_fn,
11
+ build_standalone_paginated_iter_fn,
12
+ )
13
+ from otterapi.codegen.pagination import (
14
+ PAGINATION_MODULE_CONTENT,
15
+ PaginationMethodConfig,
16
+ endpoint_is_paginated,
17
+ generate_pagination_module,
18
+ get_pagination_config_for_endpoint,
19
+ )
20
+ from otterapi.config import (
21
+ EndpointPaginationConfig,
22
+ PaginationConfig,
23
+ PaginationStyle,
24
+ ResolvedPaginationConfig,
25
+ )
26
+
27
+
28
+ class TestEndpointPaginationConfig:
29
+ """Tests for EndpointPaginationConfig model."""
30
+
31
+ def test_default_values(self):
32
+ """Test that default values are None (inherit from parent)."""
33
+ config = EndpointPaginationConfig()
34
+ assert config.enabled is None
35
+ assert config.style is None
36
+ assert config.offset_param is None
37
+ assert config.limit_param is None
38
+ assert config.cursor_param is None
39
+ assert config.page_param is None
40
+ assert config.per_page_param is None
41
+ assert config.data_path is None
42
+ assert config.total_path is None
43
+ assert config.next_cursor_path is None
44
+ assert config.total_pages_path is None
45
+ assert config.default_page_size is None
46
+ assert config.max_page_size is None
47
+
48
+ def test_explicit_values(self):
49
+ """Test setting explicit values."""
50
+ config = EndpointPaginationConfig(
51
+ enabled=True,
52
+ style='offset',
53
+ offset_param='skip',
54
+ limit_param='take',
55
+ data_path='data.items',
56
+ total_path='meta.total',
57
+ default_page_size=50,
58
+ )
59
+ assert config.enabled is True
60
+ assert config.style == PaginationStyle.OFFSET
61
+ assert config.offset_param == 'skip'
62
+ assert config.limit_param == 'take'
63
+ assert config.data_path == 'data.items'
64
+ assert config.total_path == 'meta.total'
65
+ assert config.default_page_size == 50
66
+
67
+ def test_style_normalization(self):
68
+ """Test that style strings are normalized to enum."""
69
+ config = EndpointPaginationConfig(style='CURSOR')
70
+ assert config.style == PaginationStyle.CURSOR
71
+
72
+ config = EndpointPaginationConfig(style='page')
73
+ assert config.style == PaginationStyle.PAGE
74
+
75
+ def test_rejects_extra_fields(self):
76
+ """Test that extra fields are rejected."""
77
+ with pytest.raises(Exception):
78
+ EndpointPaginationConfig(unknown_field='value')
79
+
80
+
81
+ class TestPaginationConfig:
82
+ """Tests for PaginationConfig model."""
83
+
84
+ def test_default_values(self):
85
+ """Test default configuration values."""
86
+ config = PaginationConfig()
87
+ assert config.enabled is False
88
+ assert config.default_style == PaginationStyle.OFFSET
89
+ assert config.default_page_size == 100
90
+ assert config.default_data_path is None
91
+ assert config.default_offset_param == 'offset'
92
+ assert config.default_limit_param == 'limit'
93
+ assert config.default_cursor_param == 'cursor'
94
+ assert config.default_page_param == 'page'
95
+ assert config.default_per_page_param == 'per_page'
96
+ assert config.endpoints == {}
97
+
98
+ def test_enabled_with_defaults(self):
99
+ """Test enabled config uses correct defaults."""
100
+ config = PaginationConfig(enabled=True)
101
+ assert config.enabled is True
102
+ assert config.default_style == PaginationStyle.OFFSET
103
+ assert config.default_page_size == 100
104
+
105
+ def test_with_endpoints(self):
106
+ """Test configuration with endpoint overrides."""
107
+ config = PaginationConfig(
108
+ enabled=True,
109
+ endpoints={
110
+ 'list_users': EndpointPaginationConfig(
111
+ style='offset',
112
+ data_path='users',
113
+ ),
114
+ 'list_items': EndpointPaginationConfig(
115
+ style='cursor',
116
+ cursor_param='after',
117
+ next_cursor_path='meta.next',
118
+ ),
119
+ },
120
+ )
121
+ assert 'list_users' in config.endpoints
122
+ assert config.endpoints['list_users'].data_path == 'users'
123
+ assert config.endpoints['list_items'].style == PaginationStyle.CURSOR
124
+
125
+ def test_style_normalization(self):
126
+ """Test that default_style strings are normalized."""
127
+ config = PaginationConfig(default_style='cursor')
128
+ assert config.default_style == PaginationStyle.CURSOR
129
+
130
+
131
+ class TestShouldGenerateForEndpoint:
132
+ """Tests for PaginationConfig.should_generate_for_endpoint method."""
133
+
134
+ def test_disabled_returns_false(self):
135
+ """Test that disabled config returns False."""
136
+ config = PaginationConfig(enabled=False)
137
+ should_generate, resolved = config.should_generate_for_endpoint('list_users')
138
+ assert should_generate is False
139
+ assert resolved is None
140
+
141
+ def test_no_endpoint_config_returns_false(self):
142
+ """Test that endpoints not configured return False."""
143
+ config = PaginationConfig(enabled=True)
144
+ should_generate, resolved = config.should_generate_for_endpoint('list_users')
145
+ assert should_generate is False
146
+ assert resolved is None
147
+
148
+ def test_endpoint_explicitly_disabled(self):
149
+ """Test that explicitly disabled endpoints return False."""
150
+ config = PaginationConfig(
151
+ enabled=True,
152
+ endpoints={
153
+ 'list_users': EndpointPaginationConfig(enabled=False),
154
+ },
155
+ )
156
+ should_generate, resolved = config.should_generate_for_endpoint('list_users')
157
+ assert should_generate is False
158
+ assert resolved is None
159
+
160
+ def test_endpoint_configured_offset(self):
161
+ """Test configured endpoint with offset pagination."""
162
+ config = PaginationConfig(
163
+ enabled=True,
164
+ endpoints={
165
+ 'list_users': EndpointPaginationConfig(
166
+ style='offset',
167
+ data_path='users',
168
+ total_path='total',
169
+ ),
170
+ },
171
+ )
172
+ should_generate, resolved = config.should_generate_for_endpoint('list_users')
173
+ assert should_generate is True
174
+ assert resolved is not None
175
+ assert resolved.style == PaginationStyle.OFFSET
176
+ assert resolved.data_path == 'users'
177
+ assert resolved.total_path == 'total'
178
+ assert resolved.offset_param == 'offset' # default
179
+ assert resolved.limit_param == 'limit' # default
180
+
181
+ def test_endpoint_configured_cursor(self):
182
+ """Test configured endpoint with cursor pagination."""
183
+ config = PaginationConfig(
184
+ enabled=True,
185
+ endpoints={
186
+ 'list_items': EndpointPaginationConfig(
187
+ style='cursor',
188
+ cursor_param='after',
189
+ next_cursor_path='meta.next_cursor',
190
+ data_path='items',
191
+ ),
192
+ },
193
+ )
194
+ should_generate, resolved = config.should_generate_for_endpoint('list_items')
195
+ assert should_generate is True
196
+ assert resolved is not None
197
+ assert resolved.style == PaginationStyle.CURSOR
198
+ assert resolved.cursor_param == 'after'
199
+ assert resolved.next_cursor_path == 'meta.next_cursor'
200
+ assert resolved.data_path == 'items'
201
+
202
+ def test_endpoint_configured_page(self):
203
+ """Test configured endpoint with page pagination."""
204
+ config = PaginationConfig(
205
+ enabled=True,
206
+ endpoints={
207
+ 'list_orders': EndpointPaginationConfig(
208
+ style='page',
209
+ page_param='p',
210
+ per_page_param='size',
211
+ total_pages_path='pagination.total_pages',
212
+ ),
213
+ },
214
+ )
215
+ should_generate, resolved = config.should_generate_for_endpoint('list_orders')
216
+ assert should_generate is True
217
+ assert resolved is not None
218
+ assert resolved.style == PaginationStyle.PAGE
219
+ assert resolved.page_param == 'p'
220
+ assert resolved.per_page_param == 'size'
221
+ assert resolved.total_pages_path == 'pagination.total_pages'
222
+
223
+ def test_inherits_defaults(self):
224
+ """Test that endpoint config inherits defaults."""
225
+ config = PaginationConfig(
226
+ enabled=True,
227
+ default_style=PaginationStyle.OFFSET,
228
+ default_page_size=50,
229
+ default_data_path='data',
230
+ default_offset_param='skip',
231
+ default_limit_param='take',
232
+ endpoints={
233
+ 'list_users': EndpointPaginationConfig(), # minimal config
234
+ },
235
+ )
236
+ should_generate, resolved = config.should_generate_for_endpoint('list_users')
237
+ assert should_generate is True
238
+ assert resolved is not None
239
+ assert resolved.style == PaginationStyle.OFFSET
240
+ assert resolved.default_page_size == 50
241
+ assert resolved.data_path == 'data'
242
+ assert resolved.offset_param == 'skip'
243
+ assert resolved.limit_param == 'take'
244
+
245
+ def test_endpoint_overrides_defaults(self):
246
+ """Test that endpoint config overrides defaults."""
247
+ config = PaginationConfig(
248
+ enabled=True,
249
+ default_page_size=100,
250
+ default_data_path='data',
251
+ endpoints={
252
+ 'list_users': EndpointPaginationConfig(
253
+ default_page_size=25,
254
+ data_path='users',
255
+ ),
256
+ },
257
+ )
258
+ should_generate, resolved = config.should_generate_for_endpoint('list_users')
259
+ assert should_generate is True
260
+ assert resolved is not None
261
+ assert resolved.default_page_size == 25
262
+ assert resolved.data_path == 'users'
263
+
264
+
265
+ class TestPaginationMethodConfig:
266
+ """Tests for PaginationMethodConfig dataclass."""
267
+
268
+ def test_default_values(self):
269
+ """Test default values."""
270
+ config = PaginationMethodConfig()
271
+ assert config.style == 'offset'
272
+ assert config.offset_param == 'offset'
273
+ assert config.limit_param == 'limit'
274
+ assert config.cursor_param == 'cursor'
275
+ assert config.page_param == 'page'
276
+ assert config.per_page_param == 'per_page'
277
+ assert config.data_path is None
278
+ assert config.total_path is None
279
+ assert config.next_cursor_path is None
280
+ assert config.total_pages_path is None
281
+ assert config.default_page_size == 100
282
+ assert config.max_page_size is None
283
+
284
+ def test_custom_values(self):
285
+ """Test setting custom values."""
286
+ config = PaginationMethodConfig(
287
+ style='cursor',
288
+ cursor_param='after',
289
+ data_path='items',
290
+ next_cursor_path='meta.next',
291
+ default_page_size=50,
292
+ )
293
+ assert config.style == 'cursor'
294
+ assert config.cursor_param == 'after'
295
+ assert config.data_path == 'items'
296
+ assert config.next_cursor_path == 'meta.next'
297
+ assert config.default_page_size == 50
298
+
299
+
300
+ class TestEndpointIsPaginated:
301
+ """Tests for endpoint_is_paginated function."""
302
+
303
+ def test_disabled_config(self):
304
+ """Test with disabled pagination config."""
305
+ config = PaginationConfig(enabled=False)
306
+ assert endpoint_is_paginated('list_users', config) is False
307
+
308
+ def test_unconfigured_endpoint(self):
309
+ """Test with endpoint not in config."""
310
+ config = PaginationConfig(enabled=True)
311
+ assert endpoint_is_paginated('list_users', config) is False
312
+
313
+ def test_configured_endpoint(self):
314
+ """Test with configured endpoint."""
315
+ config = PaginationConfig(
316
+ enabled=True,
317
+ endpoints={
318
+ 'list_users': EndpointPaginationConfig(style='offset'),
319
+ },
320
+ )
321
+ assert endpoint_is_paginated('list_users', config) is True
322
+ assert endpoint_is_paginated('get_user', config) is False
323
+
324
+
325
+ class TestGetPaginationConfigForEndpoint:
326
+ """Tests for get_pagination_config_for_endpoint function."""
327
+
328
+ def test_disabled_config(self):
329
+ """Test with disabled pagination config."""
330
+ config = PaginationConfig(enabled=False)
331
+ result = get_pagination_config_for_endpoint('list_users', config)
332
+ assert result is None
333
+
334
+ def test_unconfigured_endpoint(self):
335
+ """Test with endpoint not in config."""
336
+ config = PaginationConfig(enabled=True)
337
+ result = get_pagination_config_for_endpoint('list_users', config)
338
+ assert result is None
339
+
340
+ def test_configured_endpoint(self):
341
+ """Test with configured endpoint."""
342
+ config = PaginationConfig(
343
+ enabled=True,
344
+ endpoints={
345
+ 'list_users': EndpointPaginationConfig(
346
+ style='offset',
347
+ data_path='users',
348
+ total_path='total',
349
+ default_page_size=50,
350
+ ),
351
+ },
352
+ )
353
+ result = get_pagination_config_for_endpoint('list_users', config)
354
+ assert result is not None
355
+ assert isinstance(result, PaginationMethodConfig)
356
+ assert result.style == 'offset'
357
+ assert result.data_path == 'users'
358
+ assert result.total_path == 'total'
359
+ assert result.default_page_size == 50
360
+
361
+
362
+ class TestResolvedPaginationConfig:
363
+ """Tests for ResolvedPaginationConfig model."""
364
+
365
+ def test_all_fields(self):
366
+ """Test creating a resolved config with all fields."""
367
+ config = ResolvedPaginationConfig(
368
+ style=PaginationStyle.OFFSET,
369
+ offset_param='offset',
370
+ limit_param='limit',
371
+ cursor_param='cursor',
372
+ page_param='page',
373
+ per_page_param='per_page',
374
+ data_path='data.items',
375
+ total_path='meta.total',
376
+ next_cursor_path=None,
377
+ total_pages_path=None,
378
+ default_page_size=100,
379
+ max_page_size=1000,
380
+ )
381
+ assert config.style == PaginationStyle.OFFSET
382
+ assert config.offset_param == 'offset'
383
+ assert config.data_path == 'data.items'
384
+ assert config.default_page_size == 100
385
+ assert config.max_page_size == 1000
386
+
387
+ def test_rejects_extra_fields(self):
388
+ """Test that extra fields are rejected."""
389
+ with pytest.raises(Exception):
390
+ ResolvedPaginationConfig(
391
+ style=PaginationStyle.OFFSET,
392
+ offset_param='offset',
393
+ limit_param='limit',
394
+ cursor_param='cursor',
395
+ page_param='page',
396
+ per_page_param='per_page',
397
+ data_path=None,
398
+ total_path=None,
399
+ next_cursor_path=None,
400
+ total_pages_path=None,
401
+ default_page_size=100,
402
+ max_page_size=None,
403
+ unknown_field='value',
404
+ )
405
+
406
+
407
+ class TestPaginationStyleEnum:
408
+ """Tests for PaginationStyle enum."""
409
+
410
+ def test_values(self):
411
+ """Test enum values."""
412
+ assert PaginationStyle.OFFSET.value == 'offset'
413
+ assert PaginationStyle.CURSOR.value == 'cursor'
414
+ assert PaginationStyle.PAGE.value == 'page'
415
+ assert PaginationStyle.LINK.value == 'link'
416
+
417
+ def test_from_string(self):
418
+ """Test creating from string."""
419
+ assert PaginationStyle('offset') == PaginationStyle.OFFSET
420
+ assert PaginationStyle('cursor') == PaginationStyle.CURSOR
421
+ assert PaginationStyle('page') == PaginationStyle.PAGE
422
+
423
+
424
+ class TestPaginationModuleGeneration:
425
+ """Tests for pagination module generation."""
426
+
427
+ def test_generate_pagination_module(self):
428
+ """Test that the pagination module is generated correctly."""
429
+ with tempfile.TemporaryDirectory() as tmpdir:
430
+ output_path = generate_pagination_module(Path(tmpdir))
431
+ assert output_path.exists()
432
+ content = output_path.read_text()
433
+ assert 'paginate_offset' in content
434
+ assert 'paginate_cursor' in content
435
+ assert 'paginate_page' in content
436
+ assert 'iterate_offset' in content
437
+ assert 'iterate_cursor' in content
438
+ assert 'extract_path' in content
439
+
440
+ def test_pagination_module_content_is_valid_python(self):
441
+ """Test that the pagination module content is valid Python."""
442
+ # Should not raise SyntaxError
443
+ ast.parse(PAGINATION_MODULE_CONTENT)
444
+
445
+
446
+ class TestPaginatedFunctionGeneration:
447
+ """Tests for paginated function code generation."""
448
+
449
+ def test_build_offset_paginated_function(self):
450
+ """Test generating an offset-paginated function."""
451
+ fn_ast, imports = build_standalone_paginated_fn(
452
+ fn_name='list_users',
453
+ method='get',
454
+ path='/users',
455
+ parameters=None,
456
+ request_body_info=None,
457
+ response_type=None,
458
+ pagination_style='offset',
459
+ pagination_config={
460
+ 'offset_param': 'offset',
461
+ 'limit_param': 'limit',
462
+ 'data_path': 'users',
463
+ 'total_path': 'total',
464
+ 'default_page_size': 100,
465
+ },
466
+ item_type_ast=ast.Name(id='User', ctx=ast.Load()),
467
+ docs='List all users.',
468
+ is_async=False,
469
+ )
470
+ assert fn_ast.name == 'list_users'
471
+ assert isinstance(fn_ast, ast.FunctionDef)
472
+
473
+ # Check that the function has the expected parameters
474
+ arg_names = [arg.arg for arg in fn_ast.args.kwonlyargs]
475
+ assert 'offset' in arg_names
476
+ assert 'page_size' in arg_names
477
+ assert 'max_items' in arg_names
478
+
479
+ def test_build_cursor_paginated_function(self):
480
+ """Test generating a cursor-paginated function."""
481
+ fn_ast, imports = build_standalone_paginated_fn(
482
+ fn_name='list_items',
483
+ method='get',
484
+ path='/items',
485
+ parameters=None,
486
+ request_body_info=None,
487
+ response_type=None,
488
+ pagination_style='cursor',
489
+ pagination_config={
490
+ 'cursor_param': 'after',
491
+ 'limit_param': 'limit',
492
+ 'data_path': 'items',
493
+ 'next_cursor_path': 'meta.next_cursor',
494
+ 'default_page_size': 50,
495
+ },
496
+ item_type_ast=ast.Name(id='Item', ctx=ast.Load()),
497
+ docs='List all items.',
498
+ is_async=False,
499
+ )
500
+ assert fn_ast.name == 'list_items'
501
+ assert isinstance(fn_ast, ast.FunctionDef)
502
+
503
+ arg_names = [arg.arg for arg in fn_ast.args.kwonlyargs]
504
+ assert 'cursor' in arg_names
505
+ assert 'page_size' in arg_names
506
+ assert 'max_items' in arg_names
507
+
508
+ def test_build_async_paginated_function(self):
509
+ """Test generating an async paginated function."""
510
+ fn_ast, imports = build_standalone_paginated_fn(
511
+ fn_name='alist_users',
512
+ method='get',
513
+ path='/users',
514
+ parameters=None,
515
+ request_body_info=None,
516
+ response_type=None,
517
+ pagination_style='offset',
518
+ pagination_config={
519
+ 'offset_param': 'offset',
520
+ 'limit_param': 'limit',
521
+ 'data_path': 'users',
522
+ 'default_page_size': 100,
523
+ },
524
+ item_type_ast=ast.Name(id='User', ctx=ast.Load()),
525
+ docs='List all users async.',
526
+ is_async=True,
527
+ )
528
+ assert fn_ast.name == 'alist_users'
529
+ assert isinstance(fn_ast, ast.AsyncFunctionDef)
530
+
531
+ def test_build_paginated_iter_function(self):
532
+ """Test generating a paginated iterator function."""
533
+ fn_ast, imports = build_standalone_paginated_iter_fn(
534
+ fn_name='list_users_iter',
535
+ method='get',
536
+ path='/users',
537
+ parameters=None,
538
+ request_body_info=None,
539
+ response_type=None,
540
+ pagination_style='offset',
541
+ pagination_config={
542
+ 'offset_param': 'offset',
543
+ 'limit_param': 'limit',
544
+ 'data_path': 'users',
545
+ 'default_page_size': 100,
546
+ },
547
+ item_type_ast=ast.Name(id='User', ctx=ast.Load()),
548
+ docs='Iterate over users.',
549
+ is_async=False,
550
+ )
551
+ assert fn_ast.name == 'list_users_iter'
552
+ assert isinstance(fn_ast, ast.FunctionDef)
553
+
554
+ # Check that Iterator is in imports
555
+ assert 'collections.abc' in imports
556
+ assert 'Iterator' in imports['collections.abc']
557
+
558
+ def test_build_async_paginated_iter_function(self):
559
+ """Test generating an async paginated iterator function."""
560
+ fn_ast, imports = build_standalone_paginated_iter_fn(
561
+ fn_name='alist_users_iter',
562
+ method='get',
563
+ path='/users',
564
+ parameters=None,
565
+ request_body_info=None,
566
+ response_type=None,
567
+ pagination_style='cursor',
568
+ pagination_config={
569
+ 'cursor_param': 'after',
570
+ 'limit_param': 'limit',
571
+ 'data_path': 'users',
572
+ 'next_cursor_path': 'next',
573
+ 'default_page_size': 100,
574
+ },
575
+ item_type_ast=ast.Name(id='User', ctx=ast.Load()),
576
+ docs='Iterate over users async.',
577
+ is_async=True,
578
+ )
579
+ assert fn_ast.name == 'alist_users_iter'
580
+ assert isinstance(fn_ast, ast.AsyncFunctionDef)
581
+
582
+ # Check that AsyncIterator is in imports
583
+ assert 'collections.abc' in imports
584
+ assert 'AsyncIterator' in imports['collections.abc']
585
+
586
+ def test_generated_code_is_valid_python(self):
587
+ """Test that generated paginated function code is valid Python."""
588
+ fn_ast, _ = build_standalone_paginated_fn(
589
+ fn_name='list_users',
590
+ method='get',
591
+ path='/users',
592
+ parameters=None,
593
+ request_body_info=None,
594
+ response_type=None,
595
+ pagination_style='offset',
596
+ pagination_config={
597
+ 'offset_param': 'offset',
598
+ 'limit_param': 'limit',
599
+ 'data_path': 'users',
600
+ 'default_page_size': 100,
601
+ },
602
+ item_type_ast=None,
603
+ docs='List users.',
604
+ is_async=False,
605
+ )
606
+
607
+ # Wrap in a module and compile to verify it's valid Python
608
+ module = ast.Module(body=[fn_ast], type_ignores=[])
609
+ ast.fix_missing_locations(module)
610
+ # Should not raise SyntaxError
611
+ compile(module, '<test>', 'exec')
612
+
613
+
614
+ class TestPaginationCodegenIntegration:
615
+ """Integration tests for pagination code generation with Codegen."""
616
+
617
+ def test_codegen_with_offset_pagination(self):
618
+ """Test code generation with offset-based pagination."""
619
+ import json
620
+
621
+ from otterapi.codegen.codegen import Codegen
622
+ from otterapi.config import DocumentConfig
623
+
624
+ openapi_spec = {
625
+ 'openapi': '3.0.0',
626
+ 'info': {'title': 'Test API', 'version': '1.0.0'},
627
+ 'servers': [{'url': 'https://api.example.com'}],
628
+ 'paths': {
629
+ '/users': {
630
+ 'get': {
631
+ 'operationId': 'listUsers',
632
+ 'parameters': [
633
+ {
634
+ 'name': 'offset',
635
+ 'in': 'query',
636
+ 'schema': {'type': 'integer'},
637
+ },
638
+ {
639
+ 'name': 'limit',
640
+ 'in': 'query',
641
+ 'schema': {'type': 'integer'},
642
+ },
643
+ ],
644
+ 'responses': {
645
+ '200': {
646
+ 'description': 'List of users',
647
+ 'content': {
648
+ 'application/json': {
649
+ 'schema': {
650
+ 'type': 'object',
651
+ 'properties': {
652
+ 'users': {
653
+ 'type': 'array',
654
+ 'items': {
655
+ '$ref': '#/components/schemas/User'
656
+ },
657
+ },
658
+ 'total': {'type': 'integer'},
659
+ },
660
+ }
661
+ }
662
+ },
663
+ }
664
+ },
665
+ }
666
+ }
667
+ },
668
+ 'components': {
669
+ 'schemas': {
670
+ 'User': {
671
+ 'type': 'object',
672
+ 'properties': {
673
+ 'id': {'type': 'integer'},
674
+ 'name': {'type': 'string'},
675
+ },
676
+ }
677
+ }
678
+ },
679
+ }
680
+
681
+ with tempfile.TemporaryDirectory() as tmpdir:
682
+ spec_path = Path(tmpdir) / 'openapi.json'
683
+ spec_path.write_text(json.dumps(openapi_spec))
684
+ output_path = Path(tmpdir) / 'client'
685
+
686
+ config = DocumentConfig(
687
+ source=str(spec_path),
688
+ output=str(output_path),
689
+ pagination=PaginationConfig(
690
+ enabled=True,
691
+ endpoints={
692
+ 'list_users': EndpointPaginationConfig(
693
+ style='offset',
694
+ data_path='users',
695
+ total_path='total',
696
+ )
697
+ },
698
+ ),
699
+ )
700
+
701
+ codegen = Codegen(config)
702
+ codegen.generate()
703
+
704
+ # Verify pagination module was generated
705
+ assert (output_path / '_pagination.py').exists()
706
+
707
+ # Verify endpoints.py was generated and is valid Python
708
+ endpoints_file = output_path / 'endpoints.py'
709
+ assert endpoints_file.exists()
710
+ content = endpoints_file.read_text()
711
+ ast.parse(content) # Should not raise
712
+
713
+ # Verify pagination functions are present
714
+ assert 'list_users_iter' in content
715
+ assert 'alist_users_iter' in content
716
+ assert 'paginate_offset' in content
717
+
718
+ # Verify no duplicate function definitions
719
+ assert content.count('def list_users(') == 1
720
+ assert content.count('async def alist_users(') == 1
721
+
722
+ def test_codegen_with_cursor_pagination(self):
723
+ """Test code generation with cursor-based pagination."""
724
+ import json
725
+
726
+ from otterapi.codegen.codegen import Codegen
727
+ from otterapi.config import DocumentConfig
728
+
729
+ openapi_spec = {
730
+ 'openapi': '3.0.0',
731
+ 'info': {'title': 'Test API', 'version': '1.0.0'},
732
+ 'servers': [{'url': 'https://api.example.com'}],
733
+ 'paths': {
734
+ '/items': {
735
+ 'get': {
736
+ 'operationId': 'listItems',
737
+ 'parameters': [
738
+ {
739
+ 'name': 'cursor',
740
+ 'in': 'query',
741
+ 'schema': {'type': 'string'},
742
+ },
743
+ {
744
+ 'name': 'limit',
745
+ 'in': 'query',
746
+ 'schema': {'type': 'integer'},
747
+ },
748
+ ],
749
+ 'responses': {
750
+ '200': {
751
+ 'description': 'List of items',
752
+ 'content': {
753
+ 'application/json': {
754
+ 'schema': {
755
+ 'type': 'object',
756
+ 'properties': {
757
+ 'items': {
758
+ 'type': 'array',
759
+ 'items': {
760
+ '$ref': '#/components/schemas/Item'
761
+ },
762
+ },
763
+ 'next_cursor': {'type': 'string'},
764
+ },
765
+ }
766
+ }
767
+ },
768
+ }
769
+ },
770
+ }
771
+ }
772
+ },
773
+ 'components': {
774
+ 'schemas': {
775
+ 'Item': {
776
+ 'type': 'object',
777
+ 'properties': {
778
+ 'id': {'type': 'integer'},
779
+ 'name': {'type': 'string'},
780
+ },
781
+ }
782
+ }
783
+ },
784
+ }
785
+
786
+ with tempfile.TemporaryDirectory() as tmpdir:
787
+ spec_path = Path(tmpdir) / 'openapi.json'
788
+ spec_path.write_text(json.dumps(openapi_spec))
789
+ output_path = Path(tmpdir) / 'client'
790
+
791
+ config = DocumentConfig(
792
+ source=str(spec_path),
793
+ output=str(output_path),
794
+ pagination=PaginationConfig(
795
+ enabled=True,
796
+ endpoints={
797
+ 'list_items': EndpointPaginationConfig(
798
+ style='cursor',
799
+ cursor_param='cursor',
800
+ data_path='items',
801
+ next_cursor_path='next_cursor',
802
+ )
803
+ },
804
+ ),
805
+ )
806
+
807
+ codegen = Codegen(config)
808
+ codegen.generate()
809
+
810
+ # Verify endpoints.py was generated and is valid Python
811
+ endpoints_file = output_path / 'endpoints.py'
812
+ assert endpoints_file.exists()
813
+ content = endpoints_file.read_text()
814
+ ast.parse(content) # Should not raise
815
+
816
+ # Verify cursor-based pagination is used
817
+ assert 'cursor: str | None' in content
818
+ assert 'paginate_cursor' in content
819
+
820
+ def test_codegen_without_pagination(self):
821
+ """Test that code generation works normally without pagination config."""
822
+ import json
823
+
824
+ from otterapi.codegen.codegen import Codegen
825
+ from otterapi.config import DocumentConfig
826
+
827
+ openapi_spec = {
828
+ 'openapi': '3.0.0',
829
+ 'info': {'title': 'Test API', 'version': '1.0.0'},
830
+ 'servers': [{'url': 'https://api.example.com'}],
831
+ 'paths': {
832
+ '/users': {
833
+ 'get': {
834
+ 'operationId': 'listUsers',
835
+ 'responses': {
836
+ '200': {
837
+ 'description': 'List of users',
838
+ 'content': {
839
+ 'application/json': {
840
+ 'schema': {
841
+ 'type': 'array',
842
+ 'items': {
843
+ '$ref': '#/components/schemas/User'
844
+ },
845
+ }
846
+ }
847
+ },
848
+ }
849
+ },
850
+ }
851
+ }
852
+ },
853
+ 'components': {
854
+ 'schemas': {
855
+ 'User': {
856
+ 'type': 'object',
857
+ 'properties': {
858
+ 'id': {'type': 'integer'},
859
+ 'name': {'type': 'string'},
860
+ },
861
+ }
862
+ }
863
+ },
864
+ }
865
+
866
+ with tempfile.TemporaryDirectory() as tmpdir:
867
+ spec_path = Path(tmpdir) / 'openapi.json'
868
+ spec_path.write_text(json.dumps(openapi_spec))
869
+ output_path = Path(tmpdir) / 'client'
870
+
871
+ config = DocumentConfig(
872
+ source=str(spec_path),
873
+ output=str(output_path),
874
+ )
875
+
876
+ codegen = Codegen(config)
877
+ codegen.generate()
878
+
879
+ # Verify pagination module was NOT generated
880
+ assert not (output_path / '_pagination.py').exists()
881
+
882
+ # Verify endpoints.py was generated normally
883
+ endpoints_file = output_path / 'endpoints.py'
884
+ assert endpoints_file.exists()
885
+ content = endpoints_file.read_text()
886
+ ast.parse(content) # Should not raise
887
+
888
+ # Verify no pagination imports
889
+ assert 'paginate_offset' not in content
890
+ assert 'paginate_cursor' not in content
891
+
892
+
893
+ class TestPaginationAutoDetect:
894
+ """Tests for pagination auto-detection feature."""
895
+
896
+ def test_auto_detect_default_enabled(self):
897
+ """Test that auto_detect is enabled by default."""
898
+ config = PaginationConfig(enabled=True)
899
+ assert config.auto_detect is True
900
+
901
+ def test_auto_detect_disabled(self):
902
+ """Test that auto_detect can be disabled."""
903
+ config = PaginationConfig(enabled=True, auto_detect=False)
904
+ assert config.auto_detect is False
905
+
906
+ def test_auto_detect_offset_pagination(self):
907
+ """Test auto-detection of offset pagination parameters."""
908
+ from dataclasses import dataclass
909
+
910
+ @dataclass
911
+ class MockParam:
912
+ name: str
913
+
914
+ config = PaginationConfig(enabled=True, auto_detect=True)
915
+ params = [
916
+ MockParam(name='offset'),
917
+ MockParam(name='limit'),
918
+ MockParam(name='query'),
919
+ ]
920
+
921
+ should_generate, resolved = config.should_generate_for_endpoint(
922
+ 'list_users', endpoint_parameters=params
923
+ )
924
+ assert should_generate is True
925
+ assert resolved is not None
926
+ assert resolved.style == PaginationStyle.OFFSET
927
+ assert resolved.offset_param == 'offset'
928
+ assert resolved.limit_param == 'limit'
929
+
930
+ def test_auto_detect_cursor_pagination(self):
931
+ """Test auto-detection of cursor pagination parameters."""
932
+ from dataclasses import dataclass
933
+
934
+ @dataclass
935
+ class MockParam:
936
+ name: str
937
+
938
+ config = PaginationConfig(enabled=True, auto_detect=True)
939
+ params = [
940
+ MockParam(name='cursor'),
941
+ MockParam(name='limit'),
942
+ MockParam(name='query'),
943
+ ]
944
+
945
+ should_generate, resolved = config.should_generate_for_endpoint(
946
+ 'list_items', endpoint_parameters=params
947
+ )
948
+ assert should_generate is True
949
+ assert resolved is not None
950
+ assert resolved.style == PaginationStyle.CURSOR
951
+ assert resolved.cursor_param == 'cursor'
952
+ assert resolved.limit_param == 'limit'
953
+
954
+ def test_auto_detect_page_pagination(self):
955
+ """Test auto-detection of page pagination parameters."""
956
+ from dataclasses import dataclass
957
+
958
+ @dataclass
959
+ class MockParam:
960
+ name: str
961
+
962
+ config = PaginationConfig(enabled=True, auto_detect=True)
963
+ params = [
964
+ MockParam(name='page'),
965
+ MockParam(name='per_page'),
966
+ MockParam(name='query'),
967
+ ]
968
+
969
+ should_generate, resolved = config.should_generate_for_endpoint(
970
+ 'list_orders', endpoint_parameters=params
971
+ )
972
+ assert should_generate is True
973
+ assert resolved is not None
974
+ assert resolved.style == PaginationStyle.PAGE
975
+ assert resolved.page_param == 'page'
976
+ assert resolved.per_page_param == 'per_page'
977
+
978
+ def test_auto_detect_no_pagination_params(self):
979
+ """Test that endpoints without pagination params are not detected."""
980
+ from dataclasses import dataclass
981
+
982
+ @dataclass
983
+ class MockParam:
984
+ name: str
985
+
986
+ config = PaginationConfig(enabled=True, auto_detect=True)
987
+ params = [MockParam(name='query'), MockParam(name='filter')]
988
+
989
+ should_generate, resolved = config.should_generate_for_endpoint(
990
+ 'search_users', endpoint_parameters=params
991
+ )
992
+ assert should_generate is False
993
+ assert resolved is None
994
+
995
+ def test_auto_detect_custom_param_names(self):
996
+ """Test auto-detection with custom parameter names."""
997
+ from dataclasses import dataclass
998
+
999
+ @dataclass
1000
+ class MockParam:
1001
+ name: str
1002
+
1003
+ config = PaginationConfig(
1004
+ enabled=True,
1005
+ auto_detect=True,
1006
+ default_offset_param='skip',
1007
+ default_limit_param='take',
1008
+ )
1009
+ params = [
1010
+ MockParam(name='skip'),
1011
+ MockParam(name='take'),
1012
+ MockParam(name='query'),
1013
+ ]
1014
+
1015
+ should_generate, resolved = config.should_generate_for_endpoint(
1016
+ 'list_users', endpoint_parameters=params
1017
+ )
1018
+ assert should_generate is True
1019
+ assert resolved is not None
1020
+ assert resolved.style == PaginationStyle.OFFSET
1021
+ assert resolved.offset_param == 'skip'
1022
+ assert resolved.limit_param == 'take'
1023
+
1024
+ def test_auto_detect_disabled_no_detection(self):
1025
+ """Test that auto_detect=False prevents auto-detection."""
1026
+ from dataclasses import dataclass
1027
+
1028
+ @dataclass
1029
+ class MockParam:
1030
+ name: str
1031
+
1032
+ config = PaginationConfig(enabled=True, auto_detect=False)
1033
+ params = [MockParam(name='offset'), MockParam(name='limit')]
1034
+
1035
+ should_generate, resolved = config.should_generate_for_endpoint(
1036
+ 'list_users', endpoint_parameters=params
1037
+ )
1038
+ assert should_generate is False
1039
+ assert resolved is None
1040
+
1041
+ def test_auto_detect_no_params_passed(self):
1042
+ """Test that auto-detection is skipped when no params passed."""
1043
+ config = PaginationConfig(enabled=True, auto_detect=True)
1044
+
1045
+ should_generate, resolved = config.should_generate_for_endpoint(
1046
+ 'list_users', endpoint_parameters=None
1047
+ )
1048
+ assert should_generate is False
1049
+ assert resolved is None
1050
+
1051
+ def test_explicit_config_takes_precedence(self):
1052
+ """Test that explicit endpoint config takes precedence over auto-detect."""
1053
+ from dataclasses import dataclass
1054
+
1055
+ @dataclass
1056
+ class MockParam:
1057
+ name: str
1058
+
1059
+ config = PaginationConfig(
1060
+ enabled=True,
1061
+ auto_detect=True,
1062
+ endpoints={
1063
+ 'list_users': EndpointPaginationConfig(
1064
+ style='cursor',
1065
+ data_path='users',
1066
+ ),
1067
+ },
1068
+ )
1069
+ # Even though params suggest offset, explicit config says cursor
1070
+ params = [MockParam(name='offset'), MockParam(name='limit')]
1071
+
1072
+ should_generate, resolved = config.should_generate_for_endpoint(
1073
+ 'list_users', endpoint_parameters=params
1074
+ )
1075
+ assert should_generate is True
1076
+ assert resolved is not None
1077
+ assert resolved.style == PaginationStyle.CURSOR
1078
+ assert resolved.data_path == 'users'
1079
+
1080
+ def test_explicit_disabled_prevents_auto_detect(self):
1081
+ """Test that explicitly disabled endpoint prevents auto-detect."""
1082
+ from dataclasses import dataclass
1083
+
1084
+ @dataclass
1085
+ class MockParam:
1086
+ name: str
1087
+
1088
+ config = PaginationConfig(
1089
+ enabled=True,
1090
+ auto_detect=True,
1091
+ endpoints={
1092
+ 'list_users': EndpointPaginationConfig(enabled=False),
1093
+ },
1094
+ )
1095
+ params = [MockParam(name='offset'), MockParam(name='limit')]
1096
+
1097
+ should_generate, resolved = config.should_generate_for_endpoint(
1098
+ 'list_users', endpoint_parameters=params
1099
+ )
1100
+ assert should_generate is False
1101
+ assert resolved is None