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.
Files changed (88) hide show
  1. {otterapi-0.1.3 → otterapi-0.1.4}/PKG-INFO +1 -1
  2. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/_features.py +78 -4
  3. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/ast_utils.py +3 -1
  4. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/client.py +57 -20
  5. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/codegen.py +16 -3
  6. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/endpoints.py +9 -3
  7. otterapi-0.1.4/otterapi/codegen/runtime/_retry.py +74 -0
  8. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/splitting.py +16 -2
  9. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/types.py +33 -19
  10. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/config.py +20 -0
  11. {otterapi-0.1.3 → otterapi-0.1.4}/pyproject.toml +2 -0
  12. otterapi-0.1.3/otterapi/codegen/runtime/_retry.py +0 -33
  13. {otterapi-0.1.3 → otterapi-0.1.4}/.gitignore +0 -0
  14. {otterapi-0.1.3 → otterapi-0.1.4}/README.md +0 -0
  15. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/__init__.py +0 -0
  16. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/__main__.py +0 -0
  17. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/cli.py +0 -0
  18. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/__init__.py +0 -0
  19. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/_body_builder.py +0 -0
  20. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/_openapi_adapter.py +0 -0
  21. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/dataframes.py +0 -0
  22. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/export.py +0 -0
  23. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/pagination.py +0 -0
  24. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/runtime/__init__.py +0 -0
  25. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/runtime/_client_stub.py.tpl +0 -0
  26. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/runtime/_concurrency.py +0 -0
  27. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/runtime/_dataframe.py +0 -0
  28. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/runtime/_export.py +0 -0
  29. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/runtime/_models_mixin.py +0 -0
  30. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/runtime/_pagination.py +0 -0
  31. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/schema.py +0 -0
  32. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/codegen/utils.py +0 -0
  33. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/exceptions.py +0 -0
  34. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/__init__.py +0 -0
  35. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/constants.py +0 -0
  36. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v2/__init__.py +0 -0
  37. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v2/spec.json +0 -0
  38. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v2/v2.py +0 -0
  39. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v3/__init__.py +0 -0
  40. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v3/spec.json +0 -0
  41. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v3/v3.py +0 -0
  42. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v3_1/__init__.py +0 -0
  43. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v3_1/spec.json +0 -0
  44. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v3_1/v3_1.py +0 -0
  45. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v3_2/__init__.py +0 -0
  46. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v3_2/spec.json +0 -0
  47. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/v3_2/v3_2.py +0 -0
  48. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/openapi/warnings.py +0 -0
  49. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/__init__.py +0 -0
  50. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/fixtures/__init__.py +0 -0
  51. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/constraints/spec.yaml +0 -0
  52. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/dataframe/config.yaml +0 -0
  53. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/dataframe/spec.yaml +0 -0
  54. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/discriminator/spec.yaml +0 -0
  55. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/paginated/config.yaml +0 -0
  56. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/fixtures/golden/paginated/spec.yaml +0 -0
  57. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_ast_utils.py +0 -0
  58. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_body_builder.py +0 -0
  59. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_client_retry_lifecycle.py +0 -0
  60. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_codegen.py +0 -0
  61. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_codegen_structure.py +0 -0
  62. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_concurrency_runtime.py +0 -0
  63. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_config_errors.py +0 -0
  64. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_dataframe.py +0 -0
  65. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_discriminated_unions.py +0 -0
  66. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_exceptions.py +0 -0
  67. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_export.py +0 -0
  68. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_extra_forbid.py +0 -0
  69. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_feature_modules.py +0 -0
  70. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_html_repr.py +0 -0
  71. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_init_scaffold.py +0 -0
  72. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_not_schema.py +0 -0
  73. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_nullable_upgrade.py +0 -0
  74. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_openapi_adapter.py +0 -0
  75. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_openapi_support.py +0 -0
  76. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_openapi_upgrade.py +0 -0
  77. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_pagination.py +0 -0
  78. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_per_status_errors.py +0 -0
  79. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_public_api.py +0 -0
  80. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_regen_script.py +0 -0
  81. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_retry_runtime.py +0 -0
  82. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_runtime_guards.py +0 -0
  83. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_schema_constraints.py +0 -0
  84. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_smoke_generated_client.py +0 -0
  85. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_splitting_config.py +0 -0
  86. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_splitting_integration.py +0 -0
  87. {otterapi-0.1.3 → otterapi-0.1.4}/otterapi/tests/test_splitting_resolver.py +0 -0
  88. {otterapi-0.1.3 → 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.3
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)."""
@@ -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(target: ast.expr, annotation: ast.expr, value: ast.expr) -> ast.AnnAssign:
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
- imports: ImportDict = {
670
- 'httpx': {'Client', 'AsyncClient', 'Response', 'TransportError'},
671
- 'typing': {'Any'},
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
  ]
@@ -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
@@ -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([annotation, ast.Constant(value=None)])
232
- self._kwonlyargs.append(_argument(param.name_sanitized, optional_annotation))
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([body_annotation, ast.Constant(value=None)])
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 = 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(
@@ -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