From b187d9a6d4d2c7ed7cfb911ce2487a468b440d73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Mon, 10 Nov 2025 08:18:00 +0100 Subject: [PATCH 1/7] fix - pydantic model_validator("after")/classmethod deprecation pydantic.warnings.PydanticDeprecatedSince212: Using `@model_validator` with mode='after' on a classmethod is deprecated. Instead, use an instance method. See the documentation at https://docs.pydantic.dev/2.12/concepts/validators/#model-after-validator. Deprecated in Pydantic V2.12 to be removed in V3.0. --- src/aiopenapi3/v30/parameter.py | 8 +++++--- src/aiopenapi3/v30/paths.py | 17 ++++++++--------- src/aiopenapi3/v30/schemas.py | 11 +++++------ src/aiopenapi3/v30/servers.py | 8 ++++---- src/aiopenapi3/v31/info.py | 7 +++---- src/aiopenapi3/v31/paths.py | 16 ++++++++-------- src/aiopenapi3/v31/root.py | 7 +++---- src/aiopenapi3/v31/schemas.py | 11 +++++------ src/aiopenapi3/v31/servers.py | 8 ++++---- 9 files changed, 45 insertions(+), 48 deletions(-) diff --git a/src/aiopenapi3/v30/parameter.py b/src/aiopenapi3/v30/parameter.py index 365796c5..8c8cf591 100644 --- a/src/aiopenapi3/v30/parameter.py +++ b/src/aiopenapi3/v30/parameter.py @@ -316,9 +316,11 @@ class Parameter(ParameterBase, _ParameterCodec): in_: _In = Field(alias="in") # TODO must be one of ["query","header","path","cookie"] @model_validator(mode="after") - def validate_Parameter(cls, p: "ParameterBase"): - assert p.in_ != "path" or p.required is True, "Parameter '%s' must be required since it is in the path" % p.name - return p + def validate_Parameter(self): + assert self.in_ != "path" or self.required is True, ( + "Parameter '%s' must be required since it is in the path" % self.name + ) + return self def encode_parameter( diff --git a/src/aiopenapi3/v30/paths.py b/src/aiopenapi3/v30/paths.py index c08e8a66..2542bf93 100644 --- a/src/aiopenapi3/v30/paths.py +++ b/src/aiopenapi3/v30/paths.py @@ -38,15 +38,14 @@ class Link(ObjectExtended): server: Server | None = Field(default=None) @model_validator(mode="after") - @classmethod - def validate_Link_operation(cls, l: '__types["Link"]'): # noqa: F821 - assert not (l.operationId is not None and l.operationRef is not None), ( - "operationId and operationRef are mutually exclusive, only one of them is allowed" - ) - assert not (l.operationId == l.operationRef is None), ( - "operationId and operationRef are mutually exclusive, one of them must be specified" - ) - return l + def validate_Link_operation(self): # type: ignore[name-defined] + assert not ( + self.operationId != None and self.operationRef != None + ), "operationId and operationRef are mutually exclusive, only one of them is allowed" + assert not ( + self.operationId == self.operationRef == None + ), "operationId and operationRef are mutually exclusive, one of them must be specified" + return self class Response(ObjectExtended): diff --git a/src/aiopenapi3/v30/schemas.py b/src/aiopenapi3/v30/schemas.py index f84838af..c901ab76 100644 --- a/src/aiopenapi3/v30/schemas.py +++ b/src/aiopenapi3/v30/schemas.py @@ -74,13 +74,12 @@ def is_boolean_schema(cls, data: Any) -> Any: return {"not": {}} @model_validator(mode="after") - @classmethod - def validate_Schema_number_type(cls, s: "Schema"): - if s.type == "integer": + def validate_Schema_number_type(self): + if self.type == "integer": for i in ["minimum", "maximum"]: - if (v := getattr(s, i, None)) is not None and not isinstance(v, int): - setattr(s, i, int(v)) - return s + if (v := getattr(self, i, None)) is not None and not isinstance(v, int): + setattr(self, i, int(v)) + return self def __getstate__(self): return SchemaBase.__getstate__(self) diff --git a/src/aiopenapi3/v30/servers.py b/src/aiopenapi3/v30/servers.py index 14a5f50c..635e37a0 100644 --- a/src/aiopenapi3/v30/servers.py +++ b/src/aiopenapi3/v30/servers.py @@ -17,11 +17,11 @@ class ServerVariable(ObjectExtended): description: str | None = Field(default=None) @model_validator(mode="after") - def validate_ServerVariable(cls, s: "ServerVariable"): - assert isinstance(s.enum, (list, None.__class__)) + def validate_ServerVariable(self): + assert isinstance(self.enum, (list, None.__class__)) # default value must be in enum - assert s.default is None or s.default in (s.enum or [s.default]) - return s + assert self.default is None or self.default in (self.enum or [self.default]) + return self class Server(ObjectExtended): diff --git a/src/aiopenapi3/v31/info.py b/src/aiopenapi3/v31/info.py index 0f0d2147..ee7c5c17 100644 --- a/src/aiopenapi3/v31/info.py +++ b/src/aiopenapi3/v31/info.py @@ -27,13 +27,12 @@ class License(ObjectExtended): url: str | None = Field(default=None) @model_validator(mode="after") - @classmethod - def validate_License(cls, l: "License"): + def validate_License(self): """ A URL to the license used for the API. This MUST be in the form of a URL. The url field is mutually exclusive of the identifier field. """ - assert not all([getattr(l, i, None) is not None for i in ["identifier", "url"]]) - return l + assert not all([getattr(self, i, None) is not None for i in ["identifier", "url"]]) + return self class Info(ObjectExtended): diff --git a/src/aiopenapi3/v31/paths.py b/src/aiopenapi3/v31/paths.py index 70a3c0c6..1d798a70 100644 --- a/src/aiopenapi3/v31/paths.py +++ b/src/aiopenapi3/v31/paths.py @@ -38,14 +38,14 @@ class Link(ObjectExtended): server: Server | None = Field(default=None) @model_validator(mode="after") - def validate_Link_operation(cls, l: '__types["Link"]'): # noqa: F821 - assert not (l.operationId is not None and l.operationRef is not None), ( - "operationId and operationRef are mutually exclusive, only one of them is allowed" - ) - assert not (l.operationId == l.operationRef is None), ( - "operationId and operationRef are mutually exclusive, one of them must be specified" - ) - return l + def validate_Link_operation(self): # type: ignore[name-defined] + assert not ( + self.operationId != None and self.operationRef != None + ), "operationId and operationRef are mutually exclusive, only one of them is allowed" + assert not ( + self.operationId == self.operationRef == None + ), "operationId and operationRef are mutually exclusive, one of them must be specified" + return self class Response(ObjectExtended): diff --git a/src/aiopenapi3/v31/root.py b/src/aiopenapi3/v31/root.py index ebc502b3..e2f9272b 100644 --- a/src/aiopenapi3/v31/root.py +++ b/src/aiopenapi3/v31/root.py @@ -35,10 +35,9 @@ class Root(ObjectExtended, RootBase): externalDocs: dict[Any, Any] = Field(default_factory=dict) @model_validator(mode="after") - @classmethod - def validate_Root(cls, r: "Root") -> "Self": # noqa: F821 - assert r.paths or r.components or r.webhooks - return r + def validate_Root(self) -> "Self": + assert self.paths or self.components or self.webhooks + return self def _resolve_references(self, api): RootBase.resolve(api, self, self, PathItem, Reference) diff --git a/src/aiopenapi3/v31/schemas.py b/src/aiopenapi3/v31/schemas.py index 71af0cca..91c8ebdb 100644 --- a/src/aiopenapi3/v31/schemas.py +++ b/src/aiopenapi3/v31/schemas.py @@ -172,13 +172,12 @@ def is_boolean_schema(cls, data: Any) -> Any: return {"not": {}} @model_validator(mode="after") - @classmethod - def validate_Schema_number_type(cls, s: "Schema"): - if s.type == "integer": + def validate_Schema_number_type(self): + if self.type == "integer": for i in ["minimum", "maximum"]: - if (v := getattr(s, i, None)) is not None and not isinstance(v, int): - setattr(s, i, int(v)) - return s + if (v := getattr(self, i, None)) is not None and not isinstance(v, int): + setattr(self, i, int(v)) + return self def __getstate__(self): return SchemaBase.__getstate__(self) diff --git a/src/aiopenapi3/v31/servers.py b/src/aiopenapi3/v31/servers.py index 3a32f04b..de74372f 100644 --- a/src/aiopenapi3/v31/servers.py +++ b/src/aiopenapi3/v31/servers.py @@ -17,11 +17,11 @@ class ServerVariable(ObjectExtended): description: str | None = Field(default=None) @model_validator(mode="after") - def validate_ServerVariable(cls, s: "ServerVariable"): - assert isinstance(s.enum, (list, None.__class__)) + def validate_ServerVariable(self): + assert isinstance(self.enum, (list, None.__class__)) # default value must be in enum - assert s.default is None or s.default in (s.enum or [s.default]) - return s + assert self.default is None or self.default in (self.enum or [self.default]) + return self class Server(ObjectExtended): From afbf4a8a3306542e581107e99e52cc156d3441be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Fri, 14 Nov 2025 09:42:25 +0100 Subject: [PATCH 2/7] tests - FastAPI dropping -Input and pydantic 2.13 adjustments --- tests/apiv2_test.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/apiv2_test.py b/tests/apiv2_test.py index 11aa032f..8ab66f24 100644 --- a/tests/apiv2_test.py +++ b/tests/apiv2_test.py @@ -125,9 +125,9 @@ async def test_model(server, client): def randomPet(client, name=None, cat=False): if name: - Pet = client.components.schemas["Pet-Input"].get_type() + Pet = client.components.schemas["Pet"].get_type() if not cat: - Dog = typing.get_args(typing.get_args(Pet.model_fields["root"].annotation)[0])[1] + Dog = typing.get_args(Pet.model_fields["root"].annotation)[1] dog = Dog( name=name, age=datetime.timedelta(seconds=random.randint(1, 2**32)), @@ -135,8 +135,8 @@ def randomPet(client, name=None, cat=False): ) pet = Pet(dog) else: - Cat = typing.get_args(typing.get_args(Pet.model_fields["root"].annotation)[0])[0] - WhiteCat = typing.get_args(typing.get_args(Cat.model_fields["root"].annotation)[0])[1] + Cat = typing.get_args(Pet.model_fields["root"].annotation)[0] + WhiteCat = typing.get_args(Cat.model_fields["root"].annotation)[1] wc = WhiteCat(pet_type="cat", color="white", name="whitey", white_name="white") cat = Cat(wc) pet = Pet(cat) @@ -236,8 +236,8 @@ async def test_deletePet(server, client): @pytest.mark.asyncio(loop_scope="session") async def test_patchPet(server, client): - Pet = client.components.schemas["Pet-Input"].get_type() - Dog = typing.get_args(typing.get_args(Pet.model_fields["root"].annotation)[0])[1] + Pet = client.components.schemas["Pet"].get_type() + Dog = typing.get_args(Pet.model_fields["root"].annotation)[1] pets = [ Pet( Dog.model_construct( From bed0acada406097055f6e8fad203eeb54672b461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Fri, 14 Nov 2025 09:21:30 +0100 Subject: [PATCH 3/7] pydantic 2.13 - fix UnsupportedFieldAttributeWarning Union[Annotated[str, Field(default=None)], Annotated[int, Field(default=None)],] -> Annotated[Union[str, int], Field(default=None)] c.f. https://github.com/pydantic/pydantic/issues/12530 --- pyproject.toml | 4 +++- src/aiopenapi3/model.py | 35 +++++++++++++++++++++-------------- tests/schema_test.py | 10 ++++------ 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bebd5312..e44f6224 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,11 +6,12 @@ authors = [ ] dependencies = [ "PyYaml", - "pydantic == 2.11.9", + "pydantic @ git+https://github.com/pydantic/pydantic.git", "email-validator", "yarl", "httpx", "more-itertools", + 'typing_extensions; python_version<"3.10"', "jmespath", ] requires-python = ">=3.10" @@ -102,6 +103,7 @@ filterwarnings = [ "ignore:'flask.Markup' is deprecated and will be removed in Flask 2.4. Import 'markupsafe.Markup' instead.:DeprecationWarning", "ignore:unclosed resource type[BaseModel]: r = [i.model() for i in items] if len(r) > 1: - ru: object = Union[tuple(r)] + # Annotations are collected, last element has all of them + # ru: object = Annotated[Union[tuple(r)], items[-1].anno] + v = list() + for i in range(len(items)): + v.append(Annotated[r[i], items[i].anno]) + ru = Annotated[Union[tuple(v)], Field(default=None)] m: type[RootModel] = create_model(type_name, __base__=(ConfiguredRootModel[ru],), __module__=me.__name__) elif len(r) == 1: m: type[BaseModel] = cast(type[BaseModel], r[0]) + if items[0].anno: + m = Annotated[m, items[0].anno] if not is_basemodel(m): m = create_model(type_name, __base__=(ConfiguredRootModel[m],), __module__=me.__name__) else: # == 0 @@ -298,7 +306,8 @@ def from_schema( r: list[_ClassInfo] = list() for _type in Model.types(schema): - r.append(Model.createClassInfo(schema, _type, schemanames, discriminators, extra)) + args = dict() + r.append(Model.createClassInfo(schema, _type, schemanames, discriminators, extra, args)) m = _ClassInfo.collapse(schema._get_identity("L8"), r) @@ -312,6 +321,7 @@ def createClassInfo( schemanames: list[str], discriminators: list["DiscriminatorType"], extra: list["SchemaType"] | None, + args: dict[str, Any] = None, ) -> _ClassInfo: from . import v20, v30, v31 @@ -325,9 +335,8 @@ def createClassInfo( for primitive types the anyOf/oneOf is taken care of in Model.createAnnotation """ if typing.get_origin(_t := Model.createAnnotation(schema, _type=_type)) != Literal: - classinfo.root = Annotated[_t, Model.createField(schema, _type=_type, args=None)] - else: - classinfo.root = _t + classinfo.anno = Model.createField(schema, _type=_type, args=args) + classinfo.root = _t elif _type == "array": """anyOf/oneOf is taken care in in createAnnotation""" classinfo.root = Model.createAnnotation(schema, _type="array") @@ -354,9 +363,8 @@ def createClassInfo( if _type in Model.types(i) ) if schema.discriminator and schema.discriminator.mapping: - classinfo.root = Annotated[ - Union[t], Field(discriminator=Model.nameof(schema.discriminator.propertyName)) - ] + classinfo.root = Union[t] + classinfo.anno = Field(discriminator=Model.nameof(schema.discriminator.propertyName)) else: if len(t): classinfo.root = Union[t] @@ -372,9 +380,8 @@ def createClassInfo( if _type in Model.types(i) ) if schema.discriminator and schema.discriminator.mapping: - classinfo.root = Annotated[ - Union[t], Field(discriminator=Model.nameof(schema.discriminator.propertyName)) - ] + classinfo.root = Union[t] + classinfo.anno = Field(discriminator=Model.nameof(schema.discriminator.propertyName)) else: if len(t): classinfo.root = Union[t] @@ -450,8 +457,8 @@ def validate_patternProperties(self_): """ assert isinstance(schema, v20.Schema) schema_ = v20.Schema(type="string", format="binary") - _t = Model.createAnnotation(schema_, _type="string") - classinfo.root = Annotated[_t, Model.createField(schema_, _type="string", args=None)] + classinfo.root = Model.createAnnotation(schema_, _type="string") + classinfo.anno = Model.createField(schema_, _type="string", args=None) else: raise ValueError(_type) @@ -713,7 +720,7 @@ def booleanFalse(schema: Optional["SchemaType"]) -> bool: raise ValueError(schema) @staticmethod - def createField(schema: "SchemaType", _type=None, args=None): + def createField(schema: "SchemaType", _type=None, args=None) -> Field: if args is None: args = dict(default=getattr(schema, "default", None)) diff --git a/tests/schema_test.py b/tests/schema_test.py index 5032b734..f15e894c 100644 --- a/tests/schema_test.py +++ b/tests/schema_test.py @@ -121,12 +121,10 @@ def test_schema_regex_engine(with_schema_regex_engine): import pydantic_core._pydantic_core with pytest.raises(pydantic_core._pydantic_core.SchemaError, match="error: unclosed character class$"): - annotations = typing.get_args(Root.model_fields["root"].annotation) - assert len(annotations) == 2 and annotations[0] is str and isinstance(annotations[1], FieldInfo), annotations - metadata = annotations[1].metadata - assert len(metadata) == 1, metadata - pattern = metadata[0].pattern - assert isinstance(pattern, str), pattern + t = Root.model_fields["root"] + assert t.annotation == str + assert isinstance(t.metadata[0].pattern, str) + pattern = t.metadata[0].pattern from typing import Annotated C = Annotated[str, pydantic.Field(pattern=pattern)] From 9470c9dacf7b457caab5460ab1cf7b651646d283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Sat, 15 Nov 2025 09:49:42 +0100 Subject: [PATCH 4/7] =?UTF-8?q?remove=20default=20argument=20from=20?= =?UTF-8?q?=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/aiopenapi3/model.py | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/aiopenapi3/model.py b/src/aiopenapi3/model.py index 0c1c54d6..f6b1f001 100644 --- a/src/aiopenapi3/model.py +++ b/src/aiopenapi3/model.py @@ -112,7 +112,6 @@ class _PropertyInfo: type_: str root: Any = None - anno: pydantic.Field = None config: dict[str, Any] = dataclasses.field(default_factory=dict) properties: dict[str, _PropertyInfo] = dataclasses.field( default_factory=lambda: collections.defaultdict(lambda: _ClassInfo._PropertyInfo()) @@ -257,17 +256,10 @@ def collapse(cls, type_name, items: list["_ClassInfo"]) -> type[BaseModel]: r = [i.model() for i in items] if len(r) > 1: - # Annotations are collected, last element has all of them - # ru: object = Annotated[Union[tuple(r)], items[-1].anno] - v = list() - for i in range(len(items)): - v.append(Annotated[r[i], items[i].anno]) - ru = Annotated[Union[tuple(v)], Field(default=None)] + ru = Annotated[Union[tuple(r)], Field(default=None)] m: type[RootModel] = create_model(type_name, __base__=(ConfiguredRootModel[ru],), __module__=me.__name__) elif len(r) == 1: m: type[BaseModel] = cast(type[BaseModel], r[0]) - if items[0].anno: - m = Annotated[m, items[0].anno] if not is_basemodel(m): m = create_model(type_name, __base__=(ConfiguredRootModel[m],), __module__=me.__name__) else: # == 0 @@ -335,8 +327,10 @@ def createClassInfo( for primitive types the anyOf/oneOf is taken care of in Model.createAnnotation """ if typing.get_origin(_t := Model.createAnnotation(schema, _type=_type)) != Literal: - classinfo.anno = Model.createField(schema, _type=_type, args=args) - classinfo.root = _t + classinfo.root = Annotated[_t, Model.createField(schema, _type=_type, args=args)] + else: + classinfo.root = _t + elif _type == "array": """anyOf/oneOf is taken care in in createAnnotation""" classinfo.root = Model.createAnnotation(schema, _type="array") @@ -363,8 +357,10 @@ def createClassInfo( if _type in Model.types(i) ) if schema.discriminator and schema.discriminator.mapping: - classinfo.root = Union[t] - classinfo.anno = Field(discriminator=Model.nameof(schema.discriminator.propertyName)) + classinfo.root = Annotated[ + Union[t], Field(discriminator=Model.nameof(schema.discriminator.propertyName)) + ] + else: if len(t): classinfo.root = Union[t] @@ -380,8 +376,9 @@ def createClassInfo( if _type in Model.types(i) ) if schema.discriminator and schema.discriminator.mapping: - classinfo.root = Union[t] - classinfo.anno = Field(discriminator=Model.nameof(schema.discriminator.propertyName)) + classinfo.root = Annotated[ + Union[t], Field(discriminator=Model.nameof(schema.discriminator.propertyName)) + ] else: if len(t): classinfo.root = Union[t] @@ -457,8 +454,8 @@ def validate_patternProperties(self_): """ assert isinstance(schema, v20.Schema) schema_ = v20.Schema(type="string", format="binary") - classinfo.root = Model.createAnnotation(schema_, _type="string") - classinfo.anno = Model.createField(schema_, _type="string", args=None) + _t = Model.createAnnotation(schema_, _type="string") + classinfo.root = Annotated[_t, Model.createField(schema_, _type="string", args=None)] else: raise ValueError(_type) From 6deca292d53456635bbae53a2c6f0a0b84a11cd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Sat, 15 Nov 2025 10:00:33 +0100 Subject: [PATCH 5/7] using default from schema --- src/aiopenapi3/model.py | 8 ++++---- tests/fixtures/schema-type-validators.yaml | 1 + tests/schema_test.py | 3 +++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/aiopenapi3/model.py b/src/aiopenapi3/model.py index f6b1f001..f6e160b9 100644 --- a/src/aiopenapi3/model.py +++ b/src/aiopenapi3/model.py @@ -250,13 +250,13 @@ def model(self) -> type[BaseModel] | type[None]: return m @classmethod - def collapse(cls, type_name, items: list["_ClassInfo"]) -> type[BaseModel]: + def collapse(cls, schema: "SchemaType", items: list["_ClassInfo"]) -> type[BaseModel]: r: list[type[BaseModel] | type[None]] - r = [i.model() for i in items] + type_name = schema._get_identity("L8") if len(r) > 1: - ru = Annotated[Union[tuple(r)], Field(default=None)] + ru = Annotated[Union[tuple(r)], Field(default=getattr(schema, "default", None))] m: type[RootModel] = create_model(type_name, __base__=(ConfiguredRootModel[ru],), __module__=me.__name__) elif len(r) == 1: m: type[BaseModel] = cast(type[BaseModel], r[0]) @@ -301,7 +301,7 @@ def from_schema( args = dict() r.append(Model.createClassInfo(schema, _type, schemanames, discriminators, extra, args)) - m = _ClassInfo.collapse(schema._get_identity("L8"), r) + m = _ClassInfo.collapse(schema, r) return cast(type[BaseModel], m) diff --git a/tests/fixtures/schema-type-validators.yaml b/tests/fixtures/schema-type-validators.yaml index 375001e3..44661561 100644 --- a/tests/fixtures/schema-type-validators.yaml +++ b/tests/fixtures/schema-type-validators.yaml @@ -22,3 +22,4 @@ components: minLength: 5 maximum: 10 minimum: 10 + default: 10 diff --git a/tests/schema_test.py b/tests/schema_test.py index f15e894c..de031d29 100644 --- a/tests/schema_test.py +++ b/tests/schema_test.py @@ -678,6 +678,9 @@ def test_schema_type_validators(with_schema_type_validators): with pytest.raises(ValidationError): v = t.model_validate("invalid") + v = t() + assert v.root == 10 + def test_schema_allof_string(with_schema_allof_string): api = OpenAPI("/", with_schema_allof_string) From 9cbf23880368023ca1a2d12ed467524efc959b86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Sat, 15 Nov 2025 10:22:22 +0100 Subject: [PATCH 6/7] default value --- src/aiopenapi3/model.py | 13 +++++++++---- tests/fixtures/schema-type-validators.yaml | 1 + tests/schema_test.py | 2 ++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/aiopenapi3/model.py b/src/aiopenapi3/model.py index f6e160b9..64f682a6 100644 --- a/src/aiopenapi3/model.py +++ b/src/aiopenapi3/model.py @@ -297,8 +297,14 @@ def from_schema( r: list[_ClassInfo] = list() - for _type in Model.types(schema): - args = dict() + types: list[str] = list(Model.types(schema)) + multi: bool = len(types) > 1 + for _type in types: + args = dict() if multi else None + """ + for schema with multiple types, the default value needs to be attached to the RootModel + providing empty args creates a FieldInfo without a default value for the subtypes + """ r.append(Model.createClassInfo(schema, _type, schemanames, discriminators, extra, args)) m = _ClassInfo.collapse(schema, r) @@ -360,7 +366,6 @@ def createClassInfo( classinfo.root = Annotated[ Union[t], Field(discriminator=Model.nameof(schema.discriminator.propertyName)) ] - else: if len(t): classinfo.root = Union[t] @@ -612,7 +617,7 @@ def createAnnotation( return rr @staticmethod - def types(schema: "SchemaType"): + def types(schema: "SchemaType") -> typing.Generator[str, None, None]: if isinstance(schema.type, str): yield schema.type if getattr(schema, "nullable", False): diff --git a/tests/fixtures/schema-type-validators.yaml b/tests/fixtures/schema-type-validators.yaml index 44661561..16a387d6 100644 --- a/tests/fixtures/schema-type-validators.yaml +++ b/tests/fixtures/schema-type-validators.yaml @@ -9,6 +9,7 @@ components: type: integer maximum: 10 minimum: 10 + default: 10 Number: type: number maximum: 10 diff --git a/tests/schema_test.py b/tests/schema_test.py index de031d29..d4e592b6 100644 --- a/tests/schema_test.py +++ b/tests/schema_test.py @@ -653,6 +653,8 @@ def test_schema_type_validators(with_schema_type_validators): v = t.model_validate("10") with pytest.raises(ValidationError): v = t.model_validate("9") + v = t() + assert v.root == 10 t = (m := api.components.schemas["Number"]).get_type() v = t.model_validate("10.") From b1007633f2a330afc6e28680c3a9a348e95ed63f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Wed, 4 Feb 2026 09:32:23 +0100 Subject: [PATCH 7/7] rebased & ruff`d --- .pre-commit-config.yaml | 4 ++-- requirements.txt | 4 ++-- src/aiopenapi3/v30/paths.py | 12 ++++++------ src/aiopenapi3/v31/paths.py | 12 ++++++------ src/aiopenapi3/v31/root.py | 2 +- tests/schema_test.py | 3 +-- 6 files changed, 18 insertions(+), 19 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ccb8e068..84561636 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -44,7 +44,7 @@ repos: args: [--py39-plus, --keep-runtime-typing] - repo: https://github.com/adrienverge/yamllint.git - rev: v1.37.1 + rev: v1.38.0 hooks: - id: yamllint args: ["-d", "{extends: relaxed, rules: {empty-lines: disable, line-length: {max: 1500}}}", --strict, --format, parsable] @@ -58,7 +58,7 @@ repos: - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: 0.9.13 + rev: 0.9.29 hooks: # Update the uv lockfile - id: uv-lock diff --git a/requirements.txt b/requirements.txt index 27168787..48c78da0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,9 +35,9 @@ multidict==6.7.0 # via yarl propcache==0.4.1 # via yarl -pydantic==2.11.9 +pydantic @ git+https://github.com/pydantic/pydantic.git@c42224acb31dd9f374124a74809332246e595194 # via aiopenapi3 -pydantic-core==2.33.2 +pydantic-core @ git+https://github.com/pydantic/pydantic.git@c42224acb31dd9f374124a74809332246e595194#subdirectory=pydantic-core # via pydantic pyyaml==6.0.3 # via aiopenapi3 diff --git a/src/aiopenapi3/v30/paths.py b/src/aiopenapi3/v30/paths.py index 2542bf93..d2b48008 100644 --- a/src/aiopenapi3/v30/paths.py +++ b/src/aiopenapi3/v30/paths.py @@ -39,12 +39,12 @@ class Link(ObjectExtended): @model_validator(mode="after") def validate_Link_operation(self): # type: ignore[name-defined] - assert not ( - self.operationId != None and self.operationRef != None - ), "operationId and operationRef are mutually exclusive, only one of them is allowed" - assert not ( - self.operationId == self.operationRef == None - ), "operationId and operationRef are mutually exclusive, one of them must be specified" + assert not (self.operationId is not None and self.operationRef is not None), ( + "operationId and operationRef are mutually exclusive, only one of them is allowed" + ) + assert not (self.operationId == self.operationRef is None), ( + "operationId and operationRef are mutually exclusive, one of them must be specified" + ) return self diff --git a/src/aiopenapi3/v31/paths.py b/src/aiopenapi3/v31/paths.py index 1d798a70..c65628aa 100644 --- a/src/aiopenapi3/v31/paths.py +++ b/src/aiopenapi3/v31/paths.py @@ -39,12 +39,12 @@ class Link(ObjectExtended): @model_validator(mode="after") def validate_Link_operation(self): # type: ignore[name-defined] - assert not ( - self.operationId != None and self.operationRef != None - ), "operationId and operationRef are mutually exclusive, only one of them is allowed" - assert not ( - self.operationId == self.operationRef == None - ), "operationId and operationRef are mutually exclusive, one of them must be specified" + assert not (self.operationId is not None and self.operationRef is not None), ( + "operationId and operationRef are mutually exclusive, only one of them is allowed" + ) + assert not (self.operationId == self.operationRef is None), ( + "operationId and operationRef are mutually exclusive, one of them must be specified" + ) return self diff --git a/src/aiopenapi3/v31/root.py b/src/aiopenapi3/v31/root.py index e2f9272b..e596a4a5 100644 --- a/src/aiopenapi3/v31/root.py +++ b/src/aiopenapi3/v31/root.py @@ -35,7 +35,7 @@ class Root(ObjectExtended, RootBase): externalDocs: dict[Any, Any] = Field(default_factory=dict) @model_validator(mode="after") - def validate_Root(self) -> "Self": + def validate_Root(self) -> "Self": # noqa: F821 assert self.paths or self.components or self.webhooks return self diff --git a/tests/schema_test.py b/tests/schema_test.py index d4e592b6..05db6be8 100644 --- a/tests/schema_test.py +++ b/tests/schema_test.py @@ -3,7 +3,6 @@ import uuid from datetime import datetime -from pydantic.fields import FieldInfo from pathlib import Path @@ -122,7 +121,7 @@ def test_schema_regex_engine(with_schema_regex_engine): with pytest.raises(pydantic_core._pydantic_core.SchemaError, match="error: unclosed character class$"): t = Root.model_fields["root"] - assert t.annotation == str + assert t.annotation is str assert isinstance(t.metadata[0].pattern, str) pattern = t.metadata[0].pattern from typing import Annotated