otterapi 0.1.4__tar.gz → 0.1.6__tar.gz

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 (89) hide show
  1. {otterapi-0.1.4 → otterapi-0.1.6}/PKG-INFO +1 -1
  2. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/codegen.py +171 -12
  3. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/dataframes.py +45 -10
  4. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/endpoints.py +12 -4
  5. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/export.py +33 -9
  6. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/splitting.py +31 -2
  7. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/types.py +172 -21
  8. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/utils.py +25 -2
  9. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v3/v3.py +21 -2
  10. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/fixtures/golden/discriminator/spec.yaml +2 -0
  11. otterapi-0.1.6/otterapi/tests/test_codegen_edge_cases.py +170 -0
  12. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_dataframe.py +230 -0
  13. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_discriminated_unions.py +2 -2
  14. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_export.py +84 -8
  15. otterapi-0.1.6/otterapi/tests/test_recursive_schema.py +243 -0
  16. {otterapi-0.1.4 → otterapi-0.1.6}/.gitignore +0 -0
  17. {otterapi-0.1.4 → otterapi-0.1.6}/README.md +0 -0
  18. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/__init__.py +0 -0
  19. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/__main__.py +0 -0
  20. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/cli.py +0 -0
  21. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/__init__.py +0 -0
  22. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/_body_builder.py +0 -0
  23. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/_features.py +0 -0
  24. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/_openapi_adapter.py +0 -0
  25. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/ast_utils.py +0 -0
  26. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/client.py +0 -0
  27. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/pagination.py +0 -0
  28. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/runtime/__init__.py +0 -0
  29. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/runtime/_client_stub.py.tpl +0 -0
  30. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/runtime/_concurrency.py +0 -0
  31. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/runtime/_dataframe.py +0 -0
  32. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/runtime/_export.py +0 -0
  33. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/runtime/_models_mixin.py +0 -0
  34. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/runtime/_pagination.py +0 -0
  35. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/runtime/_retry.py +0 -0
  36. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/schema.py +0 -0
  37. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/config.py +0 -0
  38. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/exceptions.py +0 -0
  39. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/__init__.py +0 -0
  40. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/constants.py +0 -0
  41. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v2/__init__.py +0 -0
  42. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v2/spec.json +0 -0
  43. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v2/v2.py +0 -0
  44. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v3/__init__.py +0 -0
  45. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v3/spec.json +0 -0
  46. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v3_1/__init__.py +0 -0
  47. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v3_1/spec.json +0 -0
  48. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v3_1/v3_1.py +0 -0
  49. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v3_2/__init__.py +0 -0
  50. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v3_2/spec.json +0 -0
  51. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v3_2/v3_2.py +0 -0
  52. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/warnings.py +0 -0
  53. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/__init__.py +0 -0
  54. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/fixtures/__init__.py +0 -0
  55. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/fixtures/golden/constraints/spec.yaml +0 -0
  56. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/fixtures/golden/dataframe/config.yaml +0 -0
  57. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/fixtures/golden/dataframe/spec.yaml +0 -0
  58. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/fixtures/golden/paginated/config.yaml +0 -0
  59. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/fixtures/golden/paginated/spec.yaml +0 -0
  60. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_ast_utils.py +0 -0
  61. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_body_builder.py +0 -0
  62. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_client_retry_lifecycle.py +0 -0
  63. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_codegen.py +0 -0
  64. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_codegen_structure.py +0 -0
  65. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_concurrency_runtime.py +0 -0
  66. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_config_errors.py +0 -0
  67. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_exceptions.py +0 -0
  68. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_extra_forbid.py +0 -0
  69. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_feature_modules.py +0 -0
  70. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_html_repr.py +0 -0
  71. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_init_scaffold.py +0 -0
  72. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_not_schema.py +0 -0
  73. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_nullable_upgrade.py +0 -0
  74. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_openapi_adapter.py +0 -0
  75. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_openapi_support.py +0 -0
  76. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_openapi_upgrade.py +0 -0
  77. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_pagination.py +0 -0
  78. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_per_status_errors.py +0 -0
  79. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_public_api.py +0 -0
  80. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_regen_script.py +0 -0
  81. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_retry_runtime.py +0 -0
  82. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_runtime_guards.py +0 -0
  83. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_schema_constraints.py +0 -0
  84. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_smoke_generated_client.py +0 -0
  85. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_splitting_config.py +0 -0
  86. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_splitting_integration.py +0 -0
  87. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_splitting_resolver.py +0 -0
  88. {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_splitting_tree.py +0 -0
  89. {otterapi-0.1.4 → otterapi-0.1.6}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: otterapi
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: A cute little companion that generates type-safe clients from OpenAPI documents.
5
5
  Project-URL: Source, https://github.com/danplischke/otter
6
6
  Author: Dan Plischke
@@ -74,6 +74,59 @@ def _load_models_mixin_source() -> str:
74
74
  )
