otterapi 0.1.2__tar.gz → 0.1.4__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.2 → otterapi-0.1.4}/PKG-INFO +1 -1
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/_features.py +78 -4
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/ast_utils.py +10 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/client.py +59 -22
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/codegen.py +16 -13
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/endpoints.py +11 -2
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/export.py +2 -2
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/runtime/_export.py +5 -4
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/runtime/_pagination.py +48 -49
- otterapi-0.1.4/otterapi/codegen/runtime/_retry.py +74 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/splitting.py +16 -2
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/types.py +35 -23
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/config.py +20 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/pyproject.toml +2 -0
- otterapi-0.1.2/otterapi/codegen/runtime/_retry.py +0 -33
- {otterapi-0.1.2 → otterapi-0.1.4}/.gitignore +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/README.md +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/__init__.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/__main__.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/cli.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/__init__.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/_body_builder.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/_openapi_adapter.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/dataframes.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/pagination.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/runtime/__init__.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/runtime/_client_stub.py.tpl +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/runtime/_concurrency.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/runtime/_dataframe.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/runtime/_models_mixin.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/schema.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/utils.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/exceptions.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/__init__.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/constants.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v2/__init__.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v2/spec.json +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v2/v2.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v3/__init__.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v3/spec.json +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v3/v3.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v3_1/__init__.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v3_1/spec.json +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v3_1/v3_1.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v3_2/__init__.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v3_2/spec.json +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v3_2/v3_2.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/warnings.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/__init__.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/fixtures/__init__.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/constraints/spec.yaml +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/dataframe/config.yaml +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/dataframe/spec.yaml +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/discriminator/spec.yaml +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/paginated/config.yaml +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/paginated/spec.yaml +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_ast_utils.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_body_builder.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_client_retry_lifecycle.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_codegen.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_codegen_structure.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_concurrency_runtime.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_config_errors.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_dataframe.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_discriminated_unions.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_exceptions.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_export.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_extra_forbid.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_feature_modules.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_html_repr.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_init_scaffold.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_not_schema.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_nullable_upgrade.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_openapi_adapter.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_openapi_support.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_openapi_upgrade.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_pagination.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_per_status_errors.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_public_api.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_regen_script.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_retry_runtime.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_runtime_guards.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_schema_constraints.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_smoke_generated_client.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_splitting_config.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_splitting_integration.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_splitting_resolver.py +0 -0
- {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_splitting_tree.py +0 -0
|
@@ -64,16 +64,44 @@ class FeatureModule(ABC):
|
|
|
64
64
|
.read_text('utf-8')
|
|
65
65
|
)
|
|
66
66
|
|
|
67
|
-
def
|
|
67
|
+
def transform_content(self, content: str, config: DocumentConfig) -> str:
|
|
68
|
+
"""Optionally transform the module source before writing.
|
|
69
|
+
|
|
70
|
+
The default implementation returns *content* unchanged. Subclasses
|
|
71
|
+
override this to perform pydantic-version-specific (or other
|
|
72
|
+
config-driven) rewrites of the static template source.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
content: Raw source text read from ``otterapi.codegen.runtime``.
|
|
76
|
+
config: The active :class:`~otterapi.config.DocumentConfig`.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The (possibly transformed) source text to write to disk.
|
|
80
|
+
"""
|
|
81
|
+
return content
|
|
82
|
+
|
|
83
|
+
def write(
|
|
84
|
+
self, output_dir: Path | UPath, config: DocumentConfig | None = None
|
|
85
|
+
) -> Path | UPath:
|
|
68
86
|
"""Write :pyattr:`module_content` into ``output_dir``.
|
|
69
87
|
|
|
70
88
|
Always opens with UTF-8 encoding; relying on the platform locale
|
|
71
89
|
broke the export module on Windows in PR #2.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
output_dir: Directory where the module file is written.
|
|
93
|
+
config: Optional :class:`~otterapi.config.DocumentConfig` passed to
|
|
94
|
+
:pymeth:`transform_content`. When *None*, no transformation is
|
|
95
|
+
applied (backwards-compatible for callers that do not yet pass
|
|
96
|
+
config, e.g. ``generate_dataframe_module``).
|
|
72
97
|
"""
|
|
73
98
|
output_dir = UPath(output_dir)
|
|
74
99
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
content = self.module_content
|
|
101
|
+
if config is not None:
|
|
102
|
+
content = self.transform_content(content, config)
|
|
75
103
|
target = output_dir / self.module_filename
|
|
76
|
-
target.write_text(
|
|
104
|
+
target.write_text(content, encoding='utf-8')
|
|
77
105
|
return target
|
|
78
106
|
|
|
79
107
|
|
|
@@ -84,7 +112,9 @@ def write_enabled_features(
|
|
|
84
112
|
) -> list[Path | UPath]:
|
|
85
113
|
"""Write every enabled feature's runtime module. Returns the written paths."""
|
|
86
114
|
return [
|
|
87
|
-
feature.write(output_dir
|
|
115
|
+
feature.write(output_dir, config)
|
|
116
|
+
for feature in features
|
|
117
|
+
if feature.is_enabled(config)
|
|
88
118
|
]
|
|
89
119
|
|
|
90
120
|
|
|
@@ -114,7 +144,12 @@ class PaginationFeature(FeatureModule):
|
|
|
114
144
|
|
|
115
145
|
|
|
116
146
|
class DataFrameFeature(FeatureModule):
|
|
117
|
-
"""Emits ``_dataframe.py`` (pandas / polars converters).
|
|
147
|
+
"""Emits ``_dataframe.py`` (pandas / polars converters).
|
|
148
|
+
|
|
149
|
+
When ``config.pydantic_version == 1`` the emitted helper uses ``obj.dict()``
|
|
150
|
+
(the Pydantic v1 serialisation method). For Pydantic v2 (the default) it
|
|
151
|
+
uses ``obj.model_dump()`` and removes the dead v1 fallback branch.
|
|
152
|
+
"""
|
|
118
153
|
|
|
119
154
|
module_filename = '_dataframe.py'
|
|
120
155
|
|
|
@@ -123,6 +158,45 @@ class DataFrameFeature(FeatureModule):
|
|
|
123
158
|
bool(config.dataframe.pandas) or bool(config.dataframe.polars)
|
|
124
159
|
)
|
|
125
160
|
|
|
161
|
+
def transform_content(self, content: str, config: DocumentConfig) -> str:
|
|
162
|
+
"""Rewrite ``_to_dict`` and ``_normalize_data`` for the target Pydantic version."""
|
|
163
|
+
if config.pydantic_version == 1:
|
|
164
|
+
return (
|
|
165
|
+
content.replace(
|
|
166
|
+
# Remove model_dump branch and make dict() the only call path
|
|
167
|
+
"if hasattr(obj, 'model_dump'):\n return obj.model_dump()\n"
|
|
168
|
+
" elif hasattr(obj, 'dict'):\n # Pydantic v1 compatibility\n"
|
|
169
|
+
' return obj.dict()',
|
|
170
|
+
"# Pydantic v1\n if hasattr(obj, 'dict'):\n return obj.dict()",
|
|
171
|
+
)
|
|
172
|
+
.replace(
|
|
173
|
+
# _normalize_data: replace the model_dump / dict check with dict only
|
|
174
|
+
"if hasattr(first, 'model_dump') or hasattr(first, 'dict'):",
|
|
175
|
+
"if hasattr(first, 'dict'):",
|
|
176
|
+
)
|
|
177
|
+
.replace(
|
|
178
|
+
# single-item path
|
|
179
|
+
"if hasattr(data, 'model_dump') or hasattr(data, 'dict'):",
|
|
180
|
+
"if hasattr(data, 'dict'):",
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
# pydantic_version == 2 (default): strip the dead v1 .dict() fallback branch
|
|
184
|
+
return (
|
|
185
|
+
content.replace(
|
|
186
|
+
" elif hasattr(obj, 'dict'):\n # Pydantic v1 compatibility\n"
|
|
187
|
+
' return obj.dict()\n',
|
|
188
|
+
'',
|
|
189
|
+
)
|
|
190
|
+
.replace(
|
|
191
|
+
"if hasattr(first, 'model_dump') or hasattr(first, 'dict'):",
|
|
192
|
+
"if hasattr(first, 'model_dump'):",
|
|
193
|
+
)
|
|
194
|
+
.replace(
|
|
195
|
+
"if hasattr(data, 'model_dump') or hasattr(data, 'dict'):",
|
|
196
|
+
"if hasattr(data, 'model_dump'):",
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
|
|
126
200
|
|
|
127
201
|
class ExportFeature(FeatureModule):
|
|
128
202
|
"""Emits ``_export.py`` (streaming CSV / TSV / JSONL / Parquet writers)."""
|
|
@@ -16,6 +16,7 @@ __all__ = [
|
|
|
16
16
|
'_union_expr',
|
|
17
17
|
'_optional_expr',
|
|
18
18
|
'_argument',
|
|
19
|
+
'_ann_assign',
|
|
19
20
|
'_assign',
|
|
20
21
|
'_import',
|
|
21
22
|
'_call',
|
|
@@ -76,6 +77,15 @@ def _argument(name: str, value: ast.expr | None = None) -> ast.arg:
|
|
|
76
77
|
)
|
|
77
78
|
|
|
78
79
|
|
|
80
|
+
def _ann_assign(
|
|
81
|
+
target: ast.expr, annotation: ast.expr, value: ast.expr
|
|
82
|
+
) -> ast.AnnAssign:
|
|
83
|
+
"""Build ``target: annotation = value``."""
|
|
84
|
+
if isinstance(target, ast.Name):
|
|
85
|
+
target = ast.Name(id=target.id, ctx=ast.Store())
|
|
86
|
+
return ast.AnnAssign(target=target, annotation=annotation, value=value, simple=1)
|
|
87
|
+
|
|
88
|
+
|
|
79
89
|
def _assign(target: ast.expr, value: ast.expr) -> ast.Assign:
|
|
80
90
|
# Ensure target has Store context
|
|
81
91
|
if isinstance(target, ast.Name):
|
|
@@ -652,6 +652,7 @@ def generate_base_client_class(
|
|
|
652
652
|
class_name: str,
|
|
653
653
|
default_base_url: str,
|
|
654
654
|
default_timeout: float = 30.0,
|
|
655
|
+
pydantic_version: int = 2,
|
|
655
656
|
) -> tuple[ast.ClassDef, ImportDict]:
|
|
656
657
|
"""Generate a BaseClient class with only request infrastructure.
|
|
657
658
|
|
|
@@ -662,17 +663,28 @@ def generate_base_client_class(
|
|
|
662
663
|
class_name: Name for the generated class (e.g., 'BasePetStoreClient').
|
|
663
664
|
default_base_url: Default base URL from the OpenAPI spec.
|
|
664
665
|
default_timeout: Default request timeout in seconds.
|
|
666
|
+
pydantic_version: Target Pydantic version (1 or 2). Affects which
|
|
667
|
+
response-unwrapping pattern is emitted in ``_parse_response``.
|
|
665
668
|
|
|
666
669
|
Returns:
|
|
667
670
|
Tuple of (class AST node, required imports).
|
|
668
671
|
"""
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
672
|
+
if pydantic_version == 1:
|
|
673
|
+
imports: ImportDict = {
|
|
674
|
+
'httpx': {'Client', 'AsyncClient', 'Response', 'TransportError'},
|
|
675
|
+
'typing': {'Any'},
|
|
676
|
+
'types': {'UnionType'},
|
|
677
|
+
'pydantic': {'TypeAdapter'},
|
|
678
|
+
'._retry': {'_backoff_sleep', '_backoff_sleep_async'},
|
|
679
|
+
}
|
|
680
|
+
else:
|
|
681
|
+
imports = {
|
|
682
|
+
'httpx': {'Client', 'AsyncClient', 'Response', 'TransportError'},
|
|
683
|
+
'typing': {'Any'},
|
|
684
|
+
'types': {'UnionType'},
|
|
685
|
+
'pydantic': {'TypeAdapter', 'RootModel'},
|
|
686
|
+
'._retry': {'_backoff_sleep', '_backoff_sleep_async'},
|
|
687
|
+
}
|
|
676
688
|
|
|
677
689
|
# Build __init__ method
|
|
678
690
|
init_method = _build_init_method(default_base_url, default_timeout)
|
|
@@ -696,10 +708,14 @@ def generate_base_client_class(
|
|
|
696
708
|
validate_response_method = _build_validate_response_method()
|
|
697
709
|
|
|
698
710
|
# Build _parse_response method (sync)
|
|
699
|
-
parse_response_method = _build_parse_response_method(
|
|
711
|
+
parse_response_method = _build_parse_response_method(
|
|
712
|
+
is_async=False, pydantic_version=pydantic_version
|
|
713
|
+
)
|
|
700
714
|
|
|
701
715
|
# Build _parse_response_async method (async)
|
|
702
|
-
async_parse_response_method = _build_parse_response_method(
|
|
716
|
+
async_parse_response_method = _build_parse_response_method(
|
|
717
|
+
is_async=True, pydantic_version=pydantic_version
|
|
718
|
+
)
|
|
703
719
|
|
|
704
720
|
# Build class body
|
|
705
721
|
class_body: list[ast.stmt] = [
|
|
@@ -1049,10 +1065,19 @@ def _build_validate_response_method() -> ast.FunctionDef:
|
|
|
1049
1065
|
|
|
1050
1066
|
def _build_parse_response_method(
|
|
1051
1067
|
is_async: bool,
|
|
1068
|
+
pydantic_version: int = 2,
|
|
1052
1069
|
) -> ast.FunctionDef | ast.AsyncFunctionDef:
|
|
1053
1070
|
"""Build the _parse_response or _parse_response_async method.
|
|
1054
1071
|
|
|
1055
1072
|
This method handles JSON parsing and Pydantic validation of responses.
|
|
1073
|
+
|
|
1074
|
+
Args:
|
|
1075
|
+
is_async: Whether to generate the async variant.
|
|
1076
|
+
pydantic_version: Target Pydantic version (1 or 2). For v2, the method
|
|
1077
|
+
emits an ``isinstance(validated, RootModel)`` check and returns
|
|
1078
|
+
``validated.root``. For v1, RootModel does not exist; instead the
|
|
1079
|
+
method checks ``hasattr(validated, '__root__')`` and returns
|
|
1080
|
+
``validated.__root__``.
|
|
1056
1081
|
"""
|
|
1057
1082
|
method_name = '_parse_response_async' if is_async else '_parse_response'
|
|
1058
1083
|
|
|
@@ -1073,14 +1098,8 @@ def _build_parse_response_method(
|
|
|
1073
1098
|
defaults=[],
|
|
1074
1099
|
)
|
|
1075
1100
|
|
|
1076
|
-
#
|
|
1077
|
-
|
|
1078
|
-
# validated = TypeAdapter(response_type).validate_python(data)
|
|
1079
|
-
# self._validate_response(response, validated)
|
|
1080
|
-
# if isinstance(validated, RootModel):
|
|
1081
|
-
# return validated.root
|
|
1082
|
-
# return validated
|
|
1083
|
-
body: list[ast.stmt] = [
|
|
1101
|
+
# Common statements: parse JSON, validate with TypeAdapter, call hook
|
|
1102
|
+
common: list[ast.stmt] = [
|
|
1084
1103
|
# data = response.json()
|
|
1085
1104
|
_assign(
|
|
1086
1105
|
_name('data'),
|
|
@@ -1107,15 +1126,33 @@ def _build_parse_response_method(
|
|
|
1107
1126
|
args=[_name('response'), _name('validated')],
|
|
1108
1127
|
)
|
|
1109
1128
|
),
|
|
1110
|
-
|
|
1111
|
-
|
|
1129
|
+
]
|
|
1130
|
+
|
|
1131
|
+
if pydantic_version == 1:
|
|
1132
|
+
# Pydantic v1: RootModel does not exist; use hasattr(validated, '__root__')
|
|
1133
|
+
# if hasattr(validated, '__root__'): return validated.__root__
|
|
1134
|
+
unwrap_stmt = ast.If(
|
|
1135
|
+
test=_call(
|
|
1136
|
+
func=_name('hasattr'),
|
|
1137
|
+
args=[_name('validated'), ast.Constant(value='__root__')],
|
|
1138
|
+
),
|
|
1139
|
+
body=[ast.Return(value=_attr('validated', '__root__'))],
|
|
1140
|
+
orelse=[],
|
|
1141
|
+
)
|
|
1142
|
+
else:
|
|
1143
|
+
# Pydantic v2: if isinstance(validated, RootModel): return validated.root
|
|
1144
|
+
unwrap_stmt = ast.If(
|
|
1112
1145
|
test=_call(
|
|
1113
1146
|
func=_name('isinstance'),
|
|
1114
1147
|
args=[_name('validated'), _name('RootModel')],
|
|
1115
1148
|
),
|
|
1116
1149
|
body=[ast.Return(value=_attr('validated', 'root'))],
|
|
1117
1150
|
orelse=[],
|
|
1118
|
-
)
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
body: list[ast.stmt] = [
|
|
1154
|
+
*common,
|
|
1155
|
+
unwrap_stmt,
|
|
1119
1156
|
# return validated
|
|
1120
1157
|
ast.Return(value=_name('validated')),
|
|
1121
1158
|
]
|
|
@@ -1126,7 +1163,7 @@ def _build_parse_response_method(
|
|
|
1126
1163
|
args=args,
|
|
1127
1164
|
body=body,
|
|
1128
1165
|
decorator_list=[],
|
|
1129
|
-
returns=_name('
|
|
1166
|
+
returns=_name('Any'),
|
|
1130
1167
|
)
|
|
1131
1168
|
else:
|
|
1132
1169
|
return ast.FunctionDef(
|
|
@@ -1134,7 +1171,7 @@ def _build_parse_response_method(
|
|
|
1134
1171
|
args=args,
|
|
1135
1172
|
body=body,
|
|
1136
1173
|
decorator_list=[],
|
|
1137
|
-
returns=_name('
|
|
1174
|
+
returns=_name('Any'),
|
|
1138
1175
|
)
|
|
1139
1176
|
|
|
1140
1177
|
|
|
@@ -19,7 +19,6 @@ from otterapi.codegen.ast_utils import (
|
|
|
19
19
|
ImportCollector,
|
|
20
20
|
_all,
|
|
21
21
|
_assign,
|
|
22
|
-
_call,
|
|
23
22
|
_name,
|
|
24
23
|
_union_expr,
|
|
25
24
|
)
|
|
@@ -159,7 +158,9 @@ class Codegen(OpenAPIProcessor):
|
|
|
159
158
|
SchemaValidationError: If the schema is not valid OpenAPI.
|
|
160
159
|
"""
|
|
161
160
|
self.openapi = self._schema_loader.load(self.config.source)
|
|
162
|
-
self.typegen = TypeGenerator(
|
|
161
|
+
self.typegen = TypeGenerator(
|
|
162
|
+
self.openapi, pydantic_version=self.config.pydantic_version
|
|
163
|
+
)
|
|
163
164
|
|
|
164
165
|
def _extract_response_info(self, operation: Operation) -> dict[int, ResponseInfo]:
|
|
165
166
|
"""Extract response information including content type from an operation.
|
|
@@ -1278,7 +1279,17 @@ class Codegen(OpenAPIProcessor):
|
|
|
1278
1279
|
|
|
1279
1280
|
all_names: set[str] = set(endpoint_names)
|
|
1280
1281
|
if self.config.reexport_models:
|
|
1281
|
-
|
|
1282
|
+
model_names = import_collector._imports.get(MODELS_MODULE, set())
|
|
1283
|
+
if self.config.reexport_model_exclude_patterns:
|
|
1284
|
+
model_names = {
|
|
1285
|
+
n
|
|
1286
|
+
for n in model_names
|
|
1287
|
+
if not any(
|
|
1288
|
+
fnmatch.fnmatch(n, pat)
|
|
1289
|
+
for pat in self.config.reexport_model_exclude_patterns
|
|
1290
|
+
)
|
|
1291
|
+
}
|
|
1292
|
+
all_names |= model_names
|
|
1282
1293
|
final_body.append(_all(sorted(all_names)))
|
|
1283
1294
|
final_body.extend(body)
|
|
1284
1295
|
|
|
@@ -1584,6 +1595,7 @@ class Codegen(OpenAPIProcessor):
|
|
|
1584
1595
|
response_unwrap_config=self.config.response_unwrap,
|
|
1585
1596
|
export_config=self.config.export,
|
|
1586
1597
|
reexport_models=self.config.reexport_models,
|
|
1598
|
+
reexport_model_exclude_patterns=self.config.reexport_model_exclude_patterns,
|
|
1587
1599
|
)
|
|
1588
1600
|
|
|
1589
1601
|
emitted = emitter.emit(
|
|
@@ -1653,6 +1665,7 @@ class Codegen(OpenAPIProcessor):
|
|
|
1653
1665
|
class_name=base_client_name,
|
|
1654
1666
|
default_base_url=base_url,
|
|
1655
1667
|
default_timeout=30.0,
|
|
1668
|
+
pydantic_version=self.config.pydantic_version,
|
|
1656
1669
|
)
|
|
1657
1670
|
|
|
1658
1671
|
# Build the _client.py file
|
|
@@ -1666,16 +1679,6 @@ class Codegen(OpenAPIProcessor):
|
|
|
1666
1679
|
for import_stmt in import_collector.to_ast():
|
|
1667
1680
|
body.insert(0, import_stmt)
|
|
1668
1681
|
|
|
1669
|
-
# Add TypeVar definition: T = TypeVar('T')
|
|
1670
|
-
typevar_def = _assign(
|
|
1671
|
-
_name('T'),
|
|
1672
|
-
_call(
|
|
1673
|
-
func=_name('TypeVar'),
|
|
1674
|
-
args=[ast.Constant(value='T')],
|
|
1675
|
-
),
|
|
1676
|
-
)
|
|
1677
|
-
body.append(typevar_def)
|
|
1678
|
-
|
|
1679
1682
|
# Add __all__ export (include the full per-status error hierarchy
|
|
1680
1683
|
# so users can ``from .<pkg>._client import NotFoundError`` etc.).
|
|
1681
1684
|
body.append(_all(sorted([base_client_name, *_exported_error_names()])))
|
|
@@ -227,7 +227,13 @@ class FunctionSignatureBuilder:
|
|
|
227
227
|
if param.required:
|
|
228
228
|
self._args.append(arg)
|
|
229
229
|
else:
|
|
230
|
-
|
|
230
|
+
# Wrap annotation in `| None` so the `= None` default is type-safe.
|
|
231
|
+
optional_annotation = _union_expr(
|
|
232
|
+
[annotation, ast.Constant(value=None)]
|
|
233
|
+
)
|
|
234
|
+
self._kwonlyargs.append(
|
|
235
|
+
_argument(param.name_sanitized, optional_annotation)
|
|
236
|
+
)
|
|
231
237
|
self._kw_defaults.append(ast.Constant(value=None))
|
|
232
238
|
|
|
233
239
|
return self
|
|
@@ -262,7 +268,10 @@ class FunctionSignatureBuilder:
|
|
|
262
268
|
if body.required:
|
|
263
269
|
self._args.append(body_arg)
|
|
264
270
|
else:
|
|
265
|
-
|
|
271
|
+
optional_body_annotation = _union_expr(
|
|
272
|
+
[body_annotation, ast.Constant(value=None)]
|
|
273
|
+
)
|
|
274
|
+
self._kwonlyargs.append(_argument('body', optional_body_annotation))
|
|
266
275
|
self._kw_defaults.append(ast.Constant(value=None))
|
|
267
276
|
|
|
268
277
|
return self
|
|
@@ -188,8 +188,8 @@ def _format_literal_annotation() -> ast.expr:
|
|
|
188
188
|
|
|
189
189
|
|
|
190
190
|
def _output_path_annotation() -> ast.expr:
|
|
191
|
-
"""Build the AST for ``str | Path`` (the wrapper's path argument type)."""
|
|
192
|
-
return _union_expr([_name('str'), _name('Path')])
|
|
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)])
|
|
193
193
|
|
|
194
194
|
|
|
195
195
|
def _add_export_kwonly_args(
|
|
@@ -23,7 +23,7 @@ from datetime import date, datetime, time
|
|
|
23
23
|
from decimal import Decimal
|
|
24
24
|
from enum import Enum
|
|
25
25
|
from pathlib import Path
|
|
26
|
-
from typing import TYPE_CHECKING, Annotated, Any, Union, get_args, get_origin
|
|
26
|
+
from typing import TYPE_CHECKING, Annotated, Any, TypeAlias, Union, get_args, get_origin
|
|
27
27
|
from uuid import UUID
|
|
28
28
|
|
|
29
29
|
from pydantic import BaseModel
|
|
@@ -32,8 +32,8 @@ from upath import UPath
|
|
|
32
32
|
if TYPE_CHECKING:
|
|
33
33
|
import pyarrow as pa
|
|
34
34
|
|
|
35
|
-
Row = BaseModel | dict
|
|
36
|
-
PathLike = str | Path | UPath
|
|
35
|
+
Row: TypeAlias = BaseModel | dict
|
|
36
|
+
PathLike: TypeAlias = str | Path | UPath
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
# -----------------------------------------------------------------------------
|
|
@@ -378,6 +378,7 @@ def _python_type_to_arrow(annotation: Any, pa) -> pa.DataType:
|
|
|
378
378
|
origin = get_origin(inner)
|
|
379
379
|
|
|
380
380
|
if isinstance(inner, type) and issubclass(inner, BaseModel):
|
|
381
|
+
model_cls: type[BaseModel] = inner
|
|
381
382
|
return pa.struct(
|
|
382
383
|
[
|
|
383
384
|
pa.field(
|
|
@@ -385,7 +386,7 @@ def _python_type_to_arrow(annotation: Any, pa) -> pa.DataType:
|
|
|
385
386
|
_python_type_to_arrow(field.annotation, pa),
|
|
386
387
|
nullable=_is_nullable(field.annotation),
|
|
387
388
|
)
|
|
388
|
-
for name, field in
|
|
389
|
+
for name, field in model_cls.model_fields.items()
|
|
389
390
|
]
|
|
390
391
|
)
|
|
391
392
|
|
|
@@ -6,7 +6,6 @@ from dataclasses import dataclass
|
|
|
6
6
|
from typing import Any, TypeVar
|
|
7
7
|
|
|
8
8
|
T = TypeVar('T')
|
|
9
|
-
PageT = TypeVar('PageT')
|
|
10
9
|
|
|
11
10
|
|
|
12
11
|
@dataclass
|
|
@@ -118,14 +117,14 @@ def _progress_context(
|
|
|
118
117
|
|
|
119
118
|
|
|
120
119
|
def paginate_offset(
|
|
121
|
-
fetch_page: Callable[[int, int],
|
|
122
|
-
extract_items: Callable[[
|
|
123
|
-
get_total: Callable[[
|
|
120
|
+
fetch_page: Callable[[int, int], Any],
|
|
121
|
+
extract_items: Callable[[Any], list[T]],
|
|
122
|
+
get_total: Callable[[Any], int | None] | None = None,
|
|
124
123
|
*,
|
|
125
124
|
start_offset: int = 0,
|
|
126
125
|
page_size: int = 100,
|
|
127
126
|
max_items: int | None = None,
|
|
128
|
-
progress: bool | str =
|
|
127
|
+
progress: bool | str = True,
|
|
129
128
|
) -> list[T]:
|
|
130
129
|
"""Generic offset-based pagination that returns all items.
|
|
131
130
|
|
|
@@ -182,14 +181,14 @@ def paginate_offset(
|
|
|
182
181
|
|
|
183
182
|
|
|
184
183
|
async def paginate_offset_async(
|
|
185
|
-
fetch_page: Callable[[int, int], Any], # Returns Awaitable[
|
|
186
|
-
extract_items: Callable[[
|
|
187
|
-
get_total: Callable[[
|
|
184
|
+
fetch_page: Callable[[int, int], Any], # Returns Awaitable[Any]
|
|
185
|
+
extract_items: Callable[[Any], list[T]],
|
|
186
|
+
get_total: Callable[[Any], int | None] | None = None,
|
|
188
187
|
*,
|
|
189
188
|
start_offset: int = 0,
|
|
190
189
|
page_size: int = 100,
|
|
191
190
|
max_items: int | None = None,
|
|
192
|
-
progress: bool | str =
|
|
191
|
+
progress: bool | str = True,
|
|
193
192
|
) -> list[T]:
|
|
194
193
|
"""Async version of paginate_offset."""
|
|
195
194
|
all_items: list[T] = []
|
|
@@ -244,14 +243,14 @@ async def paginate_offset_async(
|
|
|
244
243
|
|
|
245
244
|
|
|
246
245
|
def iterate_offset(
|
|
247
|
-
fetch_page: Callable[[int, int],
|
|
248
|
-
extract_items: Callable[[
|
|
249
|
-
get_total: Callable[[
|
|
246
|
+
fetch_page: Callable[[int, int], Any],
|
|
247
|
+
extract_items: Callable[[Any], list[T]],
|
|
248
|
+
get_total: Callable[[Any], int | None] | None = None,
|
|
250
249
|
*,
|
|
251
250
|
start_offset: int = 0,
|
|
252
251
|
page_size: int = 100,
|
|
253
252
|
max_items: int | None = None,
|
|
254
|
-
progress: bool | str =
|
|
253
|
+
progress: bool | str = True,
|
|
255
254
|
) -> Iterator[T]:
|
|
256
255
|
"""Generic offset-based pagination iterator (streaming).
|
|
257
256
|
|
|
@@ -309,14 +308,14 @@ def iterate_offset(
|
|
|
309
308
|
|
|
310
309
|
|
|
311
310
|
async def iterate_offset_async(
|
|
312
|
-
fetch_page: Callable[[int, int], Any], # Returns Awaitable[
|
|
313
|
-
extract_items: Callable[[
|
|
314
|
-
get_total: Callable[[
|
|
311
|
+
fetch_page: Callable[[int, int], Any], # Returns Awaitable[Any]
|
|
312
|
+
extract_items: Callable[[Any], list[T]],
|
|
313
|
+
get_total: Callable[[Any], int | None] | None = None,
|
|
315
314
|
*,
|
|
316
315
|
start_offset: int = 0,
|
|
317
316
|
page_size: int = 100,
|
|
318
317
|
max_items: int | None = None,
|
|
319
|
-
progress: bool | str =
|
|
318
|
+
progress: bool | str = True,
|
|
320
319
|
) -> AsyncIterator[T]:
|
|
321
320
|
"""Async version of iterate_offset."""
|
|
322
321
|
current_offset = start_offset
|
|
@@ -376,14 +375,14 @@ async def iterate_offset_async(
|
|
|
376
375
|
|
|
377
376
|
|
|
378
377
|
def paginate_cursor(
|
|
379
|
-
fetch_page: Callable[[str | None, int],
|
|
380
|
-
extract_items: Callable[[
|
|
381
|
-
get_next_cursor: Callable[[
|
|
378
|
+
fetch_page: Callable[[str | None, int], Any],
|
|
379
|
+
extract_items: Callable[[Any], list[T]],
|
|
380
|
+
get_next_cursor: Callable[[Any], str | None],
|
|
382
381
|
*,
|
|
383
382
|
start_cursor: str | None = None,
|
|
384
383
|
page_size: int = 100,
|
|
385
384
|
max_items: int | None = None,
|
|
386
|
-
progress: bool | str =
|
|
385
|
+
progress: bool | str = True,
|
|
387
386
|
) -> list[T]:
|
|
388
387
|
"""Generic cursor-based pagination that returns all items.
|
|
389
388
|
|
|
@@ -431,14 +430,14 @@ def paginate_cursor(
|
|
|
431
430
|
|
|
432
431
|
|
|
433
432
|
async def paginate_cursor_async(
|
|
434
|
-
fetch_page: Callable[[str | None, int], Any], # Returns Awaitable[
|
|
435
|
-
extract_items: Callable[[
|
|
436
|
-
get_next_cursor: Callable[[
|
|
433
|
+
fetch_page: Callable[[str | None, int], Any], # Returns Awaitable[Any]
|
|
434
|
+
extract_items: Callable[[Any], list[T]],
|
|
435
|
+
get_next_cursor: Callable[[Any], str | None],
|
|
437
436
|
*,
|
|
438
437
|
start_cursor: str | None = None,
|
|
439
438
|
page_size: int = 100,
|
|
440
439
|
max_items: int | None = None,
|
|
441
|
-
progress: bool | str =
|
|
440
|
+
progress: bool | str = True,
|
|
442
441
|
) -> list[T]:
|
|
443
442
|
"""Async version of paginate_cursor."""
|
|
444
443
|
all_items: list[T] = []
|
|
@@ -485,14 +484,14 @@ async def paginate_cursor_async(
|
|
|
485
484
|
|
|
486
485
|
|
|
487
486
|
def iterate_cursor(
|
|
488
|
-
fetch_page: Callable[[str | None, int],
|
|
489
|
-
extract_items: Callable[[
|
|
490
|
-
get_next_cursor: Callable[[
|
|
487
|
+
fetch_page: Callable[[str | None, int], Any],
|
|
488
|
+
extract_items: Callable[[Any], list[T]],
|
|
489
|
+
get_next_cursor: Callable[[Any], str | None],
|
|
491
490
|
*,
|
|
492
491
|
start_cursor: str | None = None,
|
|
493
492
|
page_size: int = 100,
|
|
494
493
|
max_items: int | None = None,
|
|
495
|
-
progress: bool | str =
|
|
494
|
+
progress: bool | str = True,
|
|
496
495
|
) -> Iterator[T]:
|
|
497
496
|
"""Generic cursor-based pagination iterator (streaming).
|
|
498
497
|
|
|
@@ -537,14 +536,14 @@ def iterate_cursor(
|
|
|
537
536
|
|
|
538
537
|
|
|
539
538
|
async def iterate_cursor_async(
|
|
540
|
-
fetch_page: Callable[[str | None, int], Any], # Returns Awaitable[
|
|
541
|
-
extract_items: Callable[[
|
|
542
|
-
get_next_cursor: Callable[[
|
|
539
|
+
fetch_page: Callable[[str | None, int], Any], # Returns Awaitable[Any]
|
|
540
|
+
extract_items: Callable[[Any], list[T]],
|
|
541
|
+
get_next_cursor: Callable[[Any], str | None],
|
|
543
542
|
*,
|
|
544
543
|
start_cursor: str | None = None,
|
|
545
544
|
page_size: int = 100,
|
|
546
545
|
max_items: int | None = None,
|
|
547
|
-
progress: bool | str =
|
|
546
|
+
progress: bool | str = True,
|
|
548
547
|
) -> AsyncIterator[T]:
|
|
549
548
|
"""Async version of iterate_cursor."""
|
|
550
549
|
current_cursor = start_cursor
|
|
@@ -593,15 +592,15 @@ async def iterate_cursor_async(
|
|
|
593
592
|
|
|
594
593
|
|
|
595
594
|
def iterate_page(
|
|
596
|
-
fetch_page: Callable[[int, int],
|
|
597
|
-
extract_items: Callable[[
|
|
598
|
-
get_total_pages: Callable[[
|
|
595
|
+
fetch_page: Callable[[int, int], Any],
|
|
596
|
+
extract_items: Callable[[Any], list[T]],
|
|
597
|
+
get_total_pages: Callable[[Any], int | None] | None = None,
|
|
599
598
|
*,
|
|
600
599
|
start_page: int = 1,
|
|
601
600
|
page_size: int = 100,
|
|
602
601
|
max_items: int | None = None,
|
|
603
602
|
max_pages: int | None = None,
|
|
604
|
-
progress: bool | str =
|
|
603
|
+
progress: bool | str = True,
|
|
605
604
|
) -> Iterator[T]:
|
|
606
605
|
"""Generic page-based pagination iterator (streaming).
|
|
607
606
|
|
|
@@ -660,15 +659,15 @@ def iterate_page(
|
|
|
660
659
|
|
|
661
660
|
|
|
662
661
|
async def iterate_page_async(
|
|
663
|
-
fetch_page: Callable[[int, int], Any], # Returns Awaitable[
|
|
664
|
-
extract_items: Callable[[
|
|
665
|
-
get_total_pages: Callable[[
|
|
662
|
+
fetch_page: Callable[[int, int], Any], # Returns Awaitable[Any]
|
|
663
|
+
extract_items: Callable[[Any], list[T]],
|
|
664
|
+
get_total_pages: Callable[[Any], int | None] | None = None,
|
|
666
665
|
*,
|
|
667
666
|
start_page: int = 1,
|
|
668
667
|
page_size: int = 100,
|
|
669
668
|
max_items: int | None = None,
|
|
670
669
|
max_pages: int | None = None,
|
|
671
|
-
progress: bool | str =
|
|
670
|
+
progress: bool | str = True,
|
|
672
671
|
) -> AsyncIterator[T]:
|
|
673
672
|
"""Async version of iterate_page."""
|
|
674
673
|
current_page = start_page
|
|
@@ -723,15 +722,15 @@ async def iterate_page_async(
|
|
|
723
722
|
|
|
724
723
|
|
|
725
724
|
def paginate_page(
|
|
726
|
-
fetch_page: Callable[[int, int],
|
|
727
|
-
extract_items: Callable[[
|
|
728
|
-
get_total_pages: Callable[[
|
|
725
|
+
fetch_page: Callable[[int, int], Any],
|
|
726
|
+
extract_items: Callable[[Any], list[T]],
|
|
727
|
+
get_total_pages: Callable[[Any], int | None] | None = None,
|
|
729
728
|
*,
|
|
730
729
|
start_page: int = 1,
|
|
731
730
|
page_size: int = 100,
|
|
732
731
|
max_items: int | None = None,
|
|
733
732
|
max_pages: int | None = None,
|
|
734
|
-
progress: bool | str =
|
|
733
|
+
progress: bool | str = True,
|
|
735
734
|
) -> list[T]:
|
|
736
735
|
"""Generic page-based pagination that returns all items.
|
|
737
736
|
|
|
@@ -788,15 +787,15 @@ def paginate_page(
|
|
|
788
787
|
|
|
789
788
|
|
|
790
789
|
async def paginate_page_async(
|
|
791
|
-
fetch_page: Callable[[int, int], Any], # Returns Awaitable[
|
|
792
|
-
extract_items: Callable[[
|
|
793
|
-
get_total_pages: Callable[[
|
|
790
|
+
fetch_page: Callable[[int, int], Any], # Returns Awaitable[Any]
|
|
791
|
+
extract_items: Callable[[Any], list[T]],
|
|
792
|
+
get_total_pages: Callable[[Any], int | None] | None = None,
|
|
794
793
|
*,
|
|
795
794
|
start_page: int = 1,
|
|
796
795
|
page_size: int = 100,
|
|
797
796
|
max_items: int | None = None,
|
|
798
797
|
max_pages: int | None = None,
|
|
799
|
-
progress: bool | str =
|
|
798
|
+
progress: bool | str = True,
|
|
800
799
|
) -> list[T]:
|
|
801
800
|
"""Async version of paginate_page."""
|
|
802
801
|
all_items: list[T] = []
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Retry and backoff helpers for OtterAPI generated clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _backoff_sleep(attempt: int, factor: float, response: Any) -> None:
|
|
14
|
+
"""Sleep before a retry, honouring the Retry-After header when present."""
|
|
15
|
+
status = getattr(response, 'status_code', None) if response is not None else None
|
|
16
|
+
if response is not None:
|
|
17
|
+
retry_after = response.headers.get('Retry-After')
|
|
18
|
+
if retry_after is not None:
|
|
19
|
+
try:
|
|
20
|
+
delay = float(retry_after)
|
|
21
|
+
logger.warning(
|
|
22
|
+
'Retry-After header present; sleeping %.1fs before retry attempt %d '
|
|
23
|
+
'(status=%s)',
|
|
24
|
+
delay,
|
|
25
|
+
attempt + 1,
|
|
26
|
+
status,
|
|
27
|
+
)
|
|
28
|
+
time.sleep(delay)
|
|
29
|
+
return
|
|
30
|
+
except ValueError:
|
|
31
|
+
logger.debug(
|
|
32
|
+
'Unrecognised Retry-After value %r; falling back to exponential backoff',
|
|
33
|
+
retry_after,
|
|
34
|
+
)
|
|
35
|
+
delay = min(factor * (2**attempt), 60.0)
|
|
36
|
+
logger.debug(
|
|
37
|
+
'Retry attempt %d: sleeping %.1fs (status=%s)',
|
|
38
|
+
attempt + 1,
|
|
39
|
+
delay,
|
|
40
|
+
status,
|
|
41
|
+
)
|
|
42
|
+
time.sleep(delay)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def _backoff_sleep_async(attempt: int, factor: float, response: Any) -> None:
|
|
46
|
+
"""Async variant of :func:`_backoff_sleep`."""
|
|
47
|
+
status = getattr(response, 'status_code', None) if response is not None else None
|
|
48
|
+
if response is not None:
|
|
49
|
+
retry_after = response.headers.get('Retry-After')
|
|
50
|
+
if retry_after is not None:
|
|
51
|
+
try:
|
|
52
|
+
delay = float(retry_after)
|
|
53
|
+
logger.warning(
|
|
54
|
+
'Retry-After header present; sleeping %.1fs before retry attempt %d '
|
|
55
|
+
'(status=%s)',
|
|
56
|
+
delay,
|
|
57
|
+
attempt + 1,
|
|
58
|
+
status,
|
|
59
|
+
)
|
|
60
|
+
await asyncio.sleep(delay)
|
|
61
|
+
return
|
|
62
|
+
except ValueError:
|
|
63
|
+
logger.debug(
|
|
64
|
+
'Unrecognised Retry-After value %r; falling back to exponential backoff',
|
|
65
|
+
retry_after,
|
|
66
|
+
)
|
|
67
|
+
delay = min(factor * (2**attempt), 60.0)
|
|
68
|
+
logger.debug(
|
|
69
|
+
'Retry attempt %d: sleeping %.1fs (status=%s)',
|
|
70
|
+
attempt + 1,
|
|
71
|
+
delay,
|
|
72
|
+
status,
|
|
73
|
+
)
|
|
74
|
+
await asyncio.sleep(delay)
|
|
@@ -608,6 +608,7 @@ class SplitModuleEmitter:
|
|
|
608
608
|
response_unwrap_config: ResponseUnwrapConfig | None = None,
|
|
609
609
|
export_config: ExportConfig | None = None,
|
|
610
610
|
reexport_models: bool = False,
|
|
611
|
+
reexport_model_exclude_patterns: list[str] | None = None,
|
|
611
612
|
):
|
|
612
613
|
"""Initialize the split module emitter.
|
|
613
614
|
|
|
@@ -622,6 +623,7 @@ class SplitModuleEmitter:
|
|
|
622
623
|
response_unwrap_config: Optional response unwrap configuration.
|
|
623
624
|
export_config: Optional export configuration.
|
|
624
625
|
reexport_models: Whether to include model names in __all__.
|
|
626
|
+
reexport_model_exclude_patterns: Glob patterns of model names to exclude.
|
|
625
627
|
"""
|
|
626
628
|
self.config = config
|
|
627
629
|
self.output_dir = UPath(output_dir)
|
|
@@ -633,6 +635,9 @@ class SplitModuleEmitter:
|
|
|
633
635
|
self.response_unwrap_config = response_unwrap_config
|
|
634
636
|
self.export_config = export_config
|
|
635
637
|
self.reexport_models = reexport_models
|
|
638
|
+
self.reexport_model_exclude_patterns: list[str] = (
|
|
639
|
+
reexport_model_exclude_patterns or []
|
|
640
|
+
)
|
|
636
641
|
self._emitted_modules: list[EmittedModule] = []
|
|
637
642
|
self._typegen_types: dict[str, Type] = {}
|
|
638
643
|
|
|
@@ -1302,8 +1307,17 @@ class SplitModuleEmitter:
|
|
|
1302
1307
|
|
|
1303
1308
|
all_names = list(endpoint_names)
|
|
1304
1309
|
if self.reexport_models:
|
|
1305
|
-
model_names =
|
|
1306
|
-
|
|
1310
|
+
model_names = import_collector._imports.get(MODELS_MODULE, set())
|
|
1311
|
+
if self.reexport_model_exclude_patterns:
|
|
1312
|
+
model_names = {
|
|
1313
|
+
n
|
|
1314
|
+
for n in model_names
|
|
1315
|
+
if not any(
|
|
1316
|
+
fnmatch.fnmatch(n, pat)
|
|
1317
|
+
for pat in self.reexport_model_exclude_patterns
|
|
1318
|
+
)
|
|
1319
|
+
}
|
|
1320
|
+
all_names = sorted(set(all_names) | model_names)
|
|
1307
1321
|
|
|
1308
1322
|
final_body.append(_all(sorted(all_names)))
|
|
1309
1323
|
final_body.extend(body)
|
|
@@ -402,6 +402,7 @@ def _binop_includes_none(node: ast.expr) -> bool:
|
|
|
402
402
|
@dataclasses.dataclass
|
|
403
403
|
class TypeGenerator(OpenAPIProcessor):
|
|
404
404
|
types: dict[str, Type] = dataclasses.field(default_factory=dict)
|
|
405
|
+
pydantic_version: int = 2
|
|
405
406
|
|
|
406
407
|
@staticmethod
|
|
407
408
|
def _rename_type(type_: Type, new_name: str) -> None:
|
|
@@ -770,9 +771,7 @@ class TypeGenerator(OpenAPIProcessor):
|
|
|
770
771
|
keywords=field_keywords,
|
|
771
772
|
)
|
|
772
773
|
|
|
773
|
-
field_type.add_implementation_import(
|
|
774
|
-
module=Field.__module__, name=Field.__name__
|
|
775
|
-
)
|
|
774
|
+
field_type.add_implementation_import(module='pydantic', name=Field.__name__)
|
|
776
775
|
|
|
777
776
|
return ast.AnnAssign(
|
|
778
777
|
target=_name(field_name),
|
|
@@ -853,7 +852,7 @@ class TypeGenerator(OpenAPIProcessor):
|
|
|
853
852
|
ctx=ast.Load(),
|
|
854
853
|
)
|
|
855
854
|
extra_imports['typing'] = {'Annotated'}
|
|
856
|
-
extra_imports.setdefault(
|
|
855
|
+
extra_imports.setdefault('pydantic', set()).add(Field.__name__)
|
|
857
856
|
else:
|
|
858
857
|
annotation_ast = union_ast
|
|
859
858
|
|
|
@@ -900,27 +899,42 @@ class TypeGenerator(OpenAPIProcessor):
|
|
|
900
899
|
field_types.append(type_)
|
|
901
900
|
return body, field_types
|
|
902
901
|
|
|
903
|
-
@staticmethod
|
|
904
902
|
def _prepend_model_config_and_docstring(
|
|
905
|
-
body: list[ast.stmt], schema: Schema, name: str
|
|
903
|
+
self, body: list[ast.stmt], schema: Schema, name: str
|
|
906
904
|
) -> list[ast.stmt]:
|
|
907
905
|
"""Prepend ``model_config``/deprecation-docstring statements to a model body.
|
|
908
906
|
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
deprecation docstring (if any) is prepended after ``model_config`` so it
|
|
913
|
-
stays the first statement in the class body.
|
|
907
|
+
For Pydantic v2 (default), mirrors ``additionalProperties: false`` via
|
|
908
|
+
``model_config = {'extra': 'forbid'}``. For Pydantic v1, emits the
|
|
909
|
+
equivalent nested ``class Config: extra = 'forbid'`` form instead.
|
|
914
910
|
"""
|
|
915
911
|
if schema.additionalProperties is False:
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
912
|
+
if self.pydantic_version == 1:
|
|
913
|
+
# Pydantic v1: class Config: extra = 'forbid'
|
|
914
|
+
config_class = ast.ClassDef(
|
|
915
|
+
name='Config',
|
|
916
|
+
bases=[],
|
|
917
|
+
keywords=[],
|
|
918
|
+
body=[
|
|
919
|
+
ast.Assign(
|
|
920
|
+
targets=[_name('extra')],
|
|
921
|
+
value=ast.Constant(value='forbid'),
|
|
922
|
+
)
|
|
923
|
+
],
|
|
924
|
+
decorator_list=[],
|
|
925
|
+
type_params=[],
|
|
926
|
+
)
|
|
927
|
+
body = [config_class] + body
|
|
928
|
+
else:
|
|
929
|
+
# Pydantic v2: model_config = {'extra': 'forbid'}
|
|
930
|
+
model_config_assign = ast.Assign(
|
|
931
|
+
targets=[_name('model_config')],
|
|
932
|
+
value=ast.Dict(
|
|
933
|
+
keys=[ast.Constant(value='extra')],
|
|
934
|
+
values=[ast.Constant(value='forbid')],
|
|
935
|
+
),
|
|
936
|
+
)
|
|
937
|
+
body = [model_config_assign] + body
|
|
924
938
|
|
|
925
939
|
if schema.deprecated:
|
|
926
940
|
deprecation_doc = ast.Expr(
|
|
@@ -983,10 +997,8 @@ class TypeGenerator(OpenAPIProcessor):
|
|
|
983
997
|
|
|
984
998
|
self._attach_model_dependencies(type_, base_bases, field_types)
|
|
985
999
|
|
|
986
|
-
type_.add_implementation_import(
|
|
987
|
-
|
|
988
|
-
)
|
|
989
|
-
type_.add_implementation_import(module=Field.__module__, name=Field.__name__)
|
|
1000
|
+
type_.add_implementation_import(module='pydantic', name=BaseModel.__name__)
|
|
1001
|
+
type_.add_implementation_import(module='pydantic', name=Field.__name__)
|
|
990
1002
|
type_.copy_imports_from_sub_types(field_types)
|
|
991
1003
|
|
|
992
1004
|
type_ = self.add_type(type_, base_name=base_name)
|
|
@@ -1012,6 +1012,26 @@ class DocumentConfig(BaseModel):
|
|
|
1012
1012
|
),
|
|
1013
1013
|
)
|
|
1014
1014
|
|
|
1015
|
+
reexport_model_exclude_patterns: list[str] = Field(
|
|
1016
|
+
default_factory=list,
|
|
1017
|
+
description=(
|
|
1018
|
+
'Glob patterns of model names to exclude from re-export when '
|
|
1019
|
+
'``reexport_models`` is True. For example, '
|
|
1020
|
+
'``["*Envelope*", "*ErrorResponse"]`` hides pagination wrappers '
|
|
1021
|
+
'and error response bodies from the public API surface.'
|
|
1022
|
+
),
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
pydantic_version: Literal[1, 2] = Field(
|
|
1026
|
+
default=2,
|
|
1027
|
+
description=(
|
|
1028
|
+
'Target Pydantic version (1 or 2). '
|
|
1029
|
+
'Controls generated model syntax (model_config dict vs nested class Config, '
|
|
1030
|
+
'model_dump() vs dict()), the RootModel / __root__ pattern in the '
|
|
1031
|
+
'generated client, and the emitted _dataframe.py helper.'
|
|
1032
|
+
),
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1015
1035
|
module_split: ModuleSplitConfig = Field(
|
|
1016
1036
|
default_factory=ModuleSplitConfig,
|
|
1017
1037
|
description='Configuration for splitting endpoints into submodules.',
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
"""Retry and backoff helpers for OtterAPI generated clients."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import asyncio
|
|
6
|
-
import time
|
|
7
|
-
from typing import Any
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def _backoff_sleep(attempt: int, factor: float, response: Any) -> None:
|
|
11
|
-
"""Sleep before a retry, honouring the Retry-After header when present."""
|
|
12
|
-
if response is not None:
|
|
13
|
-
retry_after = response.headers.get('Retry-After')
|
|
14
|
-
if retry_after is not None:
|
|
15
|
-
try:
|
|
16
|
-
time.sleep(float(retry_after))
|
|
17
|
-
return
|
|
18
|
-
except ValueError:
|
|
19
|
-
pass
|
|
20
|
-
time.sleep(min(factor * (2**attempt), 60.0))
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
async def _backoff_sleep_async(attempt: int, factor: float, response: Any) -> None:
|
|
24
|
-
"""Async variant of :func:`_backoff_sleep`."""
|
|
25
|
-
if response is not None:
|
|
26
|
-
retry_after = response.headers.get('Retry-After')
|
|
27
|
-
if retry_after is not None:
|
|
28
|
-
try:
|
|
29
|
-
await asyncio.sleep(float(retry_after))
|
|
30
|
-
return
|
|
31
|
-
except ValueError:
|
|
32
|
-
pass
|
|
33
|
-
await asyncio.sleep(min(factor * (2**attempt), 60.0))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|