Skip to content

Commit 800a715

Browse files
Viicosdbanty
andauthored
Properly rebuild Pydantic models if necessary (#1176)
This is the alternative approach I mentioned in #1171 (comment). Instead of trying to rebuild the models in their respective modules (which requires weird patterns, such as unused imports or importing after the model is defined), we set `defer_build` to `True` for every model where we know a forward reference will fail to resolve (so that we don't try to build a model if we know it will fail). I added comments each time to justify the use of `defer_build`, but unfortunately this isn't always straightforward (e.g. sometimes you makes use of a model as annotation which itself has `defer_build` set; in this case we also want to defer build. Another case is when making use of the `Callback` type alias; it isn't directly visible but it uses an unresolvable forward reference). Ultimately, in the module's `__init__.py`, we call `model_rebuild` on all the necessary models. I know this isn't ideal as well, as you need to manually check for every exported model here if the build was successful. This library is a clear example that inter-dependent types across different modules is challenging, and Pydantic does not make it easy. We are trying to think about ways to simplify the process. Note that on top of fixing things for Pydantic 2.10, this also ensures every model is successfully built when the `openapi_schema_pydantic` module is imported. Currently on `main` (with Pydantic 2.9.2), some models such as `Components` are not built. While this can still work in some cases, it is advised not to do so (when `Components` is going to be instantiated, Pydantic will implicitly try to rebuild it if it wasn't already. However, we use the namespace where the instantiation call happened to rebuilt it, so depending on _where_ you first instantiate the model, this can lead to a failed model rebuild and thus a runtime exception). --- A note on `model_rebuild`: you can either provide an explicit namespace: ```python PathItem.model_rebuild(_types_namespace={Operation: "Operation", "Header": Header}) ``` Or let `model_rebuild` use the namespace where it was called (in our case, all the imports are available, so it works). --------- Co-authored-by: Dylan Anthony <[email protected]> Co-authored-by: Dylan Anthony <[email protected]>
1 parent 3a5459e commit 800a715

14 files changed

+143
-125
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
default: patch
3+
---
4+
5+
# Fix compatibility with Pydantic 2.10+
6+
7+
#1176 by @Viicos
8+
9+
Set `defer_build` to models that we know will fail to build, and call `model_rebuild`
10+
in the `__init__.py` file.

Diff for: openapi_python_client/schema/openapi_schema_pydantic/__init__.py

+11
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,14 @@
7070
from .server_variable import ServerVariable
7171
from .tag import Tag
7272
from .xml import XML
73+
74+
PathItem.model_rebuild()
75+
Operation.model_rebuild()
76+
Components.model_rebuild()
77+
Encoding.model_rebuild()
78+
MediaType.model_rebuild()
79+
OpenAPI.model_rebuild()
80+
Parameter.model_rebuild()
81+
Header.model_rebuild()
82+
RequestBody.model_rebuild()
83+
Response.model_rebuild()

Diff for: openapi_python_client/schema/openapi_schema_pydantic/components.py

+2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ class Components(BaseModel):
3535
links: Optional[dict[str, Union[Link, Reference]]] = None
3636
callbacks: Optional[dict[str, Union[Callback, Reference]]] = None
3737
model_config = ConfigDict(
38+
# `Callback` contains an unresolvable forward reference, will rebuild in `__init__.py`:
39+
defer_build=True,
3840
extra="allow",
3941
json_schema_extra={
4042
"examples": [

Diff for: openapi_python_client/schema/openapi_schema_pydantic/encoding.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66

77
if TYPE_CHECKING: # pragma: no cover
88
from .header import Header
9-
else:
10-
Header = "Header"
119

1210

1311
class Encoding(BaseModel):
@@ -19,11 +17,13 @@ class Encoding(BaseModel):
1917
"""
2018

2119
contentType: Optional[str] = None
22-
headers: Optional[dict[str, Union[Header, Reference]]] = None
20+
headers: Optional[dict[str, Union["Header", Reference]]] = None
2321
style: Optional[str] = None
2422
explode: bool = False
2523
allowReserved: bool = False
2624
model_config = ConfigDict(
25+
# `Header` is an unresolvable forward reference, will rebuild in `__init__.py`:
26+
defer_build=True,
2727
extra="allow",
2828
json_schema_extra={
2929
"examples": [

Diff for: openapi_python_client/schema/openapi_schema_pydantic/header.py

+2
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ class Header(Parameter):
2121
name: str = Field(default="")
2222
param_in: ParameterLocation = Field(default=ParameterLocation.HEADER, alias="in")
2323
model_config = ConfigDict(
24+
# `Parameter` is not build yet, will rebuild in `__init__.py`:
25+
defer_build=True,
2426
extra="allow",
2527
populate_by_name=True,
2628
json_schema_extra={

Diff for: openapi_python_client/schema/openapi_schema_pydantic/media_type.py

+2
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ class MediaType(BaseModel):
2121
examples: Optional[dict[str, Union[Example, Reference]]] = None
2222
encoding: Optional[dict[str, Encoding]] = None
2323
model_config = ConfigDict(
24+
# `Encoding` is not build yet, will rebuild in `__init__.py`:
25+
defer_build=True,
2426
extra="allow",
2527
populate_by_name=True,
2628
json_schema_extra={

Diff for: openapi_python_client/schema/openapi_schema_pydantic/open_api.py

+5-7
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@
55
from .components import Components
66
from .external_documentation import ExternalDocumentation
77
from .info import Info
8-
9-
# Required to update forward ref after object creation
10-
from .path_item import PathItem # noqa: F401
118
from .paths import Paths
129
from .security_requirement import SecurityRequirement
1310
from .server import Server
@@ -32,7 +29,11 @@ class OpenAPI(BaseModel):
3229
tags: Optional[list[Tag]] = None
3330
externalDocs: Optional[ExternalDocumentation] = None
3431
openapi: str
35-
model_config = ConfigDict(extra="allow")
32+
model_config = ConfigDict(
33+
# `Components` is not build yet, will rebuild in `__init__.py`:
34+
defer_build=True,
35+
extra="allow",
36+
)
3637

3738
@field_validator("openapi")
3839
@classmethod
@@ -46,6 +47,3 @@ def check_openapi_version(cls, value: str) -> str:
4647
if int(parts[1]) > 1:
4748
raise ValueError(f"Only OpenAPI versions 3.1.* are supported, got {value}")
4849
return value
49-
50-
51-
OpenAPI.model_rebuild()

Diff for: openapi_python_client/schema/openapi_schema_pydantic/operation.py

+2-8
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,7 @@
44

55
from .callback import Callback
66
from .external_documentation import ExternalDocumentation
7-
from .header import Header # noqa: F401
87
from .parameter import Parameter
9-
10-
# Required to update forward ref after object creation, as this is not imported yet
11-
from .path_item import PathItem # noqa: F401
128
from .reference import Reference
139
from .request_body import RequestBody
1410
from .responses import Responses
@@ -38,6 +34,8 @@ class Operation(BaseModel):
3834
security: Optional[list[SecurityRequirement]] = None
3935
servers: Optional[list[Server]] = None
4036
model_config = ConfigDict(
37+
# `Callback` contains an unresolvable forward reference, will rebuild in `__init__.py`:
38+
defer_build=True,
4139
extra="allow",
4240
json_schema_extra={
4341
"examples": [
@@ -89,7 +87,3 @@ class Operation(BaseModel):
8987
]
9088
},
9189
)
92-
93-
94-
# PathItem in Callback uses Operation, so we need to update forward refs due to circular dependency
95-
Operation.model_rebuild()

Diff for: openapi_python_client/schema/openapi_schema_pydantic/parameter.py

+2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ class Parameter(BaseModel):
3535
examples: Optional[dict[str, Union[Example, Reference]]] = None
3636
content: Optional[dict[str, MediaType]] = None
3737
model_config = ConfigDict(
38+
# `MediaType` is not build yet, will rebuild in `__init__.py`:
39+
defer_build=True,
3840
extra="allow",
3941
populate_by_name=True,
4042
json_schema_extra={

Diff for: openapi_python_client/schema/openapi_schema_pydantic/path_item.py

+6-7
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
from typing import Optional, Union
1+
from typing import TYPE_CHECKING, Optional, Union
22

33
from pydantic import BaseModel, ConfigDict, Field
44

55
from .parameter import Parameter
66
from .reference import Reference
77
from .server import Server
88

9+
if TYPE_CHECKING:
10+
from .operation import Operation # pragma: no cover
11+
912

1013
class PathItem(BaseModel):
1114
"""
@@ -33,6 +36,8 @@ class PathItem(BaseModel):
3336
servers: Optional[list[Server]] = None
3437
parameters: Optional[list[Union[Parameter, Reference]]] = None
3538
model_config = ConfigDict(
39+
# `Operation` is an unresolvable forward reference, will rebuild in `__init__.py`:
40+
defer_build=True,
3641
extra="allow",
3742
populate_by_name=True,
3843
json_schema_extra={
@@ -69,9 +74,3 @@ class PathItem(BaseModel):
6974
]
7075
},
7176
)
72-
73-
74-
# Operation uses PathItem via Callback, so we need late import and to update forward refs due to circular dependency
75-
from .operation import Operation # noqa: E402
76-
77-
PathItem.model_rebuild()

Diff for: openapi_python_client/schema/openapi_schema_pydantic/request_body.py

+2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ class RequestBody(BaseModel):
1717
content: dict[str, MediaType]
1818
required: bool = False
1919
model_config = ConfigDict(
20+
# `MediaType` is not build yet, will rebuild in `__init__.py`:
21+
defer_build=True,
2022
extra="allow",
2123
json_schema_extra={
2224
"examples": [

Diff for: openapi_python_client/schema/openapi_schema_pydantic/response.py

+2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ class Response(BaseModel):
2323
content: Optional[dict[str, MediaType]] = None
2424
links: Optional[dict[str, Union[Link, Reference]]] = None
2525
model_config = ConfigDict(
26+
# `MediaType` is not build yet, will rebuild in `__init__.py`:
27+
defer_build=True,
2628
extra="allow",
2729
json_schema_extra={
2830
"examples": [

Diff for: openapi_python_client/schema/openapi_schema_pydantic/schema.py

+1-4
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class Schema(BaseModel):
4343
anyOf: list[Union[Reference, "Schema"]] = Field(default_factory=list)
4444
schema_not: Optional[Union[Reference, "Schema"]] = Field(default=None, alias="not")
4545
items: Optional[Union[Reference, "Schema"]] = None
46-
prefixItems: Optional[list[Union[Reference, "Schema"]]] = Field(default_factory=list)
46+
prefixItems: list[Union[Reference, "Schema"]] = Field(default_factory=list)
4747
properties: Optional[dict[str, Union[Reference, "Schema"]]] = None
4848
additionalProperties: Optional[Union[bool, Reference, "Schema"]] = None
4949
description: Optional[str] = None
@@ -206,6 +206,3 @@ def handle_nullable(self) -> "Schema":
206206
self.oneOf = [Schema(type=DataType.NULL), Schema(allOf=self.allOf)]
207207
self.allOf = []
208208
return self
209-
210-
211-
Schema.model_rebuild()

0 commit comments

Comments
 (0)