75
75
 
76
76
 
77
+ def _topo_sort_by_base_classes(
78
+ types_list: list,
79
+ model_names: set[str],
80
+ ) -> list:
81
+ """Return *types_list* re-ordered so every base class precedes its subclasses.
82
+
83
+ ``get_sorted_types()`` uses field-level dependency edges. When it breaks a
84
+ field-level cycle it may place a subclass before its base class.
85
+ ``from __future__ import annotations`` defers annotation evaluation, so
86
+ field-level cycles are fine — but ``class Foo(Bar):`` base expressions are
87
+ evaluated *eagerly* at class-definition time. Inheritance always forms a
88
+ DAG in Python (cycles are a ``TypeError``), so a simple DFS topological sort
89
+ on base-class edges always terminates and produces a valid ordering.
90
+
91
+ Types with no name (primitive aliases, etc.) are appended unchanged after
92
+ the named types.
93
+ """
94
+ name_to_type: dict[str, object] = {t.name: t for t in types_list if t.name}
95
+
96
+ def base_deps(t: object) -> set[str]:
97
+ impl = t.implementation_ast # type: ignore[attr-defined]
98
+ if not isinstance(impl, ast.ClassDef):
99
+ return set()
100
+ return {
101
+ b.id for b in impl.bases if isinstance(b, ast.Name) and b.id in model_names
102
+ }
103
+
104
+ result: list = []
105
+ visited: set[str] = set()
106
+
107
+ def dfs(name: str) -> None:
108
+ if name in visited:
109
+ return
110
+ visited.add(name)
111
+ t = name_to_type.get(name)
112
+ if t is None:
113
+ return
114
+ for base_name in base_deps(t):
115
+ dfs(base_name)
116
+ result.append(t)
117
+
118
+ for t in types_list:
119
+ if t.name:
120
+ dfs(t.name)
121
+
122
+ # Preserve unnamed entries (should be rare / none for ClassDef models)
123
+ for t in types_list:
124
+ if not t.name:
125
+ result.append(t)
126
+
127
+ return result
128
+
129
+
77
130
  # Content types that should be treated as JSON
78
131
  JSON_CONTENT_TYPES = {MediaType.JSON, MediaType.TEXT_JSON}
79
132
 
@@ -1159,6 +1212,7 @@ class Codegen(OpenAPIProcessor):
1159
1212
  is_async=is_async,
1160
1213
  default_format=default_format,
1161
1214
  default_batch_size=self.config.export.batch_size,
1215
+ pagination_limit_param=self.config.pagination.limit_param,
1162
1216
  )
1163
1217
  endpoint_names.add(fn_name)
1164
1218
  body.append(fn)
@@ -1218,7 +1272,12 @@ class Codegen(OpenAPIProcessor):
1218
1272
  """Emit sync+async export wrappers for a non-paginated list endpoint."""
1219
1273
  if not self.config.export.enabled:
1220
1274
  return False
1221
- item_type_ast, item_type_imports = self._get_item_type_ast(endpoint)
1275
+ # When response unwrapping is active, the list lives behind the data
1276
+ # path (e.g. envelope.data) rather than directly on the response type,
1277
+ # so resolve the item type through that path.
1278
+ should_unwrap, unwrap_path = self._get_unwrap_config(endpoint)
1279
+ data_path = unwrap_path if should_unwrap else None
1280
+ item_type_ast, item_type_imports = self._get_item_type_ast(endpoint, data_path)
1222
1281
  if item_type_ast is None:
1223
1282
  return False
