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.
- {otterapi-0.1.4 → otterapi-0.1.6}/PKG-INFO +1 -1
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/codegen.py +171 -12
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/dataframes.py +45 -10
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/endpoints.py +12 -4
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/export.py +33 -9
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/splitting.py +31 -2
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/types.py +172 -21
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/utils.py +25 -2
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v3/v3.py +21 -2
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/fixtures/golden/discriminator/spec.yaml +2 -0
- otterapi-0.1.6/otterapi/tests/test_codegen_edge_cases.py +170 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_dataframe.py +230 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_discriminated_unions.py +2 -2
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_export.py +84 -8
- otterapi-0.1.6/otterapi/tests/test_recursive_schema.py +243 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/.gitignore +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/README.md +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/__init__.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/__main__.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/cli.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/__init__.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/_body_builder.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/_features.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/_openapi_adapter.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/ast_utils.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/client.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/pagination.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/runtime/__init__.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/runtime/_client_stub.py.tpl +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/runtime/_concurrency.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/runtime/_dataframe.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/runtime/_export.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/runtime/_models_mixin.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/runtime/_pagination.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/runtime/_retry.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/codegen/schema.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/config.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/exceptions.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/__init__.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/constants.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v2/__init__.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v2/spec.json +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v2/v2.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v3/__init__.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v3/spec.json +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v3_1/__init__.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v3_1/spec.json +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v3_1/v3_1.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v3_2/__init__.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v3_2/spec.json +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/v3_2/v3_2.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/openapi/warnings.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/__init__.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/fixtures/__init__.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/fixtures/golden/constraints/spec.yaml +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/fixtures/golden/dataframe/config.yaml +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/fixtures/golden/dataframe/spec.yaml +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/fixtures/golden/paginated/config.yaml +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/fixtures/golden/paginated/spec.yaml +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_ast_utils.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_body_builder.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_client_retry_lifecycle.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_codegen.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_codegen_structure.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_concurrency_runtime.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_config_errors.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_exceptions.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_extra_forbid.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_feature_modules.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_html_repr.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_init_scaffold.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_not_schema.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_nullable_upgrade.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_openapi_adapter.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_openapi_support.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_openapi_upgrade.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_pagination.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_per_status_errors.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_public_api.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_regen_script.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_retry_runtime.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_runtime_guards.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_schema_constraints.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_smoke_generated_client.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_splitting_config.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_splitting_integration.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_splitting_resolver.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/otterapi/tests/test_splitting_tree.py +0 -0
- {otterapi-0.1.4 → otterapi-0.1.6}/pyproject.toml +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1322
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
231
|
-
|
|
232
|
-
|
|
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(
|
|
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
|
|
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 |
|
|
192
|
-
return _union_expr([_name('str'), _name('Path'),
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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)
|