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,512 @@
1
+ """Tests for the module map resolver.
2
+
3
+ This module tests the ModuleMapResolver class and ResolvedModule dataclass
4
+ used for matching endpoint paths to target modules.
5
+ """
6
+
7
+ from otterapi.codegen.splitting import ModuleMapResolver, ResolvedModule
8
+ from otterapi.config import ModuleDefinition, ModuleSplitConfig
9
+
10
+
11
+ class TestResolvedModule:
12
+ """Tests for the ResolvedModule dataclass."""
13
+
14
+ def test_module_name_single(self):
15
+ """Test module_name property with single component."""
16
+ resolved = ResolvedModule(module_path=['users'])
17
+ assert resolved.module_name == 'users'
18
+
19
+ def test_module_name_multiple(self):
20
+ """Test module_name property with multiple components."""
21
+ resolved = ResolvedModule(module_path=['api', 'v1', 'users'])
22
+ assert resolved.module_name == 'api.v1.users'
23
+
24
+ def test_module_name_empty(self):
25
+ """Test module_name property with empty path."""
26
+ resolved = ResolvedModule(module_path=[])
27
+ assert resolved.module_name == ''
28
+
29
+ def test_file_path_single(self):
30
+ """Test file_path property with single component."""
31
+ resolved = ResolvedModule(module_path=['users'])
32
+ assert resolved.file_path == 'users.py'
33
+
34
+ def test_file_path_nested(self):
35
+ """Test file_path property with nested path."""
36
+ resolved = ResolvedModule(module_path=['api', 'v1', 'users'])
37
+ assert resolved.file_path == 'api/v1/users.py'
38
+
39
+ def test_flat_file_path(self):
40
+ """Test flat_file_path property."""
41
+ resolved = ResolvedModule(module_path=['api', 'v1', 'users'])
42
+ assert resolved.flat_file_path == 'api_v1_users.py'
43
+
44
+ def test_resolution_types(self):
45
+ """Test different resolution types."""
46
+ custom = ResolvedModule(module_path=['users'], resolution='custom')
47
+ tag = ResolvedModule(module_path=['users'], resolution='tag')
48
+ path = ResolvedModule(module_path=['users'], resolution='path')
49
+ fallback = ResolvedModule(module_path=['common'], resolution='fallback')
50
+
51
+ assert custom.resolution == 'custom'
52
+ assert tag.resolution == 'tag'
53
+ assert path.resolution == 'path'
54
+ assert fallback.resolution == 'fallback'
55
+
56
+
57
+ class TestModuleMapResolverBasic:
58
+ """Basic tests for ModuleMapResolver."""
59
+
60
+ def test_resolver_initialization(self):
61
+ """Test resolver initialization."""
62
+ config = ModuleSplitConfig(enabled=True)
63
+ resolver = ModuleMapResolver(config)
64
+ assert resolver.config == config
65
+
66
+ def test_fallback_resolution(self):
67
+ """Test that unmatched paths go to fallback module."""
68
+ config = ModuleSplitConfig(
69
+ enabled=True,
70
+ strategy='custom',
71
+ fallback_module='misc',
72
+ module_map={},
73
+ )
74
+ resolver = ModuleMapResolver(config)
75
+ result = resolver.resolve('/unknown/path', 'GET')
76
+
77
+ assert result.module_path == ['misc']
78
+ assert result.resolution == 'fallback'
79
+
80
+ def test_custom_fallback_name(self):
81
+ """Test custom fallback module name."""
82
+ config = ModuleSplitConfig(
83
+ enabled=True,
84
+ strategy='custom', # Use custom strategy with no module_map to force fallback
85
+ fallback_module='other',
86
+ module_map={},
87
+ )
88
+ resolver = ModuleMapResolver(config)
89
+ result = resolver.resolve('/unmatched', 'GET')
90
+
91
+ assert result.module_path == ['other']
92
+
93
+
94
+ class TestGlobalPrefixStripping:
95
+ """Tests for global prefix stripping."""
96
+
97
+ def test_strip_api_prefix(self):
98
+ """Test stripping /api prefix."""
99
+ config = ModuleSplitConfig(
100
+ enabled=True,
101
+ strategy='path',
102
+ global_strip_prefixes=['/api'],
103
+ )
104
+ resolver = ModuleMapResolver(config)
105
+ result = resolver.resolve('/api/users', 'GET')
106
+
107
+ assert result.module_path == ['users']
108
+ assert result.stripped_path == '/users'
109
+
110
+ def test_strip_versioned_prefix(self):
111
+ """Test stripping versioned API prefix."""
112
+ config = ModuleSplitConfig(
113
+ enabled=True,
114
+ strategy='path',
115
+ global_strip_prefixes=['/api/v1', '/api/v2'],
116
+ )
117
+ resolver = ModuleMapResolver(config)
118
+
119
+ result_v1 = resolver.resolve('/api/v1/users', 'GET')
120
+ assert result_v1.stripped_path == '/users'
121
+
122
+ result_v2 = resolver.resolve('/api/v2/users', 'GET')
123
+ assert result_v2.stripped_path == '/users'
124
+
125
+ def test_no_matching_prefix(self):
126
+ """Test path without matching prefix."""
127
+ config = ModuleSplitConfig(
128
+ enabled=True,
129
+ strategy='path',
130
+ global_strip_prefixes=['/api'],
131
+ )
132
+ resolver = ModuleMapResolver(config)
133
+ result = resolver.resolve('/users', 'GET')
134
+
135
+ assert result.stripped_path == '/users'
136
+
137
+ def test_prefix_order_matters(self):
138
+ """Test that longer prefixes should be first for correct matching."""
139
+ config = ModuleSplitConfig(
140
+ enabled=True,
141
+ strategy='path',
142
+ global_strip_prefixes=['/api/v1', '/api'],
143
+ )
144
+ resolver = ModuleMapResolver(config)
145
+ result = resolver.resolve('/api/v1/users', 'GET')
146
+
147
+ # Should strip /api/v1, not just /api
148
+ assert result.stripped_path == '/users'
149
+
150
+
151
+ class TestCustomModuleMap:
152
+ """Tests for custom module_map resolution."""
153
+
154
+ def test_simple_pattern_match(self):
155
+ """Test simple pattern matching."""
156
+ config = ModuleSplitConfig(
157
+ enabled=True,
158
+ strategy='custom',
159
+ global_strip_prefixes=[],
160
+ module_map={'users': ['/users', '/users/*']},
161
+ )
162
+ resolver = ModuleMapResolver(config)
163
+
164
+ result = resolver.resolve('/users', 'GET')
165
+ assert result.module_path == ['users']
166
+ assert result.resolution == 'custom'
167
+
168
+ result = resolver.resolve('/users/123', 'GET')
169
+ assert result.module_path == ['users']
170
+
171
+ def test_wildcard_pattern(self):
172
+ """Test wildcard * pattern matching."""
173
+ config = ModuleSplitConfig(
174
+ enabled=True,
175
+ strategy='custom',
176
+ global_strip_prefixes=[],
177
+ module_map={'users': ['/users/*']},
178
+ )
179
+ resolver = ModuleMapResolver(config)
180
+
181
+ # Should match
182
+ result = resolver.resolve('/users/123', 'GET')
183
+ assert result.module_path == ['users']
184
+
185
+ # Should not match (no wildcard for root)
186
+ result = resolver.resolve('/users', 'GET')
187
+ assert result.resolution == 'fallback'
188
+
189
+ def test_double_wildcard_pattern(self):
190
+ """Test recursive ** pattern matching."""
191
+ config = ModuleSplitConfig(
192
+ enabled=True,
193
+ strategy='custom',
194
+ global_strip_prefixes=[],
195
+ module_map={'users': ['/users/**']},
196
+ )
197
+ resolver = ModuleMapResolver(config)
198
+
199
+ result = resolver.resolve('/users/123', 'GET')
200
+ assert result.module_path == ['users']
201
+
202
+ result = resolver.resolve('/users/123/profile', 'GET')
203
+ assert result.module_path == ['users']
204
+
205
+ result = resolver.resolve('/users/123/settings/privacy', 'GET')
206
+ assert result.module_path == ['users']
207
+
208
+ def test_multiple_modules(self):
209
+ """Test multiple modules in module_map."""
210
+ config = ModuleSplitConfig(
211
+ enabled=True,
212
+ strategy='custom',
213
+ global_strip_prefixes=[],
214
+ module_map={
215
+ 'users': ['/users/*'],
216
+ 'orders': ['/orders/*'],
217
+ 'health': ['/health', '/ready'],
218
+ },
219
+ )
220
+ resolver = ModuleMapResolver(config)
221
+
222
+ assert resolver.resolve('/users/123', 'GET').module_path == ['users']
223
+ assert resolver.resolve('/orders/456', 'GET').module_path == ['orders']
224
+ assert resolver.resolve('/health', 'GET').module_path == ['health']
225
+ assert resolver.resolve('/ready', 'GET').module_path == ['health']
226
+
227
+ def test_nested_modules(self):
228
+ """Test nested module definitions."""
229
+ config = ModuleSplitConfig(
230
+ enabled=True,
231
+ strategy='custom',
232
+ global_strip_prefixes=[],
233
+ module_map={
234
+ 'identity': ModuleDefinition(
235
+ paths=['/identity/**'],
236
+ modules={
237
+ 'users': ModuleDefinition(paths=['/users/*']),
238
+ 'auth': ModuleDefinition(paths=['/auth/*', '/login']),
239
+ },
240
+ ),
241
+ },
242
+ )
243
+ resolver = ModuleMapResolver(config)
244
+
245
+ # Match the parent
246
+ result = resolver.resolve('/identity/settings', 'GET')
247
+ assert result.module_path == ['identity']
248
+
249
+ def test_module_strip_prefix(self):
250
+ """Test per-module strip_prefix."""
251
+ config = ModuleSplitConfig(
252
+ enabled=True,
253
+ strategy='custom',
254
+ global_strip_prefixes=[],
255
+ module_map={
256
+ 'v2': ModuleDefinition(
257
+ paths=['/v2/**'],
258
+ strip_prefix='/v2',
259
+ modules={
260
+ 'users': ModuleDefinition(paths=['/users/*']),
261
+ },
262
+ ),
263
+ },
264
+ )
265
+ resolver = ModuleMapResolver(config)
266
+
267
+ result = resolver.resolve('/v2/users/123', 'GET')
268
+ assert result.module_path == ['v2', 'users']
269
+
270
+
271
+ class TestTagBasedResolution:
272
+ """Tests for tag-based resolution."""
273
+
274
+ def test_tag_resolution(self):
275
+ """Test resolution based on OpenAPI tags."""
276
+ config = ModuleSplitConfig(
277
+ enabled=True,
278
+ strategy='tag',
279
+ )
280
+ resolver = ModuleMapResolver(config)
281
+
282
+ result = resolver.resolve('/users/123', 'GET', tags=['Users'])
283
+ assert result.module_path == ['users']
284
+ assert result.resolution == 'tag'
285
+
286
+ def test_first_tag_used(self):
287
+ """Test that only the first tag is used."""
288
+ config = ModuleSplitConfig(
289
+ enabled=True,
290
+ strategy='tag',
291
+ )
292
+ resolver = ModuleMapResolver(config)
293
+
294
+ result = resolver.resolve('/users/123', 'GET', tags=['Users', 'Admin'])
295
+ assert result.module_path == ['users']
296
+
297
+ def test_no_tags_fallback(self):
298
+ """Test fallback when no tags provided."""
299
+ config = ModuleSplitConfig(
300
+ enabled=True,
301
+ strategy='tag',
302
+ fallback_module='common',
303
+ )
304
+ resolver = ModuleMapResolver(config)
305
+
306
+ result = resolver.resolve('/users/123', 'GET', tags=None)
307
+ assert result.module_path == ['common']
308
+ assert result.resolution == 'fallback'
309
+
310
+ def test_empty_tags_fallback(self):
311
+ """Test fallback when tags list is empty."""
312
+ config = ModuleSplitConfig(
313
+ enabled=True,
314
+ strategy='tag',
315
+ )
316
+ resolver = ModuleMapResolver(config)
317
+
318
+ result = resolver.resolve('/users/123', 'GET', tags=[])
319
+ assert result.resolution == 'fallback'
320
+
321
+ def test_tag_sanitization(self):
322
+ """Test that tags are sanitized to valid Python identifiers."""
323
+ config = ModuleSplitConfig(
324
+ enabled=True,
325
+ strategy='tag',
326
+ )
327
+ resolver = ModuleMapResolver(config)
328
+
329
+ result = resolver.resolve('/test', 'GET', tags=['User Management'])
330
+ assert result.module_path == ['user_management']
331
+
332
+ result = resolver.resolve('/test', 'GET', tags=['123-invalid'])
333
+ assert result.module_path == ['_123_invalid']
334
+
335
+
336
+ class TestPathBasedResolution:
337
+ """Tests for path-based resolution."""
338
+
339
+ def test_path_resolution_depth_1(self):
340
+ """Test path resolution with depth 1."""
341
+ config = ModuleSplitConfig(
342
+ enabled=True,
343
+ strategy='path',
344
+ path_depth=1,
345
+ global_strip_prefixes=[],
346
+ )
347
+ resolver = ModuleMapResolver(config)
348
+
349
+ result = resolver.resolve('/users/123', 'GET')
350
+ assert result.module_path == ['users']
351
+ assert result.resolution == 'path'
352
+
353
+ def test_path_resolution_depth_2(self):
354
+ """Test path resolution with depth 2."""
355
+ config = ModuleSplitConfig(
356
+ enabled=True,
357
+ strategy='path',
358
+ path_depth=2,
359
+ global_strip_prefixes=[],
360
+ )
361
+ resolver = ModuleMapResolver(config)
362
+
363
+ result = resolver.resolve('/api/users/123', 'GET')
364
+ assert result.module_path == ['api_users']
365
+
366
+ def test_path_ignores_parameters(self):
367
+ """Test that path parameters are ignored."""
368
+ config = ModuleSplitConfig(
369
+ enabled=True,
370
+ strategy='path',
371
+ path_depth=2,
372
+ global_strip_prefixes=[],
373
+ )
374
+ resolver = ModuleMapResolver(config)
375
+
376
+ result = resolver.resolve('/users/{id}/profile', 'GET')
377
+ assert result.module_path == ['users_profile']
378
+
379
+ def test_path_sanitization(self):
380
+ """Test that path segments are sanitized."""
381
+ config = ModuleSplitConfig(
382
+ enabled=True,
383
+ strategy='path',
384
+ global_strip_prefixes=[],
385
+ )
386
+ resolver = ModuleMapResolver(config)
387
+
388
+ result = resolver.resolve('/user-management/list', 'GET')
389
+ assert result.module_path == ['user_management']
390
+
391
+ def test_root_path_fallback(self):
392
+ """Test fallback for root path."""
393
+ config = ModuleSplitConfig(
394
+ enabled=True,
395
+ strategy='path',
396
+ )
397
+ resolver = ModuleMapResolver(config)
398
+
399
+ result = resolver.resolve('/', 'GET')
400
+ assert result.resolution == 'fallback'
401
+
402
+
403
+ class TestHybridStrategy:
404
+ """Tests for hybrid strategy combining custom, tag, and path."""
405
+
406
+ def test_hybrid_custom_first(self):
407
+ """Test that custom module_map takes priority in hybrid mode."""
408
+ config = ModuleSplitConfig(
409
+ enabled=True,
410
+ strategy='hybrid',
411
+ global_strip_prefixes=[],
412
+ module_map={'users': ['/users/*']},
413
+ )
414
+ resolver = ModuleMapResolver(config)
415
+
416
+ # Custom match should take priority over tags
417
+ result = resolver.resolve('/users/123', 'GET', tags=['Other'])
418
+ assert result.module_path == ['users']
419
+ assert result.resolution == 'custom'
420
+
421
+ def test_hybrid_tag_second(self):
422
+ """Test that tags are used when no custom match."""
423
+ config = ModuleSplitConfig(
424
+ enabled=True,
425
+ strategy='hybrid',
426
+ global_strip_prefixes=[],
427
+ module_map={'users': ['/users/*']},
428
+ )
429
+ resolver = ModuleMapResolver(config)
430
+
431
+ # No custom match, should use tag
432
+ result = resolver.resolve('/orders/123', 'GET', tags=['Orders'])
433
+ assert result.module_path == ['orders']
434
+ assert result.resolution == 'tag'
435
+
436
+ def test_hybrid_path_third(self):
437
+ """Test that path is used when no custom or tag match."""
438
+ config = ModuleSplitConfig(
439
+ enabled=True,
440
+ strategy='hybrid',
441
+ global_strip_prefixes=[],
442
+ module_map={'users': ['/users/*']},
443
+ )
444
+ resolver = ModuleMapResolver(config)
445
+
446
+ # No custom match, no tags, should use path
447
+ result = resolver.resolve('/orders/123', 'GET', tags=None)
448
+ assert result.module_path == ['orders']
449
+ assert result.resolution == 'path'
450
+
451
+ def test_hybrid_fallback_last(self):
452
+ """Test fallback when nothing else matches."""
453
+ config = ModuleSplitConfig(
454
+ enabled=True,
455
+ strategy='hybrid',
456
+ global_strip_prefixes=[],
457
+ module_map={},
458
+ fallback_module='misc',
459
+ )
460
+ resolver = ModuleMapResolver(config)
461
+
462
+ result = resolver.resolve('/', 'GET', tags=None)
463
+ assert result.module_path == ['misc']
464
+ assert result.resolution == 'fallback'
465
+
466
+
467
+ class TestPatternMatching:
468
+ """Tests for glob pattern matching edge cases."""
469
+
470
+ def test_exact_match(self):
471
+ """Test exact path matching."""
472
+ config = ModuleSplitConfig(
473
+ enabled=True,
474
+ strategy='custom',
475
+ global_strip_prefixes=[],
476
+ module_map={'health': ['/health']},
477
+ )
478
+ resolver = ModuleMapResolver(config)
479
+
480
+ assert resolver.resolve('/health', 'GET').module_path == ['health']
481
+ assert resolver.resolve('/health/', 'GET').module_path == ['health']
482
+ assert resolver.resolve('/healthy', 'GET').resolution == 'fallback'
483
+
484
+ def test_trailing_slash_handling(self):
485
+ """Test that trailing slashes are handled correctly."""
486
+ config = ModuleSplitConfig(
487
+ enabled=True,
488
+ strategy='custom',
489
+ global_strip_prefixes=[],
490
+ module_map={'users': ['/users/*']},
491
+ )
492
+ resolver = ModuleMapResolver(config)
493
+
494
+ result1 = resolver.resolve('/users/123', 'GET')
495
+ result2 = resolver.resolve('/users/123/', 'GET')
496
+
497
+ assert result1.module_path == result2.module_path
498
+
499
+ def test_question_mark_pattern(self):
500
+ """Test ? pattern for single character matching."""
501
+ config = ModuleSplitConfig(
502
+ enabled=True,
503
+ strategy='custom',
504
+ global_strip_prefixes=[],
505
+ module_map={'version': ['/v?/users']},
506
+ )
507
+ resolver = ModuleMapResolver(config)
508
+
509
+ assert resolver.resolve('/v1/users', 'GET').module_path == ['version']
510
+ assert resolver.resolve('/v2/users', 'GET').module_path == ['version']
511
+ # v10 has two characters, shouldn't match
512
+ assert resolver.resolve('/v10/users', 'GET').resolution == 'fallback'