1224
1283
  should_generate, formats, _path = (
@@ -1311,23 +1370,63 @@ class Codegen(OpenAPIProcessor):
1311
1370
  import_collector = ImportCollector()
1312
1371
  all_names = set()
1313
1372
 
1314
- for type_name, type_ in self.typegen.types.items():
1373
+ # Names of all types that will get _HtmlReprMixin injected. Used
1374
+ # below to skip re-injection for classes whose base is already a
1375
+ # generated model (which would cause an MRO conflict).
1376
+ all_model_names: set[str] = {
1377
+ type_.name
1378
+ for type_ in self.typegen.types.values()
1379
+ if type_.name and isinstance(type_.implementation_ast, ast.ClassDef)
1380
+ }
1381
+
1382
+ # Emit types in dependency order (dependencies before dependents) so
1383
+ # that forward references in field annotations resolve at class-creation
1384
+ # time. get_sorted_types() returns dependents-first; reversing gives
1385
+ # the dependencies-first order needed for correct emission.
1386
+ # Types not reachable from get_sorted_types (e.g. orphaned names) are
1387
+ # appended at the end via the seen-set guard.
1388
+ sorted_types = list(reversed(self.typegen.get_sorted_types()))
1389
+ seen: set[str] = {t.name for t in sorted_types if t.name}
1390
+ for extra in self.typegen.types.values():
1391
+ if extra.name not in seen:
1392
+ sorted_types.append(extra)
1393
+
1394
+ # Re-sort to guarantee every base class precedes every subclass.
1395
+ # get_sorted_types() uses field-level dependencies; when it breaks a
1396
+ # field-level cycle it may place a subclass before its base class.
1397
+ # from __future__ import annotations makes field-type annotations lazy,
1398
+ # but base classes in `class Foo(Base):` are evaluated EAGERLY, so the
1399
+ # ordering of ClassDef nodes in the emitted file must respect the
1400
+ # inheritance DAG. Python forbids inheritance cycles, so this sort
1401
+ # always terminates.
1402
+ sorted_types = _topo_sort_by_base_classes(sorted_types, all_model_names)
1403
+
1404
+ for type_ in sorted_types:
1315
1405
  if type_.implementation_ast:
1316
1406
  impl = type_.implementation_ast
1317
1407
  # Inject _HtmlReprMixin as the first base of every model class so
1318
1408
  # it renders as an HTML table in Jupyter without touching the
1319
1409
  # per-model implementation_ast (which is exec'd in unit tests).
1410
+ # Skip injection when a base is itself a generated model: that
1411
+ # base already gets _HtmlReprMixin, so adding it again would
1412
+ # produce an unresolvable MRO (e.g. Child(_HtmlReprMixin, Parent)
1413
+ # where Parent is already Child(_HtmlReprMixin, BaseModel)).
1320
1414
  if isinstance(impl, ast.ClassDef):
1321
- impl = ast.ClassDef(
1322
- name=impl.name,
1323
- bases=[
1324
- ast.Name(id='_HtmlReprMixin', ctx=ast.Load()),
1325
- *impl.bases,
1326
- ],
1327
- keywords=impl.keywords,
1328
- body=impl.body,
1329
- decorator_list=impl.decorator_list,
1415
+ has_model_base = any(
1416
+ isinstance(b, ast.Name) and b.id in all_model_names
1417
+ for b in impl.bases
1330
1418
  )
1419
+ if not has_model_base:
1420
+ impl = ast.ClassDef(
1421
+ name=impl.name,
1422
+ bases=[
1423
+ ast.Name(id='_HtmlReprMixin', ctx=ast.Load()),
1424
+ *impl.bases,
1425
+ ],
1426
+ keywords=impl.keywords,
1427
+ body=impl.body,
1428
+ decorator_list=impl.decorator_list,
1429
+ )
1331
1430
  body.append(impl)
1332
1431
  if type_.name:
1333
1432
  all_names.add(type_.name)
@@ -1336,6 +1435,41 @@ class Codegen(OpenAPIProcessor):
1336
1435
  import_collector.add_imports(type_.implementation_imports)
1337
1436
  import_collector.add_imports(type_.annotation_imports)
1338
1437
 
1438
+ # Emit model_rebuild() / update_forward_refs() for cyclic models so
1439
+ # that Pydantic can resolve the forward references introduced by
1440
+ # from __future__ import annotations and self-referential schemas.
1441
+ cyclic = getattr(self.typegen, '_cyclic', set())
1442
+ for model_name in sorted(cyclic):
1443
+ if model_name in all_names:
1444
+ if self.typegen.pydantic_version == 1:
1445
+ body.append(
1446
+ ast.Expr(
1447
+ ast.Call(
1448
+ func=ast.Attribute(
1449
+ value=ast.Name(id=model_name, ctx=ast.Load()),
1450
+ attr='update_forward_refs',
1451
+ ctx=ast.Load(),
1452
+ ),
1453
+ args=[],
1454
+ keywords=[],
1455
+ )
1456
+ )
1457
+ )
1458
+ else:
1459
+ body.append(
1460
+ ast.Expr(
1461
+ ast.Call(
1462
+ func=ast.Attribute(
1463
+ value=ast.Name(id=model_name, ctx=ast.Load()),
1464
+ attr='model_rebuild',
1465
+ ctx=ast.Load(),
1466
+ ),
1467
+ args=[],
1468
+ keywords=[],
1469
+ )
1470
+ )
1471
+ )
1472
+
1339
1473
  # Prepend the _HtmlReprMixin helper (defines _repr_html_ for Jupyter)
1340
1474
  body[0:0] = ast.parse(_load_models_mixin_source()).body
1341
1475
 
@@ -1346,6 +1480,19 @@ class Codegen(OpenAPIProcessor):
1346
1480
  for import_stmt in import_collector.to_ast():
1347
1481
  body.insert(0, import_stmt)
1348
1482
 
1483
+ # from __future__ import annotations must be the very first statement
1484
+ # so forward references in type annotations resolve lazily. This is
1485
+ # required when cyclic models refer to each other before their class
1486
+ # bodies are fully defined.
1487
+ body.insert(
1488
+ 0,
1489
+ ast.ImportFrom(
1490
+ module='__future__',
1491
+ names=[ast.alias(name='annotations')],
1492
+ level=0,
1493
+ ),
1494
+ )
1495
+
1349
1496
  write_mod(body, path)
1350
1497
 
1351
1498
  def generate(self):
@@ -1721,8 +1868,20 @@ class Codegen(OpenAPIProcessor):
1721
1868
 
1722
1869
  Note:
1723
1870
  This method delegates to get_dataframe_config_for_endpoint() from dataframe_utils.
1871
+ When response unwrapping is active, the unwrapped data type is the
1872
+ endpoint's real return type, so it is passed through for list
1873
+ detection -- otherwise non-paginated envelope list endpoints (e.g.
1874
+ ``ResponseWithStatusEnvelope*``) would be misclassified and lose
1875
+ their DataFrame variants.
1724
1876
  """
1725
- return get_dataframe_config_for_endpoint(endpoint, self.config.dataframe)
1877
+ unwrap_type_ast = None
1878
+ should_unwrap, unwrap_path = self._get_unwrap_config(endpoint)
1879
+ if should_unwrap and unwrap_path:
1880
+ unwrap_type_ast, _ = self._get_unwrapped_type_ast(endpoint, unwrap_path)
1881
+
1882
+ return get_dataframe_config_for_endpoint(
1883
+ endpoint, self.config.dataframe, unwrap_type_ast=unwrap_type_ast
1884
+ )
1726
1885
 
1727
1886
  def _get_pagination_config(
1728
1887
  self, endpoint: Endpoint
@@ -26,6 +26,7 @@ __all__ = [
26
26
  'get_dataframe_config_from_parts',
27
27
  'endpoint_returns_list',
28
28
  'response_type_returns_list',
29
+ 'annotation_ast_returns_list',
29
30
  ]
30
31
 
31
32
 
@@ -79,14 +80,46 @@ class DataFrameMethodConfig:
79
80
  path: str | None = None
80
81
 
81
82
 
82
- def endpoint_returns_list(endpoint: 'Endpoint') -> bool:
83
+ def annotation_ast_returns_list(annotation_ast: 'ast.expr | None') -> bool:
84
+ """Check if a raw annotation AST represents a ``list[...]`` type.
85
+
86
+ Args:
87
+ annotation_ast: The annotation AST node to check, or None.
88
+
89
+ Returns:
90
+ True if the annotation is a ``list[...]`` subscript, False otherwise.
91
+ """
92
+ if isinstance(annotation_ast, ast.Subscript):
93
+ if isinstance(annotation_ast.value, ast.Name) and (
94
+ annotation_ast.value.id == 'list'
95
+ ):
96
+ return True
97
+
98
+ return False
99
+
100
+
101
+ def endpoint_returns_list(
102
+ endpoint: 'Endpoint',
103
+ unwrap_type_ast: 'ast.expr | None' = None,
104
+ ) -> bool:
83
105
  """Check if an endpoint returns a list type.
84
106
 
85
107
  Examines the endpoint's response type annotation AST to determine
86
108
  if it represents a list type.
87
109
 
110
+ When response unwrapping is active for the endpoint, the function's actual
111
+ return type is the unwrapped data field rather than the envelope model on
112
+ ``endpoint.response_type``. In that case the caller passes the unwrapped
113
+ type AST (e.g. ``list[Pet]`` extracted from ``data``) via
114
+ *unwrap_type_ast*, and it is consulted instead of the envelope type.
115
+ Without this, every non-paginated ``ResponseWithStatusEnvelope*`` list
116
+ endpoint would be misclassified as non-list and silently lose its
117
+ DataFrame variants.
118
+
88
119
  Args:
89
120
  endpoint: The endpoint to check.
121
+ unwrap_type_ast: The AST of the unwrapped return type when response
122
+ unwrapping is active, or None when it is not.
90
123
 
91
124
  Returns:
92
125
  True if the endpoint returns a list, False otherwise.
@@ -96,6 +129,9 @@ def endpoint_returns_list(endpoint: 'Endpoint') -> bool:
96
129
  >>> endpoint_returns_list(endpoint)
97
130
  True
98
131
  """
132
+ if unwrap_type_ast is not None:
133
+ return annotation_ast_returns_list(unwrap_type_ast)
134
+
99
135
  if not endpoint.response_type:
100
136
  return False
101
137
 
@@ -117,18 +153,13 @@ def response_type_returns_list(response_type: 'Type | None') -> bool:
117
153
  if not response_type:
118
154
  return False
119
155
 
120
- if response_type.annotation_ast:
121
- ann = response_type.annotation_ast
122
- if isinstance(ann, ast.Subscript):
123
- if isinstance(ann.value, ast.Name) and ann.value.id == 'list':
124
- return True
125
-
126
- return False
156
+ return annotation_ast_returns_list(response_type.annotation_ast)
127
157
 
128
158
 
129
159
  def get_dataframe_config_for_endpoint(
130
160
  endpoint: 'Endpoint',
131
161
  dataframe_config: 'DataFrameConfig',
162
+ unwrap_type_ast: 'ast.expr | None' = None,
132
163
  ) -> DataFrameMethodConfig:
133
164
  """Get the DataFrame method configuration for an endpoint.
134
165
 
@@ -139,6 +170,9 @@ def get_dataframe_config_for_endpoint(
139
170
  Args:
140
171
  endpoint: The endpoint to get configuration for.
141
172
  dataframe_config: The global DataFrame configuration.
173
+ unwrap_type_ast: The AST of the unwrapped return type when response
174
+ unwrapping is active for the endpoint, or None. When provided it
175
+ is used for list detection instead of the envelope response type.
142
176
 
143
177
  Returns:
144
178
  DataFrameMethodConfig with generation flags and default path.
@@ -151,8 +185,9 @@ def get_dataframe_config_for_endpoint(
151
185
  if not dataframe_config.enabled:
152
186
  return DataFrameMethodConfig()
153
187
 
154
- # Check if this endpoint returns a list type
155
- returns_list = endpoint_returns_list(endpoint)
188
+ # Check if this endpoint returns a list type. When response unwrapping is
189
+ # active the unwrapped data type is the real return type, so prefer it.
190
+ returns_list = endpoint_returns_list(endpoint, unwrap_type_ast=unwrap_type_ast)
156
191
 
157
192
  # Get the sync function name for config lookup
158
193
  endpoint_name = endpoint.sync_fn_name
@@ -227,10 +227,18 @@ class FunctionSignatureBuilder:
227
227
  if param.required:
228
228
  self._args.append(arg)
229
229
  else:
230
- # Wrap annotation in `| None` so the `= None` default is type-safe.
231
- optional_annotation = _union_expr(
232
- [annotation, ast.Constant(value=None)]
233
- )
230
+ # Wrap annotation in `| None` only if not already nullable.
231
+ # The OpenAPI schema may already encode nullability (nullable:true
232
+ # or type:[..., null]), in which case schema_to_type emits
233
+ # `T | None` and we must not add a second `| None`.
234
+ from otterapi.codegen.types import _binop_includes_none
235
+
236
+ if _binop_includes_none(annotation):
237
+ optional_annotation = annotation
238
+ else:
239
+ optional_annotation = _union_expr(
240
+ [annotation, ast.Constant(value=None)]
241
+ )
234
242
  self._kwonlyargs.append(
235
243
  _argument(param.name_sanitized, optional_annotation)
236
244
  )
@@ -99,9 +99,20 @@ class ExportMethodConfig:
99
99
  self.formats = []
100
100
 
101
101
 
102
- def _returns_list(response_type: 'Type | None') -> bool:
102
+ def _returns_list(
103
+ response_type: 'Type | None',
104
+ unwrap_type_ast: 'ast.expr | None' = None,
105
+ ) -> bool:
103
106
  # Local import avoids a circular dependency at module load.
104
- from otterapi.codegen.dataframes import response_type_returns_list
107
+ from otterapi.codegen.dataframes import (
108
+ annotation_ast_returns_list,
109
+ response_type_returns_list,
110
+ )
111
+
112
+ # When response unwrapping is active, list detection must inspect the
113
+ # unwrapped type (e.g. ``list[Thing]``) rather than the envelope type.
114
+ if unwrap_type_ast is not None:
115
+ return annotation_ast_returns_list(unwrap_type_ast)
105
116
 
106
117
  return response_type_returns_list(response_type)
107
118
 
@@ -109,12 +120,13 @@ def _returns_list(response_type: 'Type | None') -> bool:
109
120
  def get_export_config_for_endpoint(
110
121
  endpoint: 'Endpoint',
111
122
  export_config: 'ExportConfig',
123
+ unwrap_type_ast: 'ast.expr | None' = None,
112
124
  ) -> ExportMethodConfig:
113
125
  """Resolve export configuration for a fully-constructed endpoint."""
114
126
  if not export_config.enabled:
115
127
  return ExportMethodConfig()
116
128
 
117
- returns_list = _returns_list(endpoint.response_type)
129
+ returns_list = _returns_list(endpoint.response_type, unwrap_type_ast)
118
130
  should_generate, formats, path = export_config.should_generate_for_endpoint(
119
131
  endpoint_name=endpoint.sync_fn_name,
120
132
  returns_list=returns_list,
@@ -188,8 +200,8 @@ def _format_literal_annotation() -> ast.expr:
188
200
 
189
201
 
190
202
  def _output_path_annotation() -> ast.expr:
191
- """Build the AST for ``str | Path | None`` (the wrapper's path argument type)."""
192
- return _union_expr([_name('str'), _name('Path'), ast.Constant(value=None)])
203
+ """Build the AST for ``str | Path | UPath`` (the wrapper's path argument type)."""
204
+ return _union_expr([_name('str'), _name('Path'), _name('UPath')])
193
205
 
194
206
 
195
207
  def _add_export_kwonly_args(
@@ -199,9 +211,6 @@ def _add_export_kwonly_args(
199
211
  default_batch_size: int,
200
212
  ) -> None:
201
213
  """Append the export-specific keyword-only args + defaults in place."""
202
- kwonlyargs.append(_argument('output_path', _output_path_annotation()))
203
- kw_defaults.append(ast.Constant(value=None)) # required at call time
204
-
205
214
  kwonlyargs.append(_argument('format', _format_literal_annotation()))
206
215
  kw_defaults.append(ast.Constant(value=default_format))
207
216
 
@@ -300,6 +309,9 @@ def _build_export_function(
300
309
  from otterapi.codegen.endpoints import FunctionSignatureBuilder
301
310
 
302
311
  builder = FunctionSignatureBuilder()
312
+ # output_path is the first positional argument so it can be passed
313
+ # without a keyword: export_fn("out.jsonl", symbol="BRCA1")
314
+ builder._args.append(_argument('output_path', _output_path_annotation()))
303
315
  builder.add_parameters(parameters)
304
316
  builder.add_request_body(request_body_info)
305
317
  builder.add_client_parameter()
@@ -370,6 +382,7 @@ def _build_export_function(
370
382
 
371
383
  imports: ImportDict = {
372
384
  'pathlib': {'Path'},
385
+ 'upath': {'UPath'},
373
386
  'typing': {'Any', 'Literal'},
374
387
  '._export': {'export_async' if (is_async and is_paginated) else 'export'},
375
388
  }
@@ -428,6 +441,7 @@ def build_standalone_paginated_export_fn(
428
441
  is_async: bool,
429
442
  default_format: str = 'csv',
430
443
  default_batch_size: int = 1000,
444
+ pagination_limit_param: str = 'limit',
431
445
  ) -> tuple[ast.FunctionDef | ast.AsyncFunctionDef, ImportDict]:
432
446
  """Build an export wrapper around a paginated endpoint's ``_iter`` variant.
433
447
 
@@ -435,11 +449,21 @@ def build_standalone_paginated_export_fn(
435
449
  ``page_size``/``max_items`` (forwarded to the iter function), drives the
436
450
  iterator, and pipes items into ``export(...)`` (sync) or
437
451
  ``export_async(...)`` (async). Memory stays bounded by ``batch_size``.
452
+
453
+ The raw OpenAPI ``limit`` parameter (or ``pagination_limit_param``) is
454
+ intentionally excluded: the ``_iter`` function manages page size
455
+ internally via ``page_size`` / ``max_items``.
438
456
  """
457
+ # Strip the raw pagination limit param — the _iter fn handles it internally.
458
+ filtered_parameters = (
459
+ [p for p in parameters if p.name != pagination_limit_param]
460
+ if parameters
461
+ else parameters
462
+ )
439
463
  return _build_export_function(
440
464
  fn_name=fn_name,
441
465
  target_fn_name=target_iter_fn_name,
442
- parameters=parameters,
466
+ parameters=filtered_parameters,
443
467
  request_body_info=request_body_info,
444
468
  item_type_ast=item_type_ast,
445
469
  item_type_imports=item_type_imports or {},
@@ -1019,7 +1019,19 @@ class SplitModuleEmitter:
1019
1019
  if not (self.dataframe_config and self.dataframe_config.enabled):
1020
1020
  return False
1021
1021
 
1022
- df_config = get_dataframe_config_for_endpoint(endpoint, self.dataframe_config)
1022
+ # When response unwrapping is active the unwrapped data type is the
1023
+ # endpoint's real return type, so feed it to list detection -- without
1024
+ # this, non-paginated envelope list endpoints (e.g.
1025
+ # ``ResponseWithStatusEnvelope*``) are misclassified as non-list and
1026
+ # silently lose their DataFrame variants.
1027
+ unwrap_type_ast = None
1028
+ should_unwrap, unwrap_path = self._get_unwrap_config(endpoint)
1029
+ if should_unwrap and unwrap_path:
1030
+ unwrap_type_ast, _ = self._get_unwrapped_type_ast(endpoint, unwrap_path)
1031
+
1032
+ df_config = get_dataframe_config_for_endpoint(
1033
+ endpoint, self.dataframe_config, unwrap_type_ast=unwrap_type_ast
1034
+ )
1023
1035
 
1024
1036
  generated = False
1025
1037
  if df_config.generate_pandas:
@@ -1109,7 +1121,12 @@ class SplitModuleEmitter:
1109
1121
  """Emit sync+async export wrappers for a non-paginated list endpoint."""
1110
1122
  if not self.export_config or not self.export_config.enabled:
1111
1123
  return False
1112
- item_type_ast, item_type_imports = self._get_item_type_ast(endpoint)
1124
+ # When response unwrapping is active, the list lives behind the data
1125
+ # path (e.g. envelope.data) rather than directly on the response type,
1126
+ # so resolve the item type through that path.
1127
+ should_unwrap, unwrap_path = self._get_unwrap_config(endpoint)
1128
+ data_path = unwrap_path if should_unwrap else None
1129
+ item_type_ast, item_type_imports = self._get_item_type_ast(endpoint, data_path)
1113
1130
  if item_type_ast is None:
1114
1131
  return False
1115
1132
  should_generate, formats, _path = (
@@ -1322,6 +1339,18 @@ class SplitModuleEmitter:
1322
1339
  final_body.append(_all(sorted(all_names)))
1323
1340
  final_body.extend(body)
1324
1341
 
1342
+ # Prepend from __future__ import annotations so forward references in
1343
+ # type annotations (e.g. model names imported from .models) resolve
1344
+ # correctly when models contain cyclic references.
1345
+ final_body.insert(
1346
+ 0,
1347
+ ast.ImportFrom(
1348
+ module='__future__',
1349
+ names=[ast.alias(name='annotations')],
1350
+ level=0,
1351
+ ),
1352
+ )
1353
+
1325
1354
  file_path = UPath(file_path)
1326
1355
  file_path.parent.mkdir(parents=True, exist_ok=True)
1327
1356
  write_mod(final_body, file_path)