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.
Files changed (88) hide show
  1. {otterapi-0.1.2 → otterapi-0.1.4}/PKG-INFO +1 -1
  2. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/_features.py +78 -4
  3. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/ast_utils.py +10 -0
  4. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/client.py +59 -22
  5. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/codegen.py +16 -13
  6. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/endpoints.py +11 -2
  7. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/export.py +2 -2
  8. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/runtime/_export.py +5 -4
  9. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/runtime/_pagination.py +48 -49
  10. otterapi-0.1.4/otterapi/codegen/runtime/_retry.py +74 -0
  11. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/splitting.py +16 -2
  12. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/types.py +35 -23
  13. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/config.py +20 -0
  14. {otterapi-0.1.2 → otterapi-0.1.4}/pyproject.toml +2 -0
  15. otterapi-0.1.2/otterapi/codegen/runtime/_retry.py +0 -33
  16. {otterapi-0.1.2 → otterapi-0.1.4}/.gitignore +0 -0
  17. {otterapi-0.1.2 → otterapi-0.1.4}/README.md +0 -0
  18. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/__init__.py +0 -0
  19. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/__main__.py +0 -0
  20. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/cli.py +0 -0
  21. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/__init__.py +0 -0
  22. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/_body_builder.py +0 -0
  23. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/_openapi_adapter.py +0 -0
  24. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/dataframes.py +0 -0
  25. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/pagination.py +0 -0
  26. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/runtime/__init__.py +0 -0
  27. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/runtime/_client_stub.py.tpl +0 -0
  28. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/runtime/_concurrency.py +0 -0
  29. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/runtime/_dataframe.py +0 -0
  30. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/runtime/_models_mixin.py +0 -0
  31. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/schema.py +0 -0
  32. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/codegen/utils.py +0 -0
  33. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/exceptions.py +0 -0
  34. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/__init__.py +0 -0
  35. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/constants.py +0 -0
  36. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v2/__init__.py +0 -0
  37. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v2/spec.json +0 -0
  38. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v2/v2.py +0 -0
  39. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v3/__init__.py +0 -0
  40. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v3/spec.json +0 -0
  41. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v3/v3.py +0 -0
  42. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v3_1/__init__.py +0 -0
  43. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v3_1/spec.json +0 -0
  44. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v3_1/v3_1.py +0 -0
  45. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v3_2/__init__.py +0 -0
  46. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v3_2/spec.json +0 -0
  47. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/v3_2/v3_2.py +0 -0
  48. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/openapi/warnings.py +0 -0
  49. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/__init__.py +0 -0
  50. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/fixtures/__init__.py +0 -0
  51. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/constraints/spec.yaml +0 -0
  52. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/dataframe/config.yaml +0 -0
  53. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/dataframe/spec.yaml +0 -0
  54. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/discriminator/spec.yaml +0 -0
  55. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/paginated/config.yaml +0 -0
  56. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/paginated/spec.yaml +0 -0
  57. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_ast_utils.py +0 -0
  58. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_body_builder.py +0 -0
  59. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_client_retry_lifecycle.py +0 -0
  60. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_codegen.py +0 -0
  61. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_codegen_structure.py +0 -0
  62. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_concurrency_runtime.py +0 -0
  63. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_config_errors.py +0 -0
  64. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_dataframe.py +0 -0
  65. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_discriminated_unions.py +0 -0
  66. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_exceptions.py +0 -0
  67. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_export.py +0 -0
  68. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_extra_forbid.py +0 -0
  69. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_feature_modules.py +0 -0
  70. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_html_repr.py +0 -0
  71. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_init_scaffold.py +0 -0
  72. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_not_schema.py +0 -0
  73. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_nullable_upgrade.py +0 -0
  74. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_openapi_adapter.py +0 -0
  75. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_openapi_support.py +0 -0
  76. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_openapi_upgrade.py +0 -0
  77. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_pagination.py +0 -0
  78. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_per_status_errors.py +0 -0
  79. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_public_api.py +0 -0
  80. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_regen_script.py +0 -0
  81. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_retry_runtime.py +0 -0
  82. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_runtime_guards.py +0 -0
  83. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_schema_constraints.py +0 -0
  84. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_smoke_generated_client.py +0 -0
  85. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_splitting_config.py +0 -0
  86. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_splitting_integration.py +0 -0
  87. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_splitting_resolver.py +0 -0
  88. {otterapi-0.1.2 → otterapi-0.1.4}/otterapi/tests/test_splitting_tree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: otterapi
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: A cute little companion that generates type-safe clients from OpenAPI documents.
5
5
  Project-URL: Source, https://github.com/danplischke/otter
6
6
  Author: Dan Plischke
@@ -64,16 +64,44 @@ class FeatureModule(ABC):
64
64
  .read_text('utf-8')
65
65
  )
66
66
 
67
- def write(self, output_dir: Path | UPath) -> Path | UPath:
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(self.module_content, encoding='utf-8')
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) for feature in features if feature.is_enabled(config)
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
- imports: ImportDict = {
670
- 'httpx': {'Client', 'AsyncClient', 'Response', 'TransportError'},
671
- 'typing': {'Any', 'TypeVar'},
672
- 'types': {'UnionType'},
673
- 'pydantic': {'TypeAdapter', 'RootModel'},
674
- '._retry': {'_backoff_sleep', '_backoff_sleep_async'},
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(is_async=False)
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(is_async=True)
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
- # Build the method body:
1077
- # data = response.json()
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
- # if isinstance(validated, RootModel): return validated.root
1111
- ast.If(
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('T'),
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('T'),
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(self.openapi)
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
- all_names |= import_collector._imports.get(MODELS_MODULE, set())
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
- self._kwonlyargs.append(arg)
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
- self._kwonlyargs.append(body_arg)
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 inner.model_fields.items()
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], PageT],
122
- extract_items: Callable[[PageT], list[T]],
123
- get_total: Callable[[PageT], int | None] | None = None,
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 = False,
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[PageT]
186
- extract_items: Callable[[PageT], list[T]],
187
- get_total: Callable[[PageT], int | None] | None = None,
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 = False,
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], PageT],
248
- extract_items: Callable[[PageT], list[T]],
249
- get_total: Callable[[PageT], int | None] | None = None,
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 = False,
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[PageT]
313
- extract_items: Callable[[PageT], list[T]],
314
- get_total: Callable[[PageT], int | None] | None = None,
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 = False,
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], PageT],
380
- extract_items: Callable[[PageT], list[T]],
381
- get_next_cursor: Callable[[PageT], str | None],
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 = False,
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[PageT]
435
- extract_items: Callable[[PageT], list[T]],
436
- get_next_cursor: Callable[[PageT], str | None],
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 = False,
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], PageT],
489
- extract_items: Callable[[PageT], list[T]],
490
- get_next_cursor: Callable[[PageT], str | None],
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 = False,
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[PageT]
541
- extract_items: Callable[[PageT], list[T]],
542
- get_next_cursor: Callable[[PageT], str | None],
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 = False,
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], PageT],
597
- extract_items: Callable[[PageT], list[T]],
598
- get_total_pages: Callable[[PageT], int | None] | None = None,
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 = False,
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[PageT]
664
- extract_items: Callable[[PageT], list[T]],
665
- get_total_pages: Callable[[PageT], int | None] | None = None,
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 = False,
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], PageT],
727
- extract_items: Callable[[PageT], list[T]],
728
- get_total_pages: Callable[[PageT], int | None] | None = None,
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 = False,
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[PageT]
792
- extract_items: Callable[[PageT], list[T]],
793
- get_total_pages: Callable[[PageT], int | None] | None = None,
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 = False,
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 = sorted(import_collector._imports.get(MODELS_MODULE, set()))
1306
- all_names = sorted(set(all_names) | set(model_names))
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(Field.__module__, set()).add(Field.__name__)
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
- When the schema declares ``additionalProperties: false``, this mirrors
910
- that constraint via Pydantic's ``model_config = {'extra': 'forbid'}`` so
911
- generated models actually reject unexpected fields at runtime. The
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
- model_config_assign = ast.Assign(
917
- targets=[_name('model_config')],
918
- value=ast.Dict(
919
- keys=[ast.Constant(value='extra')],
920
- values=[ast.Constant(value='forbid')],
921
- ),
922
- )
923
- body = [model_config_assign] + body
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
- module=BaseModel.__module__, name=BaseModel.__name__
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.',
@@ -53,6 +53,8 @@ otter = "otterapi.__main__:app"
53
53
 
54
54
  [dependency-groups]
55
55
  dev = [
56
+ "pytest>=9.0.2",
57
+ "pytest-asyncio>=1.3.0",
56
58
  "ruff>=0.14.2",
57
59
  "twine>=6.2.0",
58
60
  "vulture>=2.14",
@@ -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