otterapi 0.1.3__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.3 → otterapi-0.1.4}/PKG-INFO +1 -1
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/_features.py +78 -4
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/ast_utils.py +3 -1
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/client.py +57 -20
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/codegen.py +16 -3
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/endpoints.py +9 -3
- otterapi-0.1.4/otterapi/codegen/runtime/_retry.py +74 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/splitting.py +16 -2
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/types.py +33 -19
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/config.py +20 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/pyproject.toml +2 -0
- otterapi-0.1.3/otterapi/codegen/runtime/_retry.py +0 -33
- {otterapi-0.1.3 → otterapi-0.1.4}/.gitignore +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/README.md +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/__init__.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/__main__.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/cli.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/__init__.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/_body_builder.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/_openapi_adapter.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/dataframes.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/export.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/pagination.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/runtime/__init__.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/runtime/_client_stub.py.tpl +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/runtime/_concurrency.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/runtime/_dataframe.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/runtime/_export.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/runtime/_models_mixin.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/runtime/_pagination.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/schema.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/utils.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/exceptions.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/__init__.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/constants.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v2/__init__.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v2/spec.json +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v2/v2.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v3/__init__.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v3/spec.json +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v3/v3.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v3_1/__init__.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v3_1/spec.json +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v3_1/v3_1.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v3_2/__init__.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v3_2/spec.json +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v3_2/v3_2.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/warnings.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/__init__.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/fixtures/__init__.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/constraints/spec.yaml +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/dataframe/config.yaml +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/dataframe/spec.yaml +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/discriminator/spec.yaml +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/paginated/config.yaml +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/paginated/spec.yaml +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_ast_utils.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_body_builder.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_client_retry_lifecycle.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_codegen.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_codegen_structure.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_concurrency_runtime.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_config_errors.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_dataframe.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_discriminated_unions.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_exceptions.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_export.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_extra_forbid.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_feature_modules.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_html_repr.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_init_scaffold.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_not_schema.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_nullable_upgrade.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_openapi_adapter.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_openapi_support.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_openapi_upgrade.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_pagination.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_per_status_errors.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_public_api.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_regen_script.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_retry_runtime.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_runtime_guards.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_schema_constraints.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_smoke_generated_client.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_splitting_config.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_splitting_integration.py +0 -0
- {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_splitting_resolver.py +0 -0
- {otterapi-0.1.3 → 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)."""
|
|
@@ -77,7 +77,9 @@ def _argument(name: str, value: ast.expr | None = None) -> ast.arg:
|
|
|
77
77
|
)
|
|
78
78
|
|
|
79
79
|
|
|
80
|
-
def _ann_assign(
|
|
80
|
+
def _ann_assign(
|
|
81
|
+
target: ast.expr, annotation: ast.expr, value: ast.expr
|
|
82
|
+
) -> ast.AnnAssign:
|
|
81
83
|
"""Build ``target: annotation = value``."""
|
|
82
84
|
if isinstance(target, ast.Name):
|
|
83
85
|
target = ast.Name(id=target.id, ctx=ast.Store())
|
|
@@ -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
|
]
|
|
@@ -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
|
|
@@ -228,8 +228,12 @@ class FunctionSignatureBuilder:
|
|
|
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
|
-
|
|
231
|
+
optional_annotation = _union_expr(
|
|
232
|
+
[annotation, ast.Constant(value=None)]
|
|
233
|
+
)
|
|
234
|
+
self._kwonlyargs.append(
|
|
235
|
+
_argument(param.name_sanitized, optional_annotation)
|
|
236
|
+
)
|
|
233
237
|
self._kw_defaults.append(ast.Constant(value=None))
|
|
234
238
|
|
|
235
239
|
return self
|
|
@@ -264,7 +268,9 @@ class FunctionSignatureBuilder:
|
|
|
264
268
|
if body.required:
|
|
265
269
|
self._args.append(body_arg)
|
|
266
270
|
else:
|
|
267
|
-
optional_body_annotation = _union_expr(
|
|
271
|
+
optional_body_annotation = _union_expr(
|
|
272
|
+
[body_annotation, ast.Constant(value=None)]
|
|
273
|
+
)
|
|
268
274
|
self._kwonlyargs.append(_argument('body', optional_body_annotation))
|
|
269
275
|
self._kw_defaults.append(ast.Constant(value=None))
|
|
270
276
|
|
|
@@ -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(
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|