From 1d7972a8a5db8bd3836711775757aa49b0f849c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Wed, 2 Sep 2026 10:15:05 +0200 Subject: [PATCH] tests - v32 coverage includes various fixes for v32 --- src/aiopenapi3/_types.py | 24 ++++----- src/aiopenapi3/base.py | 16 +++--- src/aiopenapi3/model.py | 6 +-- src/aiopenapi3/openapi.py | 18 +++---- src/aiopenapi3/v30/glue.py | 20 ++++++-- src/aiopenapi3/v32/parameter.py | 4 +- src/aiopenapi3/v32/paths.py | 2 +- tests/conftest.py | 6 ++- tests/fixtures/schema-itemSchema.yaml | 2 +- tests/fixtures/schema-nullable-v32.yaml | 50 +++++++++++++++++++ tests/fixtures/schema-oneOf-nullable-v32.yaml | 33 ++++++++++++ tests/parsing_test.py | 7 ++- tests/ref_test.py | 7 ++- 13 files changed, 152 insertions(+), 43 deletions(-) create mode 100644 tests/fixtures/schema-nullable-v32.yaml create mode 100644 tests/fixtures/schema-oneOf-nullable-v32.yaml diff --git a/src/aiopenapi3/_types.py b/src/aiopenapi3/_types.py index eeab9a72..d6997579 100644 --- a/src/aiopenapi3/_types.py +++ b/src/aiopenapi3/_types.py @@ -21,23 +21,23 @@ RequestParameter = str | BaseModel RequestParameters = dict[str, RequestParameter] -RootType = v20.Root | v30.Root | v31.Root -ServerType = v30.Server | v31.Server -ReferenceType = v20.Reference | v30.Reference | v31.Reference -SchemaType = v20.Schema | v30.Schema | v31.Schema -v3xSchemaType = v30.Schema | v31.Schema -DiscriminatorType = v30.Discriminator | v31.Discriminator -PathItemType = v20.PathItem, v30.PathItem | v31.PathItem -OperationType = v20.Operation | v30.Operation | v31.Operation -ParameterType = v20.Parameter | v30.Parameter | v31.Parameter -HeaderType = v20.Header | v30.Header | v31.Header +RootType = v20.Root | v30.Root | v31.Root | v32.Root +ServerType = v30.Server | v31.Server | v32.Server +ReferenceType = v20.Reference | v30.Reference | v31.Reference | v32.Reference +SchemaType = v20.Schema | v30.Schema | v31.Schema | v32.Schema +v3xSchemaType = v30.Schema | v31.Schema | v32.Schema +DiscriminatorType = v30.Discriminator | v31.Discriminator | v32.Discriminator +PathItemType = v20.PathItem, v30.PathItem | v31.PathItem | v32.PathItem +OperationType = v20.Operation | v30.Operation | v31.Operation | v32.Operation +ParameterType = v20.Parameter | v30.Parameter | v31.Parameter | v32.Parameter +HeaderType = v20.Header | v30.Header | v31.Header | v32.Header RequestType = v20.Request | v30.Request AsyncRequestType = v20.AsyncRequest | v30.AsyncRequest -MediaTypeType = v30.MediaType | v31.MediaType +MediaTypeType = v30.MediaType | v31.MediaType | v32.MediaType ExpectedType = v20.Response | MediaTypeType ResponseHeadersType = dict[str, str | BaseModel | list[BaseModel]] ResponseDataType = BaseModel, bytes | str -TagType = v20.Tag | v30.Tag | v32.Tag +TagType = v20.Tag | v30.Tag | v31.Tag | v32.Tag YAMLLoaderType = type[yaml.Loader] | type[yaml.CLoader] | type[yaml.SafeLoader] | type[yaml.CSafeLoader] diff --git a/src/aiopenapi3/base.py b/src/aiopenapi3/base.py index 60efd4b9..29499c78 100644 --- a/src/aiopenapi3/base.py +++ b/src/aiopenapi3/base.py @@ -85,7 +85,7 @@ class PathItemBase: class RootBase: @staticmethod def resolve(api: "OpenAPI", root: "RootBase", obj, _PathItem, _Reference): - from . import v20, v30, v31 + from . import v20, v30, v31, v32 def replaceSchemaReference(data): def replace(ivalue): @@ -121,14 +121,14 @@ def replace(ivalue): continue # v3.1 - Schema $ref - if isinstance(root, (v20.root.Root, v30.root.Root, v31.root.Root)): # noqa: SIM102 + if isinstance(root, (v20.root.Root, v30.root.Root, v31.root.Root, v32.root.Root)): # noqa: SIM102 if isinstance(value, SchemaBase): # noqa: SIM102 if (r := getattr(value, "ref", None)) and not isinstance(r, ReferenceBase): value = _Reference.model_construct(ref=r) setattr(obj, slot, value) - if isinstance(root, (v30.root.Root, v31.root.Root)): # noqa: SIM102 - if isinstance(value, (v30.Discriminator, v31.Discriminator)): + if isinstance(root, (v30.root.Root, v31.root.Root, v32.root.Root)): # noqa: SIM102 + if isinstance(value, (v30.Discriminator, v31.Discriminator, v32.Discriminator)): """ Discriminated Unions - implementing undefined behavior sub-schemas not having the discriminated property "const" or enum or mismatching the mapping @@ -192,7 +192,7 @@ def replace(ivalue): PathItem Ref is ambiguous https://github.com/OAI/OpenAPI-Specification/issues/2635 """ - if isinstance(root, (v20.root.Root, v30.root.Root, v31.root.Root)): # noqa: SIM102 + if isinstance(root, (v20.root.Root, v30.root.Root, v31.root.Root, v32.root.Root)): # noqa: SIM102 if isinstance(obj, _PathItem) and slot == "ref": ref = _Reference.model_construct(ref=value) ref._target = api.resolve_jr(root, obj, ref) @@ -216,7 +216,7 @@ def replace(ivalue): else: raise TypeError(type(value), value) elif isinstance(obj, dict): - if isinstance(root, (v20.root.Root, v31.root.Root)): + if isinstance(root, (v20.root.Root, v31.root.Root, v32.root.Root)): """ Resolving/Replacing Swagger 2.0 nested Schema.ref Schema.properties[name] -> Schema.ref ==> Schema.properties[name] -> Reference @@ -231,7 +231,7 @@ def replace(ivalue): RootBase.resolve(api, root, v, _PathItem, _Reference) elif isinstance(obj, list): - if isinstance(root, (v20.root.Root, v31.root.Root)): + if isinstance(root, (v20.root.Root, v31.root.Root, v32.root.Root)): replaceSchemaReference(obj) # if it's a list, resolve its item's references @@ -338,7 +338,7 @@ class SchemaBase(BaseModel): _model_types is used to store these different model representations of the same schema """ - _identity: str = PrivateAttr(default=None) + _identity: str | None = PrivateAttr(default=None) """ The _identity attribute is set during OpenAPI.__init__ and used to create the class name in get_type() """ diff --git a/src/aiopenapi3/model.py b/src/aiopenapi3/model.py index 1c9be864..92c717bb 100644 --- a/src/aiopenapi3/model.py +++ b/src/aiopenapi3/model.py @@ -314,7 +314,7 @@ def createClassInfo( extra: list["SchemaType"] | None, args: dict[str, Any] | None = None, ) -> _ClassInfo: - from . import v20, v30, v31 + from . import v20, v30, v31, v32 type_name = schema._get_identity("L8") # + f"_{type}" @@ -345,7 +345,7 @@ def createClassInfo( if hasattr(schema, "anyOf") and schema.anyOf: assert all(schema.anyOf) - assert isinstance(schema, (v30.Schema, v31.Schema)) + assert isinstance(schema, (v30.Schema, v31.Schema, v32.Schema)) t = tuple( i.get_type( names=schemanames + ([cast(str, i.ref)] if isinstance(i, ReferenceBase) else []), @@ -363,7 +363,7 @@ def createClassInfo( if len(t): classinfo.root = Union[t] elif hasattr(schema, "oneOf") and schema.oneOf: - assert isinstance(schema, (v30.Schema, v31.Schema)) + assert isinstance(schema, (v30.Schema, v31.Schema, v32.Schema)) t = tuple( i.get_type( names=schemanames + ([cast(str, i.ref)] if isinstance(i, ReferenceBase) else []), diff --git a/src/aiopenapi3/openapi.py b/src/aiopenapi3/openapi.py index 8ea5629a..58f2ef5c 100644 --- a/src/aiopenapi3/openapi.py +++ b/src/aiopenapi3/openapi.py @@ -37,7 +37,7 @@ ) -def has_components(y: Optional["RootType"]) -> TypeGuard[v30.Root | v31.Root]: +def has_components(y: Optional["RootType"]) -> TypeGuard[v30.Root | v31.Root | v32.Root]: # return all([typing.cast("RootType", y), typing.cast("RootType", y).components]) # return isinstance(y, (v30.Root, v31.Root)) # return all([y, y.components]) @@ -47,7 +47,7 @@ def has_components(y: Optional["RootType"]) -> TypeGuard[v30.Root | v31.Root]: def is_schema(v: tuple[str, "SchemaType"]) -> TypeGuard["SchemaType"]: - return isinstance(v[1], (v20.Schema, v30.Schema, v31.Schema)) + return isinstance(v[1], (v20.Schema, v30.Schema, v31.Schema, v32.Schema)) class OpenAPI: @@ -414,7 +414,7 @@ def _init_operationindex(self, use_operation_tags: bool) -> bool: for c, content in response.content.items(): if content.schema_ is None: continue - if isinstance(content.schema_, (v30.Schema, v31.Schema)): + if isinstance(content.schema_, (v30.Schema, v31.Schema, v32.Schema)): content.schema_._get_identity("OP", f"{path}.{m}.{r}.{c}") else: if isinstance(self._root, v30.Root): @@ -480,9 +480,6 @@ def _iterate_schemas(cls, schemas: dict[int, "SchemaType"], next_set: set[int], def _init_schema_types_collect(self, only_required: bool) -> dict[str, "SchemaType"]: byname: dict[str, SchemaType] = {} - def is_schema(v: tuple[str, "SchemaType"]) -> bool: - return isinstance(v[1], (v20.Schema, v30.Schema, v31.Schema)) - op: Operation if isinstance(self._root, v20.Root): documents = cast(list[v20.Root], self._documents.values()) @@ -520,9 +517,9 @@ def is_schema(v: tuple[str, "SchemaType"]) -> bool: # assert byname.get(name, None) in [None, response.schema_] byname[n] = response.schema_ - elif isinstance(self._root, (v30.Root, v31.Root)): + elif isinstance(self._root, (v30.Root, v31.Root, v32.Root)): # Schema - documents = cast(list[v30.Root] | list[v31.Root], self._documents.values()) + documents = cast(list[v30.Root] | list[v31.Root] | list[v32.Root], self._documents.values()) components = [x.components for x in filter(has_components, documents) if x.components is not None] assert components is not None if only_required is False: @@ -570,7 +567,7 @@ def is_schema(v: tuple[str, "SchemaType"]) -> bool: for r, response in op.responses.items(): if isinstance(response, ReferenceBase): response = response._target - if isinstance(response, (v30.paths.Response, v31.paths.Response)): + if isinstance(response, (v30.paths.Response, v31.paths.Response, v32.paths.Response)): assert response.content is not None for mt, mto in response.content.items(): if mto.schema_ is None: @@ -608,6 +605,7 @@ def _init_schema_types(self, only_required: bool) -> None: """ Due to Plugins (e.g. Cull/Reduce) byname may be incomplete """ + # from . import v32 resolved: list[SchemaType] = [ byid[x]._target if isinstance(byid[x], ReferenceBase) else byid[x] for x in todo | data ] @@ -692,7 +690,7 @@ def authenticate(self, *args, **kwargs): if isinstance(self._root, v20.Root): v = schemes - frozenset(SecuritySchemes := self._root.securityDefinitions) - elif isinstance(self._root, (v30.Root, v31.Root)): + elif isinstance(self._root, (v30.Root, v31.Root, v32.Root)): v = schemes - frozenset(SecuritySchemes := self._root.components.securitySchemes) else: raise TypeError(self._root) diff --git a/src/aiopenapi3/v30/glue.py b/src/aiopenapi3/v30/glue.py index cb4d6218..123ed877 100644 --- a/src/aiopenapi3/v30/glue.py +++ b/src/aiopenapi3/v30/glue.py @@ -145,10 +145,17 @@ def _prepare_secschemes_default(self, scheme: str, value: str | Sequence[str]) - and self.root.components.securitySchemes[scheme].root ) ss = self.root.components.securitySchemes[scheme].root - from .. import v30, v31 + from .. import v30, v31, v32 if ss.type == "http": - assert isinstance(ss, (v30.security._SecuritySchemes.http, v31.security._SecuritySchemes.http)) + assert isinstance( + ss, + ( + v30.security._SecuritySchemes.http, + v31.security._SecuritySchemes.http, + v32.security._SecuritySchemes.http, + ), + ) if ss.scheme_ == "basic": self.req.auth = httpx2.BasicAuth(*value) elif ss.scheme_ == "digest": @@ -164,7 +171,14 @@ def _prepare_secschemes_default(self, scheme: str, value: str | Sequence[str]) - value = cast(str, value) if ss.type == "apiKey": - assert isinstance(ss, (v30.security._SecuritySchemes.apiKey, v31.security._SecuritySchemes.apiKey)) + assert isinstance( + ss, + ( + v30.security._SecuritySchemes.apiKey, + v31.security._SecuritySchemes.apiKey, + v32.security._SecuritySchemes.apiKey, + ), + ) if ss.in_ == "query": # apiKey in query parameter self.req.params[ss.name] = value diff --git a/src/aiopenapi3/v32/parameter.py b/src/aiopenapi3/v32/parameter.py index fedc14e3..d7ffcca1 100644 --- a/src/aiopenapi3/v32/parameter.py +++ b/src/aiopenapi3/v32/parameter.py @@ -66,8 +66,8 @@ class Header(ParameterBase, _ParameterCodec): .. _here: https://spec.openapis.org/oas/v3.2.0.html#header-object """ - allowEmptyValue: None - allowReserved: None + allowEmptyValue: None = None + allowReserved: None = None def _codec(self): schema = self.schema_ or self.content.get("application/json").schema_ diff --git a/src/aiopenapi3/v32/paths.py b/src/aiopenapi3/v32/paths.py index c71f862c..2e5de485 100644 --- a/src/aiopenapi3/v32/paths.py +++ b/src/aiopenapi3/v32/paths.py @@ -160,7 +160,7 @@ class Callback(RootModel): The key that identifies the Path Item Object is a runtime expression that can be evaluated in the context of a runtime HTTP request/response to identify the URL to be used for the callback request. """ - root: dict["RuntimeExpression", PathItem] + root: dict[str, PathItem] class RuntimeExpression(RootModel): diff --git a/tests/conftest.py b/tests/conftest.py index 868e43f7..b1178b3b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,7 +42,11 @@ def schema(self): return getattr(aiopenapi3, f"v{self.major}{self.minor}").Schema -@pytest.fixture(scope="session", params=[_Version(3, 0, 3), _Version(3, 1, 0)], ids=("v30", "v31")) +@pytest.fixture( + scope="session", + params=[_Version(3, 0, 3), _Version(3, 1, 0), _Version(3, 2, 0)], + ids=("v30", "v31", "v32"), +) def openapi_version(request): return request.param diff --git a/tests/fixtures/schema-itemSchema.yaml b/tests/fixtures/schema-itemSchema.yaml index fa718395..1b4dff9f 100644 --- a/tests/fixtures/schema-itemSchema.yaml +++ b/tests/fixtures/schema-itemSchema.yaml @@ -123,7 +123,7 @@ paths: content: application/jsonl: itemSchema: - $ref: "#/components/responses/LogEntry" + $ref: "#/components/schemas/LogEntry" /ndjson: get: diff --git a/tests/fixtures/schema-nullable-v32.yaml b/tests/fixtures/schema-nullable-v32.yaml new file mode 100644 index 00000000..6dd97d10 --- /dev/null +++ b/tests/fixtures/schema-nullable-v32.yaml @@ -0,0 +1,50 @@ +openapi: 3.2.0 +info: + title: '' + version: 0.0.0 +servers: + - url: http://127.0.0.1/api + +security: + - {} + +paths: {} + +components: + schemas: + object: + type: [object, "null"] + additionalProperties: false + properties: + attr: + $ref: '#/components/schemas/nullable' + required: + - attr + + array: + type: [array, "null"] + items: + $ref: '#/components/schemas/nullable' + + union: + oneOf: + - $ref: '#/components/schemas/string' + - $ref: '#/components/schemas/integer' + + string: + type: [string, "null"] + + integer: + type: [integer, "null"] + + boolean: + type: [boolean, "null"] + + nullable: + type: [string, "null"] + + multi: + type: [integer, string, "null"] + + "null": + type: "null" diff --git a/tests/fixtures/schema-oneOf-nullable-v32.yaml b/tests/fixtures/schema-oneOf-nullable-v32.yaml new file mode 100644 index 00000000..f27a1bc4 --- /dev/null +++ b/tests/fixtures/schema-oneOf-nullable-v32.yaml @@ -0,0 +1,33 @@ +openapi: 3.2.0 +info: + title: '' + version: 0.0.0 +servers: + - url: http://127.0.0.1/api + +security: + - {} + +paths: {} + +components: + schemas: + object: + type: object + additionalProperties: false + properties: + typed: + oneOf: + - type: string + enum: + - "5" + - type: string + enum: + - "4" + - type: "null" + + enumed: + oneOf: + - type: string + enum: ["5"] + - enum: [null] diff --git a/tests/parsing_test.py b/tests/parsing_test.py index acd8afa1..67aad33c 100644 --- a/tests/parsing_test.py +++ b/tests/parsing_test.py @@ -168,8 +168,13 @@ def test_schema_array(with_schema_array): def test_parsing_paths_content_nested_array_ref(openapi_version, with_parsing_paths_content_nested_array_ref): import aiopenapi3.v30.general import aiopenapi3.v31.general + import aiopenapi3.v32.general - expected = {0: aiopenapi3.v30.general.Reference, 1: aiopenapi3.v31.general.Reference}[openapi_version.minor] + expected = { + 0: aiopenapi3.v30.general.Reference, + 1: aiopenapi3.v31.general.Reference, + 2: aiopenapi3.v32.general.Reference, + }[openapi_version.minor] OpenAPI("/", with_parsing_paths_content_nested_array_ref) diff --git a/tests/ref_test.py b/tests/ref_test.py index 7353b086..85cf4f2c 100644 --- a/tests/ref_test.py +++ b/tests/ref_test.py @@ -91,8 +91,13 @@ def is_required(x): def test_paths_content_schema_array_ref(openapi_version): import aiopenapi3.v30.general import aiopenapi3.v31.general + import aiopenapi3.v32.general - expected = {0: aiopenapi3.v30.general.Reference, 1: aiopenapi3.v31.general.Reference}[openapi_version.minor] + expected = { + 0: aiopenapi3.v30.general.Reference, + 1: aiopenapi3.v31.general.Reference, + 2: aiopenapi3.v32.general.Reference, + }[openapi_version.minor] SPEC = f"""openapi: {openapi_version} info: