From 07955a5cdeeb0efb03919cd6b829957b2ffa2a81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Sun, 3 Sep 2023 16:57:05 +0200 Subject: [PATCH 1/8] prepare 0.4.0 --- aiopenapi3/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiopenapi3/version.py b/aiopenapi3/version.py index 493f7415..dfe2f13a 100644 --- a/aiopenapi3/version.py +++ b/aiopenapi3/version.py @@ -1 +1 @@ -__version__ = "0.3.0" +__version__ = "0.4.0a1" From 8cec6384910bb7f9368458131dc175861241a9d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Tue, 5 Sep 2023 06:12:12 +0200 Subject: [PATCH 2/8] license - restore BSD-3-Clause Dorthu/openapi3 had "BSD 3-Clause License" in setup.py migrating to setup.cfg license was left empty migrating to pyproject.toml license was changed to MIT --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 24528629..48e558f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ ] requires-python = ">=3.8" readme = "README.md" -license = {text = "MIT"} +license = {text = "BSD-3-Clause"} classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Web Environment", From 4a37b6938b1400a9ccc6e279951e972eaec80122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Tue, 5 Sep 2023 07:15:59 +0200 Subject: [PATCH 3/8] release - prepare 0.4.0a3 --- aiopenapi3/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiopenapi3/version.py b/aiopenapi3/version.py index dfe2f13a..4fbec93e 100644 --- a/aiopenapi3/version.py +++ b/aiopenapi3/version.py @@ -1 +1 @@ -__version__ = "0.4.0a1" +__version__ = "0.4.0a3" From c6a804a93e026f47998ddb4eb9f4613f75bbab0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Fri, 1 Sep 2023 08:16:16 +0200 Subject: [PATCH 4/8] mypy - reduce None for models do not use Optional for complex data types with default_factory --- aiopenapi3/v20/paths.py | 12 ++++++------ aiopenapi3/v20/root.py | 16 ++++++++-------- aiopenapi3/v20/schemas.py | 6 +++--- aiopenapi3/v30/components.py | 22 ++++++++++------------ aiopenapi3/v30/media.py | 6 +++--- aiopenapi3/v30/parameter.py | 4 ++-- aiopenapi3/v30/paths.py | 14 +++++++------- aiopenapi3/v30/root.py | 12 ++++++------ aiopenapi3/v30/schemas.py | 12 ++++++------ aiopenapi3/v30/servers.py | 2 +- aiopenapi3/v31/components.py | 24 +++++++++++------------- aiopenapi3/v31/media.py | 6 +++--- aiopenapi3/v31/parameter.py | 2 +- aiopenapi3/v31/paths.py | 14 +++++++------- aiopenapi3/v31/root.py | 12 ++++++------ aiopenapi3/v31/schemas.py | 16 ++++++++-------- aiopenapi3/v31/servers.py | 2 +- 17 files changed, 89 insertions(+), 93 deletions(-) diff --git a/aiopenapi3/v20/paths.py b/aiopenapi3/v20/paths.py index 12ff35e9..2b6becb2 100644 --- a/aiopenapi3/v20/paths.py +++ b/aiopenapi3/v20/paths.py @@ -19,7 +19,7 @@ class Response(ObjectExtended): description: str = Field(...) schema_: Optional[Schema] = Field(default=None, alias="schema") - headers: Optional[Dict[str, Header]] = Field(default_factory=dict) + headers: Dict[str, Header] = Field(default_factory=dict) examples: Optional[Dict[str, Any]] = Field(default=None) @@ -35,11 +35,11 @@ class Operation(ObjectExtended, OperationBase): description: Optional[str] = Field(default=None) externalDocs: Optional[ExternalDocumentation] = Field(default=None) operationId: Optional[str] = Field(default=None) - consumes: Optional[List[str]] = Field(default_factory=list) - produces: Optional[List[str]] = Field(default_factory=list) - parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) + consumes: List[str] = Field(default_factory=list) + produces: List[str] = Field(default_factory=list) + parameters: List[Union[Parameter, Reference]] = Field(default_factory=list) responses: Dict[str, Union[Reference, Response]] = Field(default_factory=dict) - schemes: Optional[List[str]] = Field(default_factory=list) + schemes: List[str] = Field(default_factory=list) deprecated: Optional[bool] = Field(default=None) security: Optional[List[SecurityRequirement]] = Field(default=None) @@ -60,7 +60,7 @@ class PathItem(ObjectExtended): options: Optional[Operation] = Field(default=None) head: Optional[Operation] = Field(default=None) patch: Optional[Operation] = Field(default=None) - parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) + parameters: List[Union[Parameter, Reference]] = Field(default_factory=list) class Paths(PathsBase): diff --git a/aiopenapi3/v20/root.py b/aiopenapi3/v20/root.py index 09fa6173..2842f0b9 100644 --- a/aiopenapi3/v20/root.py +++ b/aiopenapi3/v20/root.py @@ -23,16 +23,16 @@ class Root(ObjectExtended, RootBase): info: Info = Field(...) host: Optional[str] = Field(default=None) basePath: Optional[str] = Field(default=None) - schemes: Optional[List[str]] = Field(default_factory=list) - consumes: Optional[List[str]] = Field(default_factory=list) - produces: Optional[List[str]] = Field(default_factory=list) + schemes: List[str] = Field(default_factory=list) + consumes: List[str] = Field(default_factory=list) + produces: List[str] = Field(default_factory=list) paths: Paths = Field(default_factory=dict) - definitions: Optional[Dict[str, Schema]] = Field(default_factory=dict) - parameters: Optional[Dict[str, Parameter]] = Field(default_factory=dict) - responses: Optional[Dict[str, Response]] = Field(default_factory=dict) - securityDefinitions: Optional[Dict[str, SecurityScheme]] = Field(default_factory=dict) + definitions: Dict[str, Schema] = Field(default_factory=dict) + parameters: Dict[str, Parameter] = Field(default_factory=dict) + responses: Dict[str, Response] = Field(default_factory=dict) + securityDefinitions: Dict[str, SecurityScheme] = Field(default_factory=dict) security: Optional[List[SecurityRequirement]] = Field(default=None) - tags: Optional[List[Tag]] = Field(default_factory=list) + tags: List[Tag] = Field(default_factory=list) externalDocs: Optional[ExternalDocumentation] = Field(default=None) def _resolve_references(self, api): diff --git a/aiopenapi3/v20/schemas.py b/aiopenapi3/v20/schemas.py index 44011329..54aad699 100644 --- a/aiopenapi3/v20/schemas.py +++ b/aiopenapi3/v20/schemas.py @@ -33,13 +33,13 @@ class Schema(ObjectExtended, SchemaBase): uniqueItems: Optional[bool] = Field(default=None) maxProperties: Optional[int] = Field(default=None) minProperties: Optional[int] = Field(default=None) - required: Optional[List[str]] = Field(default_factory=list) + required: List[str] = Field(default_factory=list) enum: Optional[List[Any]] = Field(default=None) type: Optional[str] = Field(default=None) items: Optional[Union[List[Union["Schema", Reference]], Union["Schema", Reference]]] = Field(default=None) - allOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) - properties: Optional[Dict[str, Union["Schema", Reference]]] = Field(default_factory=dict) + allOf: List[Union["Schema", Reference]] = Field(default_factory=list) + properties: Dict[str, Union["Schema", Reference]] = Field(default_factory=dict) additionalProperties: Optional[Union[bool, "Schema", Reference]] = Field(default=None) discriminator: Optional[str] = Field(default=None) # 'Discriminator' diff --git a/aiopenapi3/v30/components.py b/aiopenapi3/v30/components.py index 160b55d2..2eff6b1d 100644 --- a/aiopenapi3/v30/components.py +++ b/aiopenapi3/v30/components.py @@ -1,4 +1,4 @@ -from typing import Union, Optional, Dict +from typing import Union, Dict from pydantic import Field @@ -20,14 +20,12 @@ class Components(ObjectExtended): .. _Components Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#components-object """ - model_config = dict(undefined_types_warning=False) - - schemas: Optional[Dict[str, Union[Schema, Reference]]] = Field(default_factory=dict) - responses: Optional[Dict[str, Union[Response, Reference]]] = Field(default_factory=dict) - parameters: Optional[Dict[str, Union[Parameter, Reference]]] = Field(default_factory=dict) - examples: Optional[Dict[str, Union[Example, Reference]]] = Field(default_factory=dict) - requestBodies: Optional[Dict[str, Union[RequestBody, Reference]]] = Field(default_factory=dict) - headers: Optional[Dict[str, Union[Header, Reference]]] = Field(default_factory=dict) - securitySchemes: Optional[Dict[str, Union[SecurityScheme, Reference]]] = Field(default_factory=dict) - links: Optional[Dict[str, Union[Link, Reference]]] = Field(default_factory=dict) - callbacks: Optional[Dict[str, Union[Callback, Reference]]] = Field(default_factory=dict) + schemas: Dict[str, Union[Schema, Reference]] = Field(default_factory=dict) + responses: Dict[str, Union[Response, Reference]] = Field(default_factory=dict) + parameters: Dict[str, Union[Parameter, Reference]] = Field(default_factory=dict) + examples: Dict[str, Union[Example, Reference]] = Field(default_factory=dict) + requestBodies: Dict[str, Union[RequestBody, Reference]] = Field(default_factory=dict) + headers: Dict[str, Union[Header, Reference]] = Field(default_factory=dict) + securitySchemes: Dict[str, Union[SecurityScheme, Reference]] = Field(default_factory=dict) + links: Dict[str, Union[Link, Reference]] = Field(default_factory=dict) + callbacks: Dict[str, Union[Callback, Reference]] = Field(default_factory=dict) diff --git a/aiopenapi3/v30/media.py b/aiopenapi3/v30/media.py index 131156af..39e51558 100644 --- a/aiopenapi3/v30/media.py +++ b/aiopenapi3/v30/media.py @@ -19,7 +19,7 @@ class Encoding(ObjectExtended): model_config = dict(undefined_types_warning=False) contentType: Optional[str] = Field(default=None) - headers: Optional[Dict[str, Union["Header", Reference]]] = Field(default_factory=dict) + headers: Dict[str, Union["Header", Reference]] = Field(default_factory=dict) style: Optional[str] = Field(default=None) explode: Optional[bool] = Field(default=None) allowReserved: Optional[bool] = Field(default=None) @@ -37,5 +37,5 @@ class MediaType(ObjectExtended): schema_: Optional[Union[Schema, Reference]] = Field(default=None, alias="schema") example: Optional[Any] = Field(default=None) # 'any' type - examples: Optional[Dict[str, Union[Example, Reference]]] = Field(default_factory=dict) - encoding: Optional[Dict[str, Encoding]] = Field(default_factory=dict) + examples: Dict[str, Union[Example, Reference]] = Field(default_factory=dict) + encoding: Dict[str, Encoding] = Field(default_factory=dict) diff --git a/aiopenapi3/v30/parameter.py b/aiopenapi3/v30/parameter.py index 71ce1f92..cb2229e2 100644 --- a/aiopenapi3/v30/parameter.py +++ b/aiopenapi3/v30/parameter.py @@ -248,9 +248,9 @@ class ParameterBase(ObjectExtended, ParameterBase_): allowReserved: Optional[bool] = Field(default=None) schema_: Optional[Union[Schema, Reference]] = Field(default=None, alias="schema") example: Optional[Any] = Field(default=None) - examples: Optional[Dict[str, Union["Example", Reference]]] = Field(default_factory=dict) + examples: Dict[str, Union["Example", Reference]] = Field(default_factory=dict) - content: Optional[Dict[str, "MediaType"]] = Field(default_factory=dict) + content: Dict[str, "MediaType"] = Field(default_factory=dict) class _In(str, enum.Enum): diff --git a/aiopenapi3/v30/paths.py b/aiopenapi3/v30/paths.py index 908a07b2..1567bc54 100644 --- a/aiopenapi3/v30/paths.py +++ b/aiopenapi3/v30/paths.py @@ -62,9 +62,9 @@ class Response(ObjectExtended): model_config = dict(undefined_types_warning=False) description: str = Field(...) - headers: Optional[Dict[str, Union[Header, Reference]]] = Field(default_factory=dict) - content: Optional[Dict[str, MediaType]] = Field(default_factory=dict) - links: Optional[Dict[str, Union[Link, Reference]]] = Field(default_factory=dict) + headers: Dict[str, Union[Header, Reference]] = Field(default_factory=dict) + content: Dict[str, MediaType] = Field(default_factory=dict) + links: Dict[str, Union[Link, Reference]] = Field(default_factory=dict) class Operation(ObjectExtended, OperationBase): @@ -81,12 +81,12 @@ class Operation(ObjectExtended, OperationBase): description: Optional[str] = Field(default=None) externalDocs: Optional[ExternalDocumentation] = Field(default=None) operationId: Optional[str] = Field(default=None) - parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) + parameters: List[Union[Parameter, Reference]] = Field(default_factory=list) requestBody: Optional[Union[RequestBody, Reference]] = Field(default=None) responses: Dict[str, Union[Response, Reference]] = Field(...) - callbacks: Optional[Dict[str, Union["Callback", Reference]]] = Field(default_factory=dict) + callbacks: Dict[str, Union["Callback", Reference]] = Field(default_factory=dict) deprecated: Optional[bool] = Field(default=None) - security: Optional[List[SecurityRequirement]] = Field(default_factory=list) + security: List[SecurityRequirement] = Field(default_factory=list) servers: Optional[List[Server]] = Field(default=None) @@ -112,7 +112,7 @@ class PathItem(ObjectExtended): patch: Optional[Operation] = Field(default=None) trace: Optional[Operation] = Field(default=None) servers: Optional[List[Server]] = Field(default=None) - parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) + parameters: List[Union[Parameter, Reference]] = Field(default_factory=list) class Paths(PathsBase): diff --git a/aiopenapi3/v30/root.py b/aiopenapi3/v30/root.py index c670e12a..e0587da0 100644 --- a/aiopenapi3/v30/root.py +++ b/aiopenapi3/v30/root.py @@ -1,4 +1,4 @@ -from typing import Any, List, Optional, Dict +from typing import Any, List, Dict from pydantic import Field, validator @@ -27,12 +27,12 @@ class Root(ObjectExtended, RootBase): openapi: str = Field(...) info: Info = Field(...) - servers: Optional[List[Server]] = Field(default_factory=list) + servers: List[Server] = Field(default_factory=list) paths: Paths = Field(default_factory=dict) - components: Optional[Components] = Field(default_factory=Components) - security: Optional[List[SecurityRequirement]] = Field(default_factory=list) - tags: Optional[List[Tag]] = Field(default_factory=list) - externalDocs: Optional[Dict[Any, Any]] = Field(default_factory=dict) + components: Components = Field(default_factory=Components) + security: List[SecurityRequirement] = Field(default_factory=list) + tags: List[Tag] = Field(default_factory=list) + externalDocs: Dict[Any, Any] = Field(default_factory=dict) def _resolve_references(self, api): RootBase.resolve(api, self, self, PathItem, Reference) diff --git a/aiopenapi3/v30/schemas.py b/aiopenapi3/v30/schemas.py index ab154055..253cea2d 100644 --- a/aiopenapi3/v30/schemas.py +++ b/aiopenapi3/v30/schemas.py @@ -14,7 +14,7 @@ class Discriminator(ObjectExtended, DiscriminatorBase): """ propertyName: str = Field(...) - mapping: Optional[Dict[str, str]] = Field(default_factory=dict) + mapping: Dict[str, str] = Field(default_factory=dict) class Schema(ObjectExtended, SchemaBase): @@ -38,16 +38,16 @@ class Schema(ObjectExtended, SchemaBase): uniqueItems: Optional[bool] = Field(default=None) maxProperties: Optional[int] = Field(default=None) minProperties: Optional[int] = Field(default=None) - required: Optional[List[str]] = Field(default_factory=list) + required: List[str] = Field(default_factory=list) enum: Optional[List[Any]] = Field(default=None) type: Optional[str] = Field(default=None) - allOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) - oneOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) - anyOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) + allOf: List[Union["Schema", Reference]] = Field(default_factory=list) + oneOf: List[Union["Schema", Reference]] = Field(default_factory=list) + anyOf: List[Union["Schema", Reference]] = Field(default_factory=list) not_: Optional[Union["Schema", Reference]] = Field(default=None, alias="not") items: Optional[Union["Schema", Reference]] = Field(default=None) - properties: Optional[Dict[str, Union["Schema", Reference]]] = Field(default_factory=dict) + properties: Dict[str, Union["Schema", Reference]] = Field(default_factory=dict) additionalProperties: Optional[Union[bool, "Schema", Reference]] = Field(default=None) description: Optional[str] = Field(default=None) format: Optional[str] = Field(default=None) diff --git a/aiopenapi3/v30/servers.py b/aiopenapi3/v30/servers.py index c887e7d2..7b42232e 100644 --- a/aiopenapi3/v30/servers.py +++ b/aiopenapi3/v30/servers.py @@ -26,4 +26,4 @@ class Server(ObjectExtended): url: str = Field(...) description: Optional[str] = Field(default=None) - variables: Optional[Dict[str, ServerVariable]] = Field(default_factory=dict) + variables: Dict[str, ServerVariable] = Field(default_factory=dict) diff --git a/aiopenapi3/v31/components.py b/aiopenapi3/v31/components.py index 939ec316..8bd97de6 100644 --- a/aiopenapi3/v31/components.py +++ b/aiopenapi3/v31/components.py @@ -1,4 +1,4 @@ -from typing import Union, Optional, Dict +from typing import Union, Dict from pydantic import Field @@ -20,15 +20,13 @@ class Components(ObjectExtended): .. _Components Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#components-object """ - model_config = dict(undefined_types_warning=False) - - schemas: Optional[Dict[str, Union[Schema, bool]]] = Field(default_factory=dict) - responses: Optional[Dict[str, Union[Response, Reference]]] = Field(default_factory=dict) - parameters: Optional[Dict[str, Union[Parameter, Reference]]] = Field(default_factory=dict) - examples: Optional[Dict[str, Union[Example, Reference]]] = Field(default_factory=dict) - requestBodies: Optional[Dict[str, Union[RequestBody, Reference]]] = Field(default_factory=dict) - headers: Optional[Dict[str, Union[Header, Reference]]] = Field(default_factory=dict) - securitySchemes: Optional[Dict[str, Union[SecurityScheme, Reference]]] = Field(default_factory=dict) - links: Optional[Dict[str, Union[Link, Reference]]] = Field(default_factory=dict) - callbacks: Optional[Dict[str, Union[Callback, Reference]]] = Field(default_factory=dict) - pathItems: Optional[Dict[str, Union[PathItem, Reference]]] = Field(default_factory=dict) # v3.1 + schemas: Dict[str, Union[Schema, bool]] = Field(default_factory=dict) + responses: Dict[str, Union[Response, Reference]] = Field(default_factory=dict) + parameters: Dict[str, Union[Parameter, Reference]] = Field(default_factory=dict) + examples: Dict[str, Union[Example, Reference]] = Field(default_factory=dict) + requestBodies: Dict[str, Union[RequestBody, Reference]] = Field(default_factory=dict) + headers: Dict[str, Union[Header, Reference]] = Field(default_factory=dict) + securitySchemes: Dict[str, Union[SecurityScheme, Reference]] = Field(default_factory=dict) + links: Dict[str, Union[Link, Reference]] = Field(default_factory=dict) + callbacks: Dict[str, Union[Callback, Reference]] = Field(default_factory=dict) + pathItems: Dict[str, Union[PathItem, Reference]] = Field(default_factory=dict) # v3.1 diff --git a/aiopenapi3/v31/media.py b/aiopenapi3/v31/media.py index 07547efa..f06fd9e2 100644 --- a/aiopenapi3/v31/media.py +++ b/aiopenapi3/v31/media.py @@ -20,7 +20,7 @@ class Encoding(ObjectExtended): model_config = dict(undefined_types_warning=False) contentType: Optional[str] = Field(default=None) - headers: Optional[Dict[str, Union[Header, Reference]]] = Field(default_factory=dict) + headers: Dict[str, Union[Header, Reference]] = Field(default_factory=dict) style: Optional[str] = Field(default=None) explode: Optional[bool] = Field(default=None) allowReserved: Optional[bool] = Field(default=None) @@ -37,5 +37,5 @@ class MediaType(ObjectExtended): model_config = dict(undefined_types_warning=False) schema_: Optional[Schema] = Field(default=None, alias="schema") example: Optional[Any] = Field(default=None) # 'any' type - examples: Optional[Dict[str, Union[Example, Reference]]] = Field(default_factory=dict) - encoding: Optional[Dict[str, Encoding]] = Field(default_factory=dict) + examples: Dict[str, Union[Example, Reference]] = Field(default_factory=dict) + encoding: Dict[str, Encoding] = Field(default_factory=dict) diff --git a/aiopenapi3/v31/parameter.py b/aiopenapi3/v31/parameter.py index 74a8183e..7c681ad9 100644 --- a/aiopenapi3/v31/parameter.py +++ b/aiopenapi3/v31/parameter.py @@ -31,7 +31,7 @@ class ParameterBase(ObjectExtended): allowReserved: Optional[bool] = Field(default=None) schema_: Optional[Schema] = Field(default=None, alias="schema") example: Optional[Any] = Field(default=None) - examples: Optional[Dict[str, Union["Example", Reference]]] = Field(default_factory=dict) + examples: Dict[str, Union["Example", Reference]] = Field(default_factory=dict) content: Optional[Dict[str, "MediaType"]] = None diff --git a/aiopenapi3/v31/paths.py b/aiopenapi3/v31/paths.py index 44a0ad44..63ee2698 100644 --- a/aiopenapi3/v31/paths.py +++ b/aiopenapi3/v31/paths.py @@ -60,9 +60,9 @@ class Response(ObjectExtended): model_config = dict(undefined_types_warning=False) description: str = Field(...) - headers: Optional[Dict[str, Union[Header, Reference]]] = Field(default_factory=dict) - content: Optional[Dict[str, MediaType]] = Field(default_factory=dict) - links: Optional[Dict[str, Union[Link, Reference]]] = Field(default_factory=dict) + headers: Dict[str, Union[Header, Reference]] = Field(default_factory=dict) + content: Dict[str, MediaType] = Field(default_factory=dict) + links: Dict[str, Union[Link, Reference]] = Field(default_factory=dict) class Operation(ObjectExtended, OperationBase): @@ -79,12 +79,12 @@ class Operation(ObjectExtended, OperationBase): description: Optional[str] = Field(default=None) externalDocs: Optional[ExternalDocumentation] = Field(default=None) operationId: Optional[str] = Field(default=None) - parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) + parameters: List[Union[Parameter, Reference]] = Field(default_factory=list) requestBody: Optional[Union[RequestBody, Reference]] = Field(default=None) responses: Dict[str, Union[Response, Reference]] = Field(default_factory=dict) - callbacks: Optional[Dict[str, Union["Callback", Reference]]] = Field(default_factory=dict) + callbacks: Dict[str, Union["Callback", Reference]] = Field(default_factory=dict) deprecated: Optional[bool] = Field(default=None) - security: Optional[List[SecurityRequirement]] = Field(default_factory=list) + security: List[SecurityRequirement] = Field(default_factory=list) servers: Optional[List[Server]] = Field(default=None) @@ -110,7 +110,7 @@ class PathItem(ObjectExtended): patch: Optional[Operation] = Field(default=None) trace: Optional[Operation] = Field(default=None) servers: Optional[List[Server]] = Field(default=None) - parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) + parameters: List[Union[Parameter, Reference]] = Field(default_factory=list) class Paths(PathsBase): diff --git a/aiopenapi3/v31/root.py b/aiopenapi3/v31/root.py index a0eb21c4..c9c5f437 100644 --- a/aiopenapi3/v31/root.py +++ b/aiopenapi3/v31/root.py @@ -27,14 +27,14 @@ class Root(ObjectExtended, RootBase): openapi: str = Field(...) info: Info = Field(...) jsonSchemaDialect: Optional[str] = Field(default=None) # FIXME should be URI - servers: Optional[List[Server]] = Field(default=None) + servers: Optional[List[Server]] = Field(default_factory=list) # paths: Dict[str, PathItem] = Field(default_factory=dict) paths: Paths = Field(default_factory=dict) - webhooks: Optional[Dict[str, Union[PathItem, Reference]]] = Field(default_factory=dict) - components: Optional[Components] = Field(default=None) - security: Optional[List[SecurityRequirement]] = Field(default=None) - tags: Optional[List[Tag]] = Field(default_factory=list) - externalDocs: Optional[Dict[Any, Any]] = Field(default_factory=dict) + webhooks: Dict[str, Union[PathItem, Reference]] = Field(default_factory=dict) + components: Optional[Components] = Field(default_factory=Components) + security: Optional[List[SecurityRequirement]] = Field(default_factory=list) + tags: List[Tag] = Field(default_factory=list) + externalDocs: Dict[Any, Any] = Field(default_factory=dict) @model_validator(mode="after") def validate_Root(cls, r: "Root"): diff --git a/aiopenapi3/v31/schemas.py b/aiopenapi3/v31/schemas.py index 67d9640f..4a614360 100644 --- a/aiopenapi3/v31/schemas.py +++ b/aiopenapi3/v31/schemas.py @@ -14,7 +14,7 @@ class Discriminator(ObjectExtended, DiscriminatorBase): """ propertyName: str = Field(...) - mapping: Optional[Dict[str, str]] = Field(default_factory=dict) + mapping: Dict[str, str] = Field(default_factory=dict) class Schema(ObjectExtended, SchemaBase): @@ -51,9 +51,9 @@ class Schema(ObjectExtended, SchemaBase): """ 10.2.1. Keywords for Applying Subschemas With Logic """ - allOf: Optional[List["Schema"]] = Field(default_factory=list) - oneOf: Optional[List["Schema"]] = Field(default_factory=list) - anyOf: Optional[List["Schema"]] = Field(default_factory=list) + allOf: List["Schema"] = Field(default_factory=list) + oneOf: List["Schema"] = Field(default_factory=list) + anyOf: List["Schema"] = Field(default_factory=list) not_: Optional["Schema"] = Field(default=None, alias="not") """ @@ -62,7 +62,7 @@ class Schema(ObjectExtended, SchemaBase): if_: Optional["Schema"] = Field(default=None, alias="if") then_: Optional["Schema"] = Field(default=None, alias="then") else_: Optional["Schema"] = Field(default=None, alias="else") - dependentSchemas: Optional[Dict[str, "Schema"]] = Field(default_factory=dict) + dependentSchemas: Dict[str, "Schema"] = Field(default_factory=dict) """ 10.3.1. Keywords for Applying Subschemas to Arrays @@ -74,8 +74,8 @@ class Schema(ObjectExtended, SchemaBase): """ 10.3.2. Keywords for Applying Subschemas to Objects """ - properties: Optional[Dict[str, "Schema"]] = Field(default_factory=dict) - patternProperties: Optional[Dict[str, "Schema"]] = Field(default_factory=dict) + properties: Dict[str, "Schema"] = Field(default_factory=dict) + patternProperties: Dict[str, "Schema"] = Field(default_factory=dict) additionalProperties: Optional[Union[bool, "Schema"]] = Field(default=None) unevaluatedProperties: Optional["Schema"] = Field(default=None) propertyNames: Optional["Schema"] = Field(default=None) @@ -129,7 +129,7 @@ class Schema(ObjectExtended, SchemaBase): """ maxProperties: Optional[int] = Field(default=None) minProperties: Optional[int] = Field(default=None) - required: Optional[List[str]] = Field(default_factory=list) + required: List[str] = Field(default_factory=list) dependentRequired: Dict[str, str] = Field(default_factory=dict) # FIXME """ diff --git a/aiopenapi3/v31/servers.py b/aiopenapi3/v31/servers.py index 7eb35d00..73602a76 100644 --- a/aiopenapi3/v31/servers.py +++ b/aiopenapi3/v31/servers.py @@ -33,4 +33,4 @@ class Server(ObjectExtended): url: str = Field(...) description: Optional[str] = Field(default=None) - variables: Optional[Dict[str, ServerVariable]] = Field(default_factory=dict) + variables: Dict[str, ServerVariable] = Field(default_factory=dict) From 11a136ccc40f23ea2ccf9f132448f4a4374602ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Fri, 25 Aug 2023 19:05:34 +0200 Subject: [PATCH 5/8] mypy - more type annotations --- aiopenapi3/_types.py | 82 +++++++++++ aiopenapi3/base.py | 123 ++++++++++------ aiopenapi3/cli.py | 30 ++-- aiopenapi3/debug.py | 4 +- aiopenapi3/errors.py | 60 +++++--- aiopenapi3/json.py | 5 +- aiopenapi3/loader.py | 38 ++--- aiopenapi3/log.py | 7 +- aiopenapi3/me.py | 4 +- aiopenapi3/model.py | 234 ++++++++++++++++-------------- aiopenapi3/openapi.py | 274 ++++++++++++++++++++++-------------- aiopenapi3/plugin.py | 60 +++++--- aiopenapi3/request.py | 166 +++++++++++++++------- aiopenapi3/v20/general.py | 14 +- aiopenapi3/v20/glue.py | 99 +++++++------ aiopenapi3/v20/paths.py | 4 +- aiopenapi3/v30/formdata.py | 43 +++--- aiopenapi3/v30/general.py | 16 ++- aiopenapi3/v30/glue.py | 229 +++++++++++++++++++----------- aiopenapi3/v30/media.py | 8 +- aiopenapi3/v30/parameter.py | 14 +- aiopenapi3/v30/paths.py | 16 +-- aiopenapi3/v30/root.py | 2 - aiopenapi3/v30/schemas.py | 5 +- aiopenapi3/v30/security.py | 2 +- aiopenapi3/v30/servers.py | 2 +- aiopenapi3/v31/general.py | 15 +- aiopenapi3/v31/info.py | 1 + aiopenapi3/v31/media.py | 3 - aiopenapi3/v31/parameter.py | 12 +- aiopenapi3/v31/paths.py | 17 +-- aiopenapi3/v31/root.py | 2 - aiopenapi3/v31/schemas.py | 5 +- aiopenapi3/v31/security.py | 8 +- aiopenapi3/v31/servers.py | 2 +- pyproject.toml | 4 + 36 files changed, 1016 insertions(+), 594 deletions(-) create mode 100644 aiopenapi3/_types.py diff --git a/aiopenapi3/_types.py b/aiopenapi3/_types.py new file mode 100644 index 00000000..e462a65f --- /dev/null +++ b/aiopenapi3/_types.py @@ -0,0 +1,82 @@ +from . import v20, v30, v31 + +from typing import ( + TYPE_CHECKING, + Dict, + List, + Sequence, + Tuple, + Union, + TypeAlias, + Type, +) + +import yaml + +if TYPE_CHECKING: + pass + + +from httpx._types import RequestContent, FileTypes, RequestFiles, AuthTypes # noqa +from pydantic import BaseModel + +RequestFileParameter = Tuple[str, FileTypes] +RequestFilesParameter = Sequence[RequestFileParameter] + +JSON: TypeAlias = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None +""" +Define a JSON type +https://github.com/python/typing/issues/182#issuecomment-1320974824 +""" + +RequestData = Union[JSON, BaseModel, RequestFilesParameter] +RequestParameter = Union[str, BaseModel] +RequestParameters = Dict[str, RequestParameter] + +RootType = Union[v20.Root, v30.Root, v31.Root] +ReferenceType = Union[v20.Reference, v30.Reference, v31.Reference] +SchemaType = Union[v20.Schema, v30.Schema, v31.Schema] +DiscriminatorType = Union[v30.Discriminator, v31.Discriminator] +PathItemType = Union[v20.PathItem, v30.PathItem, v31.PathItem] +OperationType = Union[v20.Operation, v30.Operation, v31.Operation] +ParameterType = Union[v20.Parameter, v30.Parameter, v31.Parameter] +HeaderType = Union[v30.Header, v30.Header, v31.Header] +RequestType = Union[v20.Request, v30.Request] +MediaTypeType = Union[v30.MediaType, v31.MediaType] +ExpectedType = Union[v20.Response, MediaTypeType] +ResponseHeadersType = Dict[str, str] +ResponseDataType = Union[BaseModel, bytes, str] + + +YAMLLoaderType = Union[Type[yaml.Loader], Type[yaml.CLoader], Type[yaml.SafeLoader], Type[yaml.CSafeLoader]] + +PrimitiveTypes = Union[str, float, int, bool] + +__all__: List[str] = [ + "RootType", + "SchemaType", + "DiscriminatorType", + "PathItemType", + "OperationType", + "ParameterType", + "HeaderType", + "RequestType", + "ExpectedType", + "MediaTypeType", + "ResponseHeadersType", + "ResponseDataType", + "RequestData", + "RequestParameters", + "ReferenceType", + "PrimitiveTypes", + # + "YAMLLoaderType", + # httpx forwards + "RequestContent", + "RequestFiles", + "AuthTypes", + # + "JSON", + "RequestFilesParameter", + "RequestFileParameter", +] diff --git a/aiopenapi3/base.py b/aiopenapi3/base.py index ec192368..d01a6900 100644 --- a/aiopenapi3/base.py +++ b/aiopenapi3/base.py @@ -1,5 +1,6 @@ +import typing import warnings -from typing import Optional, Any, List, Dict, ForwardRef, Union +from typing import Optional, Any, List, Dict, ForwardRef, Union, Tuple, cast, Type, TypeGuard, FrozenSet, Sequence import re import builtins @@ -12,23 +13,24 @@ else: from pathlib3x import Path -from pydantic import BaseModel, Field, AnyUrl, model_validator, PrivateAttr +from pydantic import BaseModel, Field, AnyUrl, model_validator, PrivateAttr, ConfigDict from .json import JSONPointer, JSONReference from .errors import ReferenceResolutionError, OperationParameterValidationError -# from . import me +if typing.TYPE_CHECKING: + from aiopenapi3 import OpenAPI + from ._types import SchemaType, JSON, PathItemType, ParameterType, ReferenceType, DiscriminatorType HTTP_METHODS = frozenset(["get", "delete", "head", "post", "put", "patch", "trace"]) class ObjectBase(BaseModel): """ - The base class for all schema objects. Includes helpers for common schema- - related functions. + The base class for all schema objects. Includes helpers for common schema-related functions. """ - model_config = dict(arbitrary_types_allowed=False, extra="forbid") + model_config = ConfigDict(extra="forbid") class ObjectExtended(ObjectBase): @@ -79,33 +81,38 @@ def values(self): return self.paths.values() +class PathItemBase: + # parameters: Optional[List[Union["ParameterBase", "ReferenceBase"]]] + parameters: List[Any] + + class RootBase: @staticmethod def resolve(api: "OpenAPI", root: "RootBase", obj, _PathItem, _Reference): from . import v20, v30, v31 def replaceSchemaReference(data): - def replace(value): - if not isinstance(value, SchemaBase): - return value - r = getattr(value, "ref", None) - if not r: - return value - return _Reference.model_construct(ref=r) + def replace(ivalue): + if not isinstance(ivalue, SchemaBase): + return ivalue + ir = getattr(ivalue, "ref", None) + if not ir: + return ivalue + return _Reference.model_construct(ref=ir) if isinstance(data, list): - for idx, item in enumerate(data): - n = replace(item) - if item != n: + for idx, _item in enumerate(data): + n = replace(_item) + if _item != n: data[idx] = n elif isinstance(data, dict): new = dict() - for k, v in data.items(): - n = replace(v) # Swagger 2.0 Schema.ref resolver … - if v != n: - v = n - new[k] = v + for _k, _v in data.items(): + n = replace(_v) # Swagger 2.0 Schema.ref resolver … + if _v != n: + _v = n + new[_k] = _v if new: data.update(new) @@ -125,13 +132,13 @@ def replace(value): setattr(obj, slot, value) if isinstance(root, (v30.root.Root, v31.root.Root)): - if isinstance(value, DiscriminatorBase): + if isinstance(value, (v30.Discriminator, v31.Discriminator)): """ Discriminated Unions - implementing undefined behavior sub-schemas not having the discriminated property "const" or enum or mismatching the mapping are a problem pydantic requires these to be mapping Literal and unique - creating a seperate Model for the sub-schema with the mapping Literal is possible + creating a separate Model for the sub-schema with the mapping Literal is possible but makes using them horrible we warn about it and force feed the mapping Literal to make it work @@ -153,7 +160,7 @@ def replace(value): from .model import Model from . import errors - if not "object" in (t := sorted(Model.types(v._target))): + if "object" not in (t := sorted(Model.types(v._target))): raise errors.SpecError(f"Discriminated Union on a schema with types {t}") if (p := v.properties.get(value.propertyName, None)) is None: @@ -186,7 +193,7 @@ def replace(value): """ ref fields embedded in objects -> replace the object with a Reference object - PathItem Ref is ambigous + PathItem Ref is ambiguous https://github.com/OAI/OpenAPI-Specification/issues/2635 """ if isinstance(root, (v20.root.Root, v30.root.Root, v31.root.Root)): @@ -284,7 +291,8 @@ def resolve_jp(self, jp): class ReferenceBase: - pass + ref: str + _target: Union["SchemaType", "PathItemType"] class ParameterBase: @@ -295,17 +303,21 @@ class DiscriminatorBase: pass +# propertyName: str +# mapping: Dict[str, str] = Field(default_factory=dict) + + class SchemaBase(BaseModel): """ The Base for the Schema """ - _model_type: "BaseModel" = PrivateAttr(default=None) + _model_type: Type["BaseModel"] = PrivateAttr(default=None) """ use to store _the_ model """ - _model_types: List["BaseModel"] = PrivateAttr(default_factory=list) + _model_types: List[Type["BaseModel"]] = PrivateAttr(default_factory=list) """ sub-schemas add the properties of the parent to the model of the subschemas @@ -332,6 +344,8 @@ class SchemaBase(BaseModel): The _identity attribute is set during OpenAPI.__init__ and used to create the class name in get_type() """ + # items: Optional[Union["SchemaType", List["SchemaType"]]] + def __getstate__(self): """ pickle can't do the _model_type - remove from pydantic's __getstate__ @@ -353,7 +367,7 @@ def _get_identity(self, prefix="XLS", name=None): if name is None: name = self.title if name: - n = re.sub(r"[^\w]", "_", name, flags=re.ASCII) + n = re.sub(r"\W", "_", name, flags=re.ASCII) else: n = str(uuid.uuid4()).replace("-", "_") @@ -374,28 +388,35 @@ def _get_identity(self, prefix="XLS", name=None): return self._identity def set_type( - self, names: List[str] = None, discriminators: List[DiscriminatorBase] = None, extra: "SchemaBase" = None - ) -> BaseModel: + self, + names: List[str] | None = None, + discriminators: Sequence[DiscriminatorBase] | None = None, + extra: Optional["SchemaBase"] = None, + ) -> Type[BaseModel]: from .model import Model if extra is None: - self._model_type = Model.from_schema(self, names, discriminators) + self._model_type = Model.from_schema( + cast("SchemaType", self), names, cast(List["DiscriminatorType"], discriminators) + ) return self._model_type else: identity = self._identity self._identity = f"{identity}.c{len(self._model_types)}" - r = Model.from_schema(self, names, discriminators, extra) + r = Model.from_schema( + cast("SchemaType", self), names, cast(List["DiscriminatorType"], discriminators), extra + ) self._model_types.append(r) self._identity = identity return r def get_type( self, - names: List[str] = None, - discriminators: List[DiscriminatorBase] = None, - extra: "SchemaBase" = None, + names: List[str] | None = None, + discriminators: Sequence[DiscriminatorBase] | None = None, + extra: Optional["SchemaBase"] = None, fwdref: bool = False, - ) -> Union[BaseModel, ForwardRef]: + ) -> Union[Type[BaseModel], ForwardRef]: if fwdref: if "module" in ForwardRef.__init__.__code__.co_varnames: # FIXME Python < 3.9 compat @@ -409,7 +430,7 @@ def get_type( else: return self.set_type(names, discriminators, extra) - def model(self, data: Dict): + def model(self, data: "JSON") -> Union[BaseModel, List[BaseModel]]: """ Generates a model representing this schema from the given data. @@ -419,21 +440,27 @@ def model(self, data: Dict): :returns: A new :any:`Model` created in this Schema's type from the data. :rtype: self.get_type() """ + if self.type in ("string", "number", "boolean", "integer"): assert len(self.properties) == 0 - t = Model.typeof(self) + t = Model.typeof(cast("SchemaType", self)) # data from Headers will be of type str if not isinstance(data, t): return t(data) return data elif self.type == "array": - return [self.items.model(i) for i in data] + items = cast("SchemaType", self.items) + return [items.model(i) for i in cast(List["JSON"], data)] else: - return self.get_type().model_validate(data) + type_ = cast("SchemaType", self.get_type()) + return type_.model_validate(data) class OperationBase: - def _validate_path_parameters(self, pi: "PathItem", path_, loc): + # parameters: Optional[List[ParameterBase | ReferenceBase]] + parameters: List[Any] + + def _validate_path_parameters(self, pi_: "PathItemBase", path_: str, loc: Tuple[Any, str]): """ Ensures that all parameters for this path are valid """ @@ -441,8 +468,16 @@ def _validate_path_parameters(self, pi: "PathItem", path_, loc): # FIXME { and } are allowed in parameter name, regex can't handle this e.g. {name}} path = frozenset(re.findall(r"{([a-zA-Z0-9\-\._~]+)}", path_)) - op = frozenset(map(lambda x: x.name, filter(lambda c: c.in_ == "path", self.parameters))) - pi = frozenset(map(lambda x: x.name, filter(lambda c: c.in_ == "path", pi.parameters))) + def parameter_in_path(c: Union["ParameterType", "ReferenceType"]) -> TypeGuard["ParameterType"]: + if isinstance(c, ParameterBase): + return c.in_ == "path" + assert isinstance(c, ReferenceBase) + return parameter_in_path(c._target) + + assert self.parameters is not None + assert pi_.parameters is not None + op: FrozenSet[str] = frozenset(map(lambda x: x.name, filter(parameter_in_path, self.parameters))) + pi: FrozenSet[str] = frozenset(map(lambda x: x.name, filter(parameter_in_path, pi_.parameters))) invalid = sorted(filter(lambda x: re.match(r"^([a-zA-Z0-9\-\._~]+)$", x) is None or len(x) == 0, op | pi)) if invalid: diff --git a/aiopenapi3/cli.py b/aiopenapi3/cli.py index 835dbace..6b95651f 100644 --- a/aiopenapi3/cli.py +++ b/aiopenapi3/cli.py @@ -12,6 +12,7 @@ import tracemalloc import linecache import logging +from typing import Callable import jmespath import yaml @@ -34,7 +35,7 @@ import aiopenapi3.loader -log = None +log: Callable[[...], None] | None = None def loader_prepare(args, session_factory): @@ -51,18 +52,19 @@ def loader_prepare(args, session_factory): def plugins_load(baseurl, plugins: List[str]) -> List[aiopenapi3.plugin.Plugin]: """ load Plugins from python files - :param args: - :return: """ r = [] for p in plugins: file, _, cls = p.partition(":") - cls = cls.split(",") + clsp = cls.split(",") - spec = importlib.util.spec_from_file_location("extra", file) - module = importlib.util.module_from_spec(spec) + if (spec := importlib.util.spec_from_file_location("extra", file)) is None: + raise ValueError("importlib") + if (module := importlib.util.module_from_spec(spec)) is None: + raise ValueError("importlib") + assert spec and module spec.loader.exec_module(module) - for c in cls: + for c in clsp: plugin = getattr(module, c) varnames = plugin.__init__.__code__.co_varnames if len(varnames) == 1: @@ -127,6 +129,8 @@ def schema_display_stats(api, duration): print(f"… {len(operations)} #operations (with operationId)") def schemaof(x): + import aiopenapi3.v20 + if isinstance(api._root, aiopenapi3.v20.Root): return x.definitions else: @@ -158,7 +162,7 @@ def main(argv=None): cmd.add_argument("output") cmd.add_argument("-f", "--format", choices=["yaml", "json"], default=None) - def cmd_convert(args): + def cmd_convert(args: argparse.Namespace) -> None: output = Path(args.output) loader = loader_prepare(args, session_factory) input_ = yarl.URL(args.input) @@ -185,7 +189,7 @@ def cmd_convert(args): cmd.add_argument("-d", "--data") cmd.add_argument("-f", "--format") - def cmd_call(args): + def cmd_call(args: argparse.Namespace) -> None: loader = loader_prepare(args, session_factory) def prepare_arg(value): @@ -209,8 +213,8 @@ def prepare_arg(value): expr = None if args.cache: + cache = Path(args.cache) try: - cache = Path(args.cache) api = OpenAPI.cache_load(cache, plugins, session_factory) except FileNotFoundError: api = OpenAPI.load_file( @@ -228,6 +232,9 @@ def prepare_arg(value): if auth: api.authenticate(**auth) + import aiopenapi3.request + + req: aiopenapi3.request.RequestBase if args.method: req = api.createRequest((args.operationId, args.method)) else: @@ -246,6 +253,7 @@ def prepare_arg(value): obj = response.json() if args.format: + assert expr obj = expr.search(obj) print(json.dumps(obj, indent=2, sort_keys=True)) @@ -255,7 +263,7 @@ def prepare_arg(value): cmd = sub.add_parser("validate") cmd.add_argument("input") - def cmd_validate(args): + def cmd_validate(args: argparse.Namespace) -> None: loader = loader_prepare(args, session_factory) try: diff --git a/aiopenapi3/debug.py b/aiopenapi3/debug.py index bfc82001..bc8ece8a 100644 --- a/aiopenapi3/debug.py +++ b/aiopenapi3/debug.py @@ -1,10 +1,10 @@ -import aiopenapi3.plugin +from aiopenapi3.plugin import Document import yaml from pathlib import Path import json -class DescriptionDocumentDumper(aiopenapi3.plugin.Document): +class DescriptionDocumentDumper(Document): def __init__(self, path): super().__init__() self.path = Path(path) diff --git a/aiopenapi3/errors.py b/aiopenapi3/errors.py index cf51d3cf..28a477f3 100644 --- a/aiopenapi3/errors.py +++ b/aiopenapi3/errors.py @@ -1,6 +1,22 @@ -from typing import List, Tuple, Dict +import typing +from typing import List, Tuple, Dict, Optional import dataclasses +import httpx + + +if typing.TYPE_CHECKING: + from ._types import ( + OperationType, + SchemaType, + RequestType, + RequestData, + RequestParameters, + HeaderType, + ExpectedType, + OperationType, + ) + class ErrorBase(Exception): pass @@ -72,10 +88,10 @@ class HTTPError(ErrorBase): @dataclasses.dataclass class RequestError(HTTPError): - operation: object - request: object - data: object - parameters: object + operation: Optional["OperationType"] + request: Optional["RequestType"] + data: Optional["RequestData"] + parameters: Optional["RequestParameters"] class ResponseError(HTTPError): @@ -88,56 +104,56 @@ class ResponseError(HTTPError): class ContentLengthExceededError(ResponseError): """The Content-Length exceeds our Limits""" - operation: object + operation: "OperationType" content_length: int message: str - response: object + response: httpx.Response @dataclasses.dataclass class ContentTypeError(ResponseError): """The content-type is unexpected""" - operation: object + operation: "OperationType" content_type: str message: str - response: object + response: httpx.Response @dataclasses.dataclass class HTTPStatusError(ResponseError): """The HTTP Status is unexpected""" - operation: object + operation: "OperationType" http_status: int message: str - response: object + response: httpx.Response @dataclasses.dataclass class ResponseDecodingError(ResponseError): """the json decoder failed""" - operation: object - data: object - response: object + operation: "OperationType" + data: str + response: httpx.Response @dataclasses.dataclass class ResponseSchemaError(ResponseError): """the response data does not match the schema""" - operation: object - expectation: object - schema: object - response: object - exception: object + operation: "OperationType" + expectation: "ExpectedType" + schema: Optional["SchemaType"] + response: httpx.Response + exception: Optional[Exception] @dataclasses.dataclass class HeadersMissingError(ResponseError): """the response is missing required header/s""" - operation: object - missing: Dict[str, object] - response: object + operation: "OperationType" + missing: Dict[str, "HeaderType"] + response: httpx.Response diff --git a/aiopenapi3/json.py b/aiopenapi3/json.py index 902917a3..8f75368e 100644 --- a/aiopenapi3/json.py +++ b/aiopenapi3/json.py @@ -1,3 +1,4 @@ +from typing import Tuple, Union import urllib.parse from yarl import URL @@ -11,7 +12,7 @@ class JSONPointer: """ @staticmethod - def decode(part): + def decode(part: str) -> str: """ https://swagger.io/docs/specification/using-ref/ @@ -24,7 +25,7 @@ def decode(part): class JSONReference: @staticmethod - def split(url): + def split(url: Union[str, URL]) -> Tuple[str, str]: """ split the url into path and fragment """ diff --git a/aiopenapi3/loader.py b/aiopenapi3/loader.py index 0e0ea542..8789a3fe 100644 --- a/aiopenapi3/loader.py +++ b/aiopenapi3/loader.py @@ -1,6 +1,7 @@ import abc import json import logging +import typing import yaml import httpx @@ -16,6 +17,9 @@ from .plugin import Plugins +if typing.TYPE_CHECKING: + from ._types import YAMLLoaderType, JSON + log = logging.getLogger("aiopenapi3.loader") @@ -104,11 +108,11 @@ class Loader(abc.ABC): * parse """ - def __init__(self, yload: yaml.Loader = YAML12Loader): + def __init__(self, yload: "YAMLLoaderType" = YAML12Loader): self.yload = yload @abc.abstractmethod - def load(self, plugins: Plugins, url: yarl.URL, codec: str = None): + def load(self, plugins: Plugins, url: yarl.URL, codec: str | None = None): """ load and decode description document @@ -120,7 +124,7 @@ def load(self, plugins: Plugins, url: yarl.URL, codec: str = None): raise NotImplementedError("load") @classmethod - def decode(cls, data: bytes, codec: str): + def decode(cls, data: bytes, codec: str | None) -> str: """ decode bytes to ascii or utf-8 @@ -132,15 +136,16 @@ def decode(cls, data: bytes, codec: str): codecs = [codec] else: codecs = ["ascii", "utf-8"] + r = None for c in codecs: try: - data = data.decode(c) + r = data.decode(c) break except UnicodeError: continue else: raise ValueError("encoding") - return data + return r def parse(self, plugins: Plugins, url: yarl.URL, data: str): """ @@ -191,7 +196,7 @@ class NullLoader(Loader): Loader does not load anything """ - def load(self, plugins: Plugins, url: yarl.URL, codec: str = None): + def load(self, plugins: Plugins, url: yarl.URL, codec: str | None = None): raise NotImplementedError("load") @@ -200,13 +205,13 @@ class WebLoader(Loader): Loader downloads data via http/s using the supplied session_factory """ - def __init__(self, baseurl: yarl.URL, session_factory=httpx.Client, yload: yaml.Loader = YAML12Loader): + def __init__(self, baseurl: yarl.URL, session_factory=httpx.Client, yload: "YAMLLoaderType" = YAML12Loader): super().__init__(yload) assert isinstance(baseurl, yarl.URL) self.baseurl: yarl.URL = baseurl self.session_factory = session_factory - def load(self, plugins: Plugins, url: yarl.URL, codec: str = None): + def load(self, plugins: Plugins, url: yarl.URL, codec: str | None = None) -> "JSON": url = self.baseurl.join(url) with self.session_factory() as session: data = session.get(str(url)) @@ -225,7 +230,7 @@ class FileSystemLoader(Loader): Loader to use the local filesystem """ - def __init__(self, base: Path, yload: yaml.Loader = YAML12Loader): + def __init__(self, base: Path, yload: "YAMLLoaderType" = YAML12Loader): """ :param base: basedir - lookups are relative to this :param yload: @@ -234,15 +239,16 @@ def __init__(self, base: Path, yload: yaml.Loader = YAML12Loader): assert isinstance(base, Path) self.base = base - def load(self, plugins: Plugins, url: yarl.URL, codec: str = None): + def load(self, plugins: Plugins, url: yarl.URL, codec: str | None = None): assert isinstance(url, yarl.URL) assert plugins file = Path(url.path) path = self.base / file assert path.is_relative_to(self.base), f"{path} is not relative to {self.base}" + data: bytes with path.open("rb") as f: data = f.read() - data = self.decode(data, codec) + data: str = self.decode(data, codec) data = plugins.document.loaded(url=url, document=data).document return data @@ -256,7 +262,7 @@ class RedirectLoader(FileSystemLoader): everything but the "name" is stripped of the url """ - def load(self, plugins: "Plugins", url: yarl.URL, codec: str = None): + def load(self, plugins: "Plugins", url: yarl.URL, codec: str | None = None): return super().load(plugins, yarl.URL(url.name), codec) @@ -265,7 +271,7 @@ class ChainLoader(Loader): Loader to chain different Loaders: succeed or raise trying """ - def __init__(self, *loaders, yload: yaml.Loader = YAML12Loader): + def __init__(self, *loaders, yload: "YAMLLoaderType" = YAML12Loader): """ :param loaders: loaders to use @@ -274,7 +280,7 @@ def __init__(self, *loaders, yload: yaml.Loader = YAML12Loader): Loader.__init__(self, yload) self.loaders = loaders - def load(self, plugins: "Plugins", url: yarl.URL, codec: str = None): + def load(self, plugins: "Plugins", url: yarl.URL, codec: str | None = None): log.debug(f"load {url}") errors = [] for i in self.loaders: @@ -282,8 +288,8 @@ def load(self, plugins: "Plugins", url: yarl.URL, codec: str = None): r = i.load(plugins, url, codec) log.debug(f"using {i}") return r - except Exception as e: - errors.append((i, str(e))) + except Exception as exc: + errors.append((i, str(exc))) for l, e in errors: log.debug(f"{l} {e}") raise FileNotFoundError(url) diff --git a/aiopenapi3/log.py b/aiopenapi3/log.py index 21c03d89..7a737e6f 100644 --- a/aiopenapi3/log.py +++ b/aiopenapi3/log.py @@ -1,16 +1,17 @@ import sys import logging.config import os +from typing import List, Dict, Any if sys.version_info >= (3, 9): from pathlib import Path else: from pathlib3x import Path -handlers = None +handlers: List[str] | None = None -def init(force=False): +def init(force: bool = False) -> None: global handlers if handlers is not None: @@ -33,7 +34,7 @@ def init(force=False): """export AIOPENAPI3_LOGGING_HANDLERS=debug to get /tmp/aiopenapi3-debug.log""" handlers.extend(filter(lambda x: len(x), os.environ.get("AIOPENAPI3_LOGGING_HANDLERS", "").split(","))) - config = { + config: Dict[str, Any] = { "version": 1, "formatters": { "notimestamp": { diff --git a/aiopenapi3/me.py b/aiopenapi3/me.py index a9a2c5b3..3646b96b 100644 --- a/aiopenapi3/me.py +++ b/aiopenapi3/me.py @@ -1 +1,3 @@ -__all__ = [] +from typing import List + +__all__: List[str] = [] diff --git a/aiopenapi3/model.py b/aiopenapi3/model.py index 5d70e60f..94199489 100644 --- a/aiopenapi3/model.py +++ b/aiopenapi3/model.py @@ -5,11 +5,10 @@ import logging import re import sys -from typing import Any, Set +from typing import Any, Set, Type, cast, TypeGuard, TypeVar import typing import pydantic -import pydantic_core if sys.version_info >= (3, 9): pass @@ -19,6 +18,7 @@ from .base import ReferenceBase, SchemaBase from . import me +from .pydanticv2 import field_class_to_schema if sys.version_info >= (3, 9): from typing import List, Optional, Union, Tuple, Dict, Annotated, Literal @@ -26,11 +26,13 @@ from typing import List, Optional, Union, Tuple, Dict from typing_extensions import Annotated, Literal -from pydantic import BaseModel, Field, RootModel -from .pydanticv2 import field_class_to_schema +from pydantic import BaseModel, Field, RootModel, ConfigDict +if typing.TYPE_CHECKING: + from .base import DiscriminatorBase + from ._types import SchemaType, ReferenceType, PrimitiveTypes, DiscriminatorType -type_format_to_class = collections.defaultdict(lambda: dict()) +type_format_to_class: Dict[str, Dict[str, Type]] = collections.defaultdict(lambda: dict()) log = logging.getLogger("aiopenapi3.model") @@ -65,8 +67,8 @@ def generate_type_format_to_class(): type_format_to_class["string"]["byte"] = Base64Str -def class_from_schema(s, type): - a = type_format_to_class[type] +def class_from_schema(s, _type): + a = type_format_to_class[_type] b = a.get(s.format, a[None]) return b @@ -79,7 +81,7 @@ class _PropertyInfo: default: Any = None root: Any = None - config: Dict[str, Any] = None + config: Dict[str, Any] = dataclasses.field(default_factory=dict) properties: Dict[str, _PropertyInfo] = dataclasses.field( default_factory=lambda: collections.defaultdict(lambda: _ClassInfo._PropertyInfo()) ) @@ -96,65 +98,74 @@ def fields(self): return dict(r) -class Model: # (BaseModel): - # class Config: - # extra: "forbid" +_T = TypeVar("_T") + + +def _follow(r: "ReferenceType", t: Type[_T]) -> TypeGuard[_T]: + assert isinstance(r, ReferenceBase) + if isinstance(r._target, t): + return r._target + assert r._target + return _follow(r._target, t) - TypeInfo = collections.namedtuple("annotation", "value") - ALIASES = dict() + +class Model: # (BaseModel): + ALIASES: Dict[str, str] = dict() @classmethod def from_schema( cls, - schema: "SchemaBase", - schemanames: List[str] = None, - discriminators: List["DiscriminatorBase"] = None, - extra: "SchemaBase" = None, - ): + schema: "SchemaType", + schemanames: List[str] | None = None, + discriminators: List["DiscriminatorType"] | None = None, + extra: "SchemaType" | None = None, + ) -> Type[BaseModel]: if schemanames is None: schemanames = [] if discriminators is None: discriminators = [] - r = list() + r: List[Type[BaseModel]] = list() for _type in Model.types(schema): r.append(Model.from_schema_type(schema, _type, schemanames, discriminators, extra)) if len(r) > 1: - r = Union[tuple(r)] + ru: object = Union[tuple(r)] type_name = schema._get_identity("L8") - m = pydantic.create_model(type_name, __base__=(RootModel[r],), __module__=me.__name__) + m: Type[RootModel] = pydantic.create_model(type_name, __base__=(RootModel[ru],), __module__=me.__name__) elif len(r) == 1: - m = r[0] + m: Type[BaseModel] = cast(Type[BaseModel], r[0]) else: # == 0 raise ValueError(r) - return m + return cast(Type[BaseModel], m) @classmethod def from_schema_type( cls, - schema: "SchemaBase", - type: str, - schemanames: List[str] = None, - discriminators: List["DiscriminatorBase"] = None, - extra: "SchemaBase" = None, - ): + schema: "SchemaType", + _type: str, + schemanames: List[str], + discriminators: List["DiscriminatorType"], + extra: Optional["SchemaType"], + ) -> Type[BaseModel]: + from . import v20, v30, v31 + type_name = schema._get_identity("L8") # + f"_{type}" classinfo = _ClassInfo() # do not create models for primitive types - if type in ("string", "integer", "number", "boolean"): - if type == "boolean": + if _type in ("string", "integer", "number", "boolean"): + if _type == "boolean": return bool - if typing.get_origin((_t := Model.typeof(schema, _type=type))) != Literal: + if typing.get_origin((_t := Model.typeof(schema, _type=_type))) != Literal: classinfo.root = Annotated[_t, Model.fieldof_args(schema, None)] else: classinfo.root = _t - elif type == "object": + elif _type == "object": # this is a anyOf/oneOf - the parent may have properties which will collide with __root__ # so - add the parent properties to this model if extra: @@ -163,28 +174,31 @@ def from_schema_type( if hasattr(schema, "anyOf") and schema.anyOf: assert all(schema.anyOf) + assert isinstance(schema, (v30.Schema, v31.Schema)) t = tuple( i.get_type( - names=schemanames + ([i.ref] if isinstance(i, ReferenceBase) else []), + names=schemanames + ([cast(str, i.ref)] if isinstance(i, ReferenceBase) else []), discriminators=discriminators + ([schema.discriminator] if schema.discriminator else []), extra=schema if schema.properties else None, ) for i in schema.anyOf ) if schema.discriminator and schema.discriminator.mapping: - classinfo.root = Annotated[Union[t], Field(discriminator=schema.discriminator.propertyName)] + classinfo.root = Annotated[ + Union[t], Field(discriminator=Model.nameof(schema.discriminator.propertyName)) + ] else: classinfo.root = Union[t] elif hasattr(schema, "oneOf") and schema.oneOf: + assert isinstance(schema, (v30.Schema, v31.Schema)) t = tuple( i.get_type( - names=schemanames + ([i.ref] if isinstance(i, ReferenceBase) else []), + names=schemanames + ([cast(str, i.ref)] if isinstance(i, ReferenceBase) else []), discriminators=discriminators + ([schema.discriminator] if schema.discriminator else []), extra=schema if schema.properties else None, ) for i in schema.oneOf ) - if schema.discriminator and schema.discriminator.mapping: classinfo.root = Annotated[ Union[t], Field(discriminator=Model.nameof(schema.discriminator.propertyName)) @@ -193,7 +207,7 @@ def from_schema_type( classinfo.root = Union[t] else: # default schema properties … - Model.annotationsof_type(schema, type, discriminators, schemanames, classinfo, fwdref=True) + Model.annotationsof_type(schema, _type, discriminators, schemanames, classinfo, fwdref=True) Model.fieldof(schema, classinfo) if "patternProperties" in schema.model_fields_set: @@ -235,10 +249,10 @@ def get_patternProperties(self_): Model.annotationsof(i, discriminators, schemanames, classinfo, fwdref=True) Model.fieldof(i, classinfo) - elif type == "array": + elif _type == "array": classinfo.root = Model.typeof(schema, _type="array") - if type in ("array", "object"): + if _type in ("array", "object"): if schema.enum or getattr(schema, "const", None): raise NotImplementedError("complex enums/const are not supported") @@ -260,15 +274,15 @@ def get_additionalProperties(x): else: m = pydantic.create_model( type_name, - __base__=(BaseModel,), + # __base__=(BaseModel,), __module__=me.__name__, model_config=classinfo.config, **classinfo.fields, ) - return m + return cast(Type[BaseModel], m) @staticmethod - def configof(schema): + def configof(schema: "SchemaType"): """ create pydantic model_config for the BaseModel we need to set "extra" - "allow" is not an option though … @@ -278,12 +292,14 @@ def configof(schema): * pydantic type identification does not work reliable due to missing rejects, """ + from . import v20, v30, v31 + arbitrary_types_allowed_ = False extra_ = "allow" if schema.additionalProperties is not None: if isinstance(schema.additionalProperties, bool): - if schema.additionalProperties == False: + if not schema.additionalProperties: extra_ = "forbid" else: arbitrary_types_allowed_ = True @@ -291,6 +307,7 @@ def configof(schema): """ we allow arbitrary types if additionalProperties has no properties """ + assert schema.additionalProperties.properties is not None if len(schema.additionalProperties.properties) == 0: arbitrary_types_allowed_ = True else: @@ -299,50 +316,50 @@ def configof(schema): if getattr(schema, "patternProperties", None): extra_ = "allow" - return dict( - undefined_types_warning=False, + return ConfigDict( extra=extra_, arbitrary_types_allowed=arbitrary_types_allowed_, # validate_assignment=True ) @staticmethod - def typeof(schema: "SchemaBase", _type=None, fwdref=False): - r = None - # assert schema is not None + def typeof( + schema: Optional[Union["SchemaType", "ReferenceType"]], _type: str | None = None, fwdref: bool = False + ) -> Type: if schema is None: return BaseModel if isinstance(schema, SchemaBase): nullable = False + schema = cast("SchemaType", schema) """ Required, can be None: Optional[str] Not required, can be None, is … by default: f4: Optional[str] = … """ - - if (v := getattr(schema, "const", None)) != None: + r: List[Type] = list() + rr: Type + if (v := getattr(schema, "const", None)) is not None: """ const - is not nullable """ - r = [Literal[v]] + r = [Literal[cast(str, v)]] # type: ignore[assignment,list-item] nullable = False elif schema.enum: if None in (_names := tuple(schema.enum)): nullable = True _names = tuple(filter(lambda x: x, _names)) - r = [Literal[_names]] + r = [Literal[_names]] # type: ignore[assignment,list-item] else: - r = list() - for type in Model.types(schema) if not _type else [_type]: - if type == "integer": + for _type in Model.types(schema) if not _type else [_type]: + if _type == "integer": r.append(int) - elif type == "number": + elif _type == "number": r.append(class_from_schema(schema, "number")) - elif type == "string": + elif _type == "string": v = class_from_schema(schema, "string") r.append(v) - elif type == "boolean": + elif _type == "boolean": r.append(bool) - elif type == "array": + elif _type == "array": if isinstance(schema.items, list): v = Tuple[tuple(Model.typeof(i, fwdref=True) for i in schema.items)] elif schema.items: @@ -350,37 +367,43 @@ def typeof(schema: "SchemaBase", _type=None, fwdref=False): """ self referencing array """ - v = List[schema.get_type(fwdref=True)] + v = List[schema.get_type(fwdref=True)] # type: ignore[misc,index] else: - v = List[Model.typeof(schema.items, fwdref=True)] + v = List[Model.typeof(schema.items, fwdref=True)] # type: ignore[misc,index] elif schema.items is None: continue else: raise TypeError(schema.items) r.append(v) - elif type == "object": + elif _type == "object": r.append(schema.get_type(fwdref=fwdref)) - elif type == "null": + elif _type == "null": nullable = True else: - raise ValueError(type) + raise ValueError(_type) if len(r) == 1: - r = r[0] + rr = r[0] elif len(r) > 1: - r = Union[tuple(r)] + rr = Union[tuple(r)] # type: ignore[arg-type] else: - r = None + rr = None # type: ignore[assignment] if nullable is True: - r = Optional[r] + rr = Optional[rr] elif isinstance(schema, ReferenceBase): - r = Model.typeof(schema._target, fwdref=True) + rr = Model.typeof(schema._target, fwdref=True) else: raise TypeError(type(schema)) - return r + return rr @staticmethod - def annotationsof(schema: "SchemaBase", discriminators, shmanm, classinfo: _ClassInfo, fwdref=False): + def annotationsof( + schema: "SchemaType", + discriminators: List["DiscriminatorType"], + shmanm: List[str], + classinfo: _ClassInfo, + fwdref=False, + ): if isinstance(schema.type, list): classinfo.root = Model.typeof(schema) elif schema.type is None: @@ -394,14 +417,14 @@ def annotationsof(schema: "SchemaBase", discriminators, shmanm, classinfo: _Clas @staticmethod def annotationsof_type( - schema: "SchemaBase", type: str, discriminators, shmanm, classinfo: _ClassInfo, fwdref=False + schema: "SchemaType", _type: str, discriminators, shmanm, classinfo: _ClassInfo, fwdref=False ): - if type == "array": + if _type == "array": v = Model.typeof(schema) if Model.is_nullable(schema): - v = Optional[v] + v = Optional[v] # type: ignore[assignment] classinfo.root = v - elif type == "object": + elif _type == "object": if ( schema.additionalProperties and isinstance(schema.additionalProperties, (SchemaBase, ReferenceBase)) @@ -423,39 +446,39 @@ def annotationsof_type( additionalProperties: type: string """ - v = Dict[str, Model.typeof(schema.additionalProperties)] + v = Dict[str, Model.typeof(schema.additionalProperties)] # type: ignore[misc,index] if Model.is_nullable(schema): - v = Optional[v] + v = Optional[v] # type: ignore[assignment] classinfo.root = v else: + assert schema.properties is not None for name, f in schema.properties.items(): - r = None canbenull = True r = Model.typeof(f, fwdref=fwdref) if typing.get_origin(r) == Literal: canbenull = False if canbenull: - if getattr(f, "const", None) == None: + if getattr(f, "const", None) is None: """not const""" if name not in schema.required or Model.is_nullable(f): """not required - or nullable""" - r = Optional[r] + r = Optional[r] # type: ignore[assignment] classinfo.properties[Model.nameof(name)].annotation = r - elif type in ("string", "integer", "boolean", "number"): + elif _type in ("string", "integer", "boolean", "number"): pass else: raise ValueError() return classinfo @staticmethod - def types(schema: "SchemaBase"): + def types(schema: "SchemaType"): if isinstance(schema.type, str): yield schema.type else: - typesfilter = set() + typesfilter: Set[str] = set() values: Set[str] if isinstance(schema.type, list): values = set(schema.type) @@ -464,16 +487,17 @@ def types(schema: "SchemaBase"): typesfilter = set() if (const := getattr(schema, "const", None)) is not None: - typesfilter.add(TYPES_SCHEMA_MAP.get(type(const))) + typesfilter.add(cast(str, TYPES_SCHEMA_MAP.get(type(const)))) if enum := getattr(schema, "enum", None): - typesfilter |= set([TYPES_SCHEMA_MAP.get(type(i)) for i in enum]) + typesfilter |= set([cast(str, TYPES_SCHEMA_MAP.get(type(i))) for i in enum]) """ anyOf / oneOf / allOf do not need to be of type object but the type of their children can be used to limit the type of the parent """ + totalOf: List["SchemaType"] if totalOf := sum([getattr(schema, i, []) for i in ["anyOf", "allOf", "oneOf"]], []): tmp = set.union(*[set(Model.types(x)) for x in totalOf]) typesfilter |= tmp @@ -481,6 +505,8 @@ def types(schema: "SchemaBase"): # if (v:=getattr(schema, "items", None)) is None and "array" not in typesfilter: # values.discard("array") + else: + raise StopIteration if typesfilter: values = values & typesfilter @@ -489,31 +515,32 @@ def types(schema: "SchemaBase"): yield i @staticmethod - def is_type(schema: "SchemaBase", type_) -> bool: - if isinstance(schema.type, str) and schema.type == type_ or Model.or_type(schema, type_, l=None): - return True + def is_type(schema: "SchemaType", type_) -> bool: + return isinstance(schema.type, str) and schema.type == type_ or Model.or_type(schema, type_, l=None) @staticmethod - def or_type(schema: "SchemaBase", type_: str, l=2) -> bool: + def or_type(schema: "SchemaType", type_: str, l: int | None = 2) -> bool: return isinstance((t := schema.type), list) and (l is None or len(t) == l) and type_ in t @staticmethod - def is_nullable(schema: "SchemaBase") -> bool: + def is_nullable(schema: "SchemaType") -> bool: return Model.or_type(schema, "null", l=None) or getattr(schema, "nullable", False) @staticmethod - def is_type_any(schema: "SchemaBase"): + def is_type_any(schema: "SchemaType"): return schema.type is None @staticmethod - def fieldof(schema: "SchemaBase", classinfo: _ClassInfo): + def fieldof(schema: "SchemaType", classinfo: _ClassInfo): if schema.type == "array": return classinfo if Model.is_type(schema, "object") or Model.is_type_any(schema): + f: Union[SchemaBase, ReferenceBase] + assert schema.properties is not None for name, f in schema.properties.items(): - f: SchemaBase - args = dict() + args: Dict[str, Any] = dict() + assert schema.required is not None if name not in schema.required: args["default"] = None name = Model.nameof(name, args=args) @@ -528,7 +555,7 @@ def fieldof(schema: "SchemaBase", classinfo: _ClassInfo): return classinfo @staticmethod - def fieldof_args(schema: "SchemaBase", args=None): + def fieldof_args(schema: "SchemaType", args=None): if args is None: args = dict(default=getattr(schema, "default", None)) @@ -546,12 +573,15 @@ def fieldof_args(schema: "SchemaBase", args=None): from . import v20, v30, v31 if isinstance(schema, (v20.Schema, v30.Schema)): - todo = ("multipleOf", "multiple_of") - if (v := getattr(schema, todo[0], None)) is not None: - args[todo[1]] = v - - todo = [("maximum", "exclusiveMaximum", "le", "lt"), ("minimum", "exclusiveMinimum", "ge", "gt")] - for v0, v1, t0, t1 in todo: + mof: Tuple[str, str] = ("multipleOf", "multiple_of") + if (v := getattr(schema, mof[0], None)) is not None: + args[mof[1]] = v + + mum: List[Tuple[str, str, str, str]] = [ + ("maximum", "exclusiveMaximum", "le", "lt"), + ("minimum", "exclusiveMinimum", "ge", "gt"), + ] + for v0, v1, t0, t1 in mum: if v := getattr(schema, v0): if getattr(schema, v1, False): # exclusive args[t1] = v diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index b801a40d..9409ec08 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -1,12 +1,12 @@ -import copy import sys +import typing if sys.version_info >= (3, 9): import pathlib else: import pathlib3x as pathlib -from typing import List, Dict, Set, Union, Callable, Tuple, Any +from typing import List, Dict, Set, Callable, Tuple, Any, Union, cast, Optional, TypeGuard, Type, ForwardRef import collections import inspect import logging @@ -28,11 +28,33 @@ from .errors import ReferenceResolutionError from .loader import Loader, NullLoader from .plugin import Plugin, Plugins -from .base import RootBase, ReferenceBase, SchemaBase +from .base import RootBase, ReferenceBase, SchemaBase, OperationBase +from .request import RequestBase from .v30.paths import Operation +if typing.TYPE_CHECKING: + from ._types import RootType, JSON, PathItemType, SchemaType, OperationType, ReferenceType, RequestType + + +def has_components(y: Optional["RootType"]) -> TypeGuard[Union[v30.Root, v31.Root]]: + # return all([typing.cast("RootType", y), typing.cast("RootType", y).components]) + # return isinstance(y, (v30.Root, v31.Root)) + # return all([y, y.components]) + if y is None: + return False + if y.components is None: + return False + return True + + +def is_schema(v: Tuple[str, "SchemaType"]) -> TypeGuard["SchemaType"]: + return isinstance(v[1], (v20.Schema, v30.Schema, v31.Schema)) + class OpenAPI: + # _root: Union[v20.Root, v30.Root, v31.Root] | None + _root: "RootType" + @property def paths(self): return self._root.paths @@ -57,9 +79,9 @@ def servers(self): def load_sync( cls, url, - session_factory: Callable[[], httpx.Client] = httpx.Client, - loader=None, - plugins: List[Plugin] = None, + session_factory: Callable[..., httpx.Client] = httpx.Client, + loader: Loader | None = None, + plugins: List[Plugin] | None = None, use_operation_tags: bool = False, ) -> "OpenAPI": """ @@ -80,9 +102,9 @@ def load_sync( async def load_async( cls, url: str, - session_factory: Callable[[], httpx.AsyncClient] = httpx.AsyncClient, - loader: Loader = None, - plugins: List[Plugin] = None, + session_factory: Callable[..., httpx.AsyncClient] = httpx.AsyncClient, + loader: Loader | None = None, + plugins: List[Plugin] | None = None, use_operation_tags: bool = False, ) -> "OpenAPI": """ @@ -109,9 +131,9 @@ def load_file( cls, url: str, path: Union[str, pathlib.Path, yarl.URL], - session_factory: Callable[[], Union[httpx.AsyncClient, httpx.Client]] = httpx.AsyncClient, - loader: Loader = None, - plugins: List[Plugin] = None, + session_factory: Callable[..., Union[httpx.AsyncClient, httpx.Client]] = httpx.AsyncClient, + loader: Loader | None = None, + plugins: List[Plugin] | None = None, use_operation_tags: bool = False, ) -> "OpenAPI": """ @@ -136,9 +158,9 @@ def loads( cls, url: str, data: str, - session_factory: Callable[[], Union[httpx.AsyncClient, httpx.Client]] = httpx.AsyncClient, - loader=None, - plugins: List[Plugin] = None, + session_factory: Callable[..., Union[httpx.AsyncClient, httpx.Client]] = httpx.AsyncClient, + loader: Loader | None = None, + plugins: List[Plugin] | None = None, use_operation_tags: bool = False, ) -> "OpenAPI": """ @@ -155,10 +177,11 @@ def loads( data = loader.parse(Plugins(plugins or []), yarl.URL(url), data) return cls(url, data, session_factory, loader, plugins, use_operation_tags) - def _parse_obj(self, document: Dict[str, Any]) -> RootBase: - v = document.get("openapi", None) - if v: - v = list(map(int, v.split("."))) + @classmethod + def _parse_obj(cls, document: "JSON") -> "RootType": + document = cast(Dict[str, Any], document) + if (version := document.get("openapi", None)) is not None: + v = list(map(int, version.split("."))) if v[0] == 3: if v[1] == 0: return v30.Root.model_validate(document) @@ -167,28 +190,26 @@ def _parse_obj(self, document: Dict[str, Any]) -> RootBase: else: raise ValueError(f"openapi version 3.{v[1]} not supported") else: - raise ValueError(f"openapi major version {v[0]} not supported") - return + raise ValueError(f"openapi major version {version} not supported") - v = document.get("swagger", None) - if v: - v = list(map(int, v.split("."))) + if (version := document.get("swagger", None)) is not None: + v = list(map(int, version.split("."))) if v[0] == 2 and v[1] == 0: return v20.Root.model_validate(document) else: - raise ValueError(f"swagger version {'.'.join(v)} not supported") + raise ValueError(f"swagger version {version} not supported") else: raise ValueError("missing openapi/swagger field") def __init__( self, url: str, - document: Dict[str, Any], - session_factory: Callable[[], Union[httpx.Client, httpx.AsyncClient]] = httpx.AsyncClient, - loader: Loader = None, - plugins: List[Plugin] = None, + document: "JSON", + session_factory: Callable[..., Union[httpx.Client, httpx.AsyncClient]] = httpx.AsyncClient, + loader: Loader | None = None, + plugins: List[Plugin] | None = None, use_operation_tags: bool = True, - ) -> "OpenAPI": + ) -> None: """ Creates a new OpenAPI document from a loaded spec file. This is overridden here because we need to specify the path in the parent @@ -203,14 +224,14 @@ def __init__( """ self._base_url: yarl.URL = yarl.URL(url) - self._session_factory: Callable[[], Union[httpx.Client, httpx.AsyncClient]] = session_factory + self._session_factory: Callable[..., Union[httpx.Client, httpx.AsyncClient]] = session_factory - self.loader: Loader = loader + self.loader: Loader | None = loader """ Loader - loading referenced documents """ - self._createRequest: Callable[["OpenAPI", str, str, "Operation"], "RequestBase"] = None + self._createRequest: Callable[["OpenAPI", str, str, "OperationType"], "RequestBase"] """ creates the Async/Request for the protocol required """ @@ -226,7 +247,7 @@ def __init__( e.g. {"BasicAuth": ("user","secret")} """ - self._documents: Dict[yarl.URL, RootBase] = dict() + self._documents: Dict[yarl.URL, "RootType"] = dict() """ the related documents """ @@ -308,10 +329,11 @@ def _init_operationindex(self, use_operation_tags: bool): if isinstance(self._root, v20.Root): if self.paths: + obj: "PathItemType" for path, obj in self.paths.items(): for m in obj.model_fields_set & HTTP_METHODS: - op = getattr(obj, m) - op._validate_path_parameters(obj, path, (m, op.operationId)) + op: "Operation" = getattr(obj, m) + op._validate_path_parameters(obj, path, (m, cast(str, op.operationId))) if op.operationId is None: continue for r, response in op.responses.items(): @@ -319,6 +341,7 @@ def _init_operationindex(self, use_operation_tags: bool): continue if response.headers: for h in response.headers.values(): + h = cast(v20.Header, h) items = v20.Schema(type=h.items.type) if h.items else None h._schema = v20.Schema(type=h.type, items=items) @@ -327,39 +350,47 @@ def _init_operationindex(self, use_operation_tags: bool): elif isinstance(self._root, (v30.Root, v31.Root)): allschemas = [ - x.components.schemas for x in filter(lambda y: all([y, y.components]), self._documents.values()) + x.components.schemas for x in filter(has_components, self._documents.values()) if x.components.schemas ] + for schemas in allschemas: - for name, schema in filter(lambda v: isinstance(v[1], SchemaBase), schemas.items()): + name: str + schema: "SchemaType" + for name, schema in filter(is_schema, schemas.items()): schema._get_identity(name=name, prefix="OP") if self.paths: for path, obj in self.paths.items(): if obj.ref: - obj = obj.ref._target + obj = cast("PathItemType", cast(ReferenceBase, obj.ref)._target) for m in obj.model_fields_set & HTTP_METHODS: op = getattr(obj, m) - op._validate_path_parameters(obj, path, (m, op.operationId)) + op._validate_path_parameters(obj, path, (m, typing.cast(str, op.operationId))) if op.operationId is None: continue for r, response in op.responses.items(): if isinstance(response, Reference): continue + assert response.content is not None for c, content in response.content.items(): if content.schema_ is None: continue if isinstance(content.schema_, (v30.Schema, v31.Schema)): content.schema_._get_identity("OP", f"{path}.{m}.{r}.{c}") else: - self._root.paths = dict() - + if isinstance(self._root, v30.Root): + self._root.paths = v30.Paths(paths={}, extensions={}) + elif isinstance(self._root, v31.Root): + self._root.paths = v31.Paths(paths={}, extensions={}) + else: + raise ValueError(self._root) else: raise ValueError(self._root) self._operationindex = OperationIndex(self, use_operation_tags) @staticmethod - def _iterate_schemas(schemas: Dict[int, SchemaBase], next: Set[int], processed: Set[int]): + def _iterate_schemas(schemas: Dict[int, "SchemaType"], next: Set[int], processed: Set[int]): """ recursively collect all schemas related to the starting set """ @@ -368,65 +399,88 @@ def _iterate_schemas(schemas: Dict[int, SchemaBase], next: Set[int], processed: processed.update(next) - new = collections.ChainMap( - *[ - dict( - filter( - lambda z: z[0] not in processed, - map( - lambda y: (id(y._target), y._target) if isinstance(y, ReferenceBase) else (id(y), y), - filter( - lambda x: isinstance(x, (ReferenceBase, SchemaBase)), - getattr(schemas[i], "oneOf", []) # Swagger compat - + getattr(schemas[i], "anyOf", []) # Swagger compat - + schemas[i].allOf - + list(schemas[i].properties.values()) - + ( - [schemas[i].items] - if schemas[i].type == "array" - and schemas[i].items - and not isinstance(schemas[i], list) - else [] - ) - + ( - schemas[i].items - if schemas[i].type == "array" and schemas[i].items and isinstance(schemas[i], list) - else [] - ), + def not_in_processed(z: Tuple[int, SchemaBase]) -> TypeGuard[Tuple[int, "SchemaType"]]: + return z[0] not in processed + + def is_instance_schema_ref( + x: Union["SchemaType", "ReferenceType"] + ) -> TypeGuard[Union["SchemaType", "ReferenceType"]]: + return isinstance(x, (ReferenceBase, SchemaBase)) + + new: Dict[int, "SchemaType"] = cast( + Dict[int, "SchemaType"], + collections.ChainMap( + *[ + dict( + filter( + not_in_processed, + map( + lambda y: (id(y._target), y._target) if isinstance(y, ReferenceBase) else (id(y), y), + filter( + is_instance_schema_ref, + getattr(schemas[i], "oneOf", []) # Swagger compat + + getattr(schemas[i], "anyOf", []) # Swagger compat + + schemas[i].allOf + + list(schemas[i].properties.values()) + + ( + [schemas[i].items] + if schemas[i].type == "array" + and schemas[i].items + and not isinstance(schemas[i].items, list) + else [] + ) + + ( + schemas[i].items + if schemas[i].type == "array" + and schemas[i].items + and isinstance(schemas[i].items, list) + else [] + ), + ), # type: ignore[operator] ), - ), - ) - ) - for i in next - ] + ) + ) # type: ignore[arg-type] + for i in next + ] + ), ) - sets = new.keys() + sets = set(new.keys()) schemas.update(new) processed.update(sets) return OpenAPI._iterate_schemas(schemas, sets, processed) - def _init_schema_types_collect(self): - byname: Dict[str, SchemaBase] = dict() + def _init_schema_types_collect(self) -> Dict[str, "SchemaType"]: + byname: Dict[str, "SchemaType"] = dict() + + def is_schema(v: Tuple[str, "SchemaType"]) -> bool: + return isinstance(v[1], (v20.Schema, v30.Schema, v31.Schema)) if isinstance(self._root, v20.Root): + documents = cast(List[v20.Root], self._documents.values()) # Schema - for byid in map(lambda x: x.definitions, self._documents.values()): - for name, schema in filter(lambda v: isinstance(v[1], SchemaBase), byid.items()): + for byid in map(lambda x: x.definitions, documents): + assert byid is not None and isinstance(byid, dict) + for name, schema in filter(is_schema, byid.items()): byname[schema._get_identity(name=name)] = schema # Request # Response - for byid in map(lambda x: x.responses, self._documents.values()): - for name, response in filter(lambda v: isinstance(v[1].schema_, SchemaBase), byid.items()): + for byid in map(lambda x: x.responses, documents): + assert byid is not None and isinstance(byid, dict) + for name, response in filter(is_schema, byid.items()): + assert response.schema_ byname[response.schema_._get_identity(name=name)] = response.schema_ elif isinstance(self._root, (v30.Root, v31.Root)): # Schema - components = [x.components for x in filter(lambda y: all([y, y.components]), self._documents.values())] + documents = cast(Union[List[v30.Root], List[v31.Root]], self._documents.values()) + components = [x.components for x in filter(has_components, documents)] + assert components is not None for byid in map(lambda x: x.schemas, components): - for name, schema in filter(lambda v: isinstance(v[1], SchemaBase), byid.items()): + assert byid is not None and isinstance(byid, dict) + for name, schema in filter(is_schema, byid.items()): byname[schema._get_identity(name=name)] = schema # Request @@ -444,6 +498,7 @@ def _init_schema_types_collect(self): if isinstance(response, ReferenceBase): response = response._target if isinstance(response, (v30.paths.Response, v31.paths.Response)): + assert response.content is not None for c, content in response.content.items(): if content.schema_ is None: continue @@ -455,6 +510,7 @@ def _init_schema_types_collect(self): # Response for responses in map(lambda x: x.responses, components): + assert responses is not None for rname, response in responses.items(): for content_type, media_type in response.content.items(): if media_type.schema_ is None: @@ -464,19 +520,18 @@ def _init_schema_types_collect(self): byname = self.plugins.init.schemas(initialized=self._root, schemas=byname).schemas return byname - def _init_schema_types(self): - byname: Dict[str, SchemaBase] = self._init_schema_types_collect() - byid: Dict[int, SchemaBase] = {id(i): i for i in byname.values()} + def _init_schema_types(self) -> None: + byname: Dict[str, "SchemaType"] = self._init_schema_types_collect() + byid: Dict[int, "SchemaType"] = {id(i): i for i in byname.values()} data: Set[int] = set(byid.keys()) todo: Set[int] = self._iterate_schemas(byid, data, set()) - - types: Dict[int, "BaseModel"] = dict() + types: Dict[str, Union[ForwardRef, Type[BaseModel], Type[int], Type[str], Type[float], Type[bool]]] = dict() for i in todo | data: b = byid[i] name = b._get_identity("X") types[name] = b.get_type() - for idx, i in enumerate(b._model_types): - types[f"{name}.c{idx}"] = i + for idx, j in enumerate(b._model_types): + types[f"{name}.c{idx}"] = j for name, schema in types.items(): if not (inspect.isclass(schema) and issubclass(schema, BaseModel)): @@ -530,10 +585,13 @@ def authenticate(self, *args, **kwargs): self._security = dict() schemes = frozenset(kwargs.keys()) + if isinstance(self._root, v20.Root): v = schemes - frozenset(SecuritySchemes := self._root.securityDefinitions) elif isinstance(self._root, (v30.Root, v31.Root)): v = schemes - frozenset(SecuritySchemes := self._root.components.securitySchemes) + else: + raise TypeError(self._root) # noqa if v: raise ValueError("{} does not accept security schemes {}".format(self.info.title, sorted(v))) @@ -555,6 +613,7 @@ def authenticate(self, *args, **kwargs): def _load(self, url: yarl.URL): self.log.debug(f"Downloading Description Document {url} using {self.loader} …") + assert self.loader data = self.loader.get(self.plugins, url) return self._parse_obj(data) @@ -566,7 +625,7 @@ def _(self) -> OperationIndex: """ return self._operationindex - def createRequest(self, operationId: Union[str, Tuple[str, str]]) -> aiopenapi3.request.RequestBase: + def createRequest(self, operationId: Union[str, Tuple[str, str]]) -> "RequestType": """ create a Request @@ -578,23 +637,28 @@ def createRequest(self, operationId: Union[str, Tuple[str, str]]) -> aiopenapi3. :return: the returned Request is either :class:`aiopenapi3.request.RequestBase` or - in case of a httpx.AsyncClient session_factory - :class:`aiopenapi3.request.AsyncRequestBase` """ + operation: Optional["OperationType"] = None + request: Optional["RequestType"] = None try: if isinstance(operationId, str): - p = operationId.split(".") - req = self._operationindex - for i in p: - req = getattr(req, i) - assert isinstance(req, aiopenapi3.request.RequestBase) + *tags, opn = operationId.split(".") + opi: OperationIndex = self._operationindex + for i in tags: + opi = getattr(opi, i) + _, _, operation = opi._operations[opn] + request = getattr(opi, opn) + assert isinstance(request, aiopenapi3.request.RequestBase) else: path, method = operationId pathitem = self._root.paths[path] if pathitem.ref: pathitem = pathitem.ref._target - op = getattr(pathitem, method) - req = self._createRequest(self, method, path, op) - return req + operation = getattr(pathitem, method) + assert operation is not None + request = self._createRequest(self, method, path, operation) + return request except Exception as e: - raise aiopenapi3.errors.RequestError(None, None, None, None) from e + raise aiopenapi3.errors.RequestError(operation, request, None, {}) from e def resolve_jr(self, root: RootBase, obj, value: Reference): """ @@ -605,9 +669,9 @@ def resolve_jr(self, root: RootBase, obj, value: Reference): :param value: :return: """ - url, jp = JSONReference.split(value.ref) - if url != "": - url = yarl.URL(url) + urlstr, jp = JSONReference.split(value.ref) + if urlstr != "": + url: yarl.URL = yarl.URL(urlstr) if url not in self._documents: self.log.debug(f"Resolving {value.ref} - Description Document {url} unknown …") try: @@ -651,7 +715,7 @@ def __copy__(self) -> "OpenAPI": api.loader = self.loader return api - def clone(self, baseurl: yarl.URL = None) -> "OpenAPI": + def clone(self, baseurl: yarl.URL | None = None) -> "OpenAPI": """ shallwo copy the api object optional set a base url @@ -664,7 +728,7 @@ def clone(self, baseurl: yarl.URL = None) -> "OpenAPI": return api @staticmethod - def cache_load(path: pathlib.Path, plugins: List[Plugin] = None, session_factory=None) -> "OpenAPI": + def cache_load(path: pathlib.Path, plugins: List[Plugin] | None = None, session_factory=None) -> "OpenAPI": """ read a pickle api object from path and init the schema types @@ -693,7 +757,7 @@ def cache_store(self, path: pathlib.Path) -> None: """ restore = (self.loader, self.plugins, self._session_factory) - self.loader = self._session_factory = self.plugins = None + self.loader = self._session_factory = self.plugins = None # type: ignore[assignment] with path.open("wb") as f: pickle.dump(self, f) self.loader, self.plugins, self._session_factory = restore diff --git a/aiopenapi3/plugin.py b/aiopenapi3/plugin.py index b13c4245..e55b136a 100644 --- a/aiopenapi3/plugin.py +++ b/aiopenapi3/plugin.py @@ -1,20 +1,30 @@ import dataclasses import typing -from typing import List, Any, Dict, Optional, Type +from typing import TYPE_CHECKING, List, Any, Dict, Optional, Type import abc from pydantic import BaseModel import yarl +if TYPE_CHECKING: + from aiopenapi3 import OpenAPI + + import httpx + from .base import PathItemBase, SchemaBase + """ the plugin interface replicates the suds way of dealing with broken data/schema information """ class Plugin(abc.ABC): - def __init__(self): - self._api: "OpenAPI" = None + @dataclasses.dataclass + class Context: + ... + + def __init__(self) -> None: + self._api: Optional["OpenAPI"] = None @property def api(self): @@ -30,24 +40,24 @@ def api(self, v): class Init(Plugin): @dataclasses.dataclass class Context: - initialized: "aiopenapi3.OpenAPI" = None + initialized: Optional["OpenAPI"] = None """available in :func:`~aiopenapi3.plugin.Init.initialized`""" - schemas: Dict[str, "Schema"] = None + schemas: Dict[str, "SchemaBase"] | None = None """available in :func:`~aiopenapi3.plugin.Init.schemas`""" - paths: Dict[str, "PathItemBase"] = None + paths: Dict[str, "PathItemBase"] | None = None """available in :func:`~aiopenapi3.plugin.Init.paths`""" def schemas(self, ctx: "Init.Context") -> "Init.Context": # pragma: no cover """modify the Schema before creating Models""" - pass + return ctx # noqa def paths(self, ctx: "Init.Context") -> "Init.Context": # pragma: no cover """modify the paths/PathItems before initializing the Operations""" - pass + return ctx # noqa def initialized(self, ctx: "Init.Context") -> "Init.Context": # pragma: no cover """it is initialized""" - pass + return ctx # noqa class Document(Plugin): @@ -64,11 +74,11 @@ class Context: def loaded(self, ctx: "Document.Context") -> "Document.Context": # pragma: no cover """modify the text before parsing""" - pass + return ctx # noqa def parsed(self, ctx: "Document.Context") -> "Document.Context": # pragma: no cover """modify the parsed dict before …""" - pass + return ctx # noqa class Message(Plugin): @@ -81,7 +91,9 @@ class Message(Plugin): @dataclasses.dataclass class Context: operationId: str - """available :func:`~aiopenapi3.plugin.Message.marshalled` :func:`~aiopenapi3.plugin.Message.sending` :func:`~aiopenapi3.plugin.Message.received` :func:`~aiopenapi3.plugin.Message.parsed` :func:`~aiopenapi3.plugin.Message.unmarshalled`""" + """available :func:`~aiopenapi3.plugin.Message.marshalled` :func:`~aiopenapi3.plugin.Message.sending` + :func:`~aiopenapi3.plugin.Message.received` :func:`~aiopenapi3.plugin.Message.parsed` + :func:`~aiopenapi3.plugin.Message.unmarshalled`""" marshalled: Optional[Dict[str, Any]] = None """available :func:`~aiopenapi3.plugin.Message.marshalled` """ sending: Optional[str] = None @@ -105,31 +117,31 @@ def marshalled(self, ctx: "Message.Context") -> "Message.Context": # pragma: no """ modify the dict before sending """ - pass + return ctx # noqa def sending(self, ctx: "Message.Context") -> "Message.Context": # pragma: no cover """ modify the text before sending """ - pass + return ctx # noqa def received(self, ctx: "Message.Context") -> "Message.Context": # pragma: no cover """ modify the received text """ - pass + return ctx # noqa def parsed(self, ctx: "Message.Context") -> "Message.Context": # pragma: no cover """ modify the parsed dict structure """ - pass + return ctx # noqa def unmarshalled(self, ctx: "Message.Context") -> "Message.Context": # pragma: no cover """ modify the object """ - pass + return ctx # noqa class Domain: @@ -138,7 +150,7 @@ def __init__(self, ctx, plugins: List[Plugin]): self.plugins = plugins def __getstate__(self): - return (self.ctx, self.plugins) + return self.ctx, self.plugins def __setstate__(self, state): self.ctx, self.plugins = state @@ -177,9 +189,15 @@ def __init__(self, plugins: List[Plugin]): self._document = self._get_domain("document", plugins) self._message = self._get_domain("message", plugins) - def _get_domain(self, name, plugins) -> "Domain": - domain = self._domains.get(name) - p: List[Plugin] = list(filter(lambda x: isinstance(x, domain), plugins)) + def _get_domain(self, name: str, plugins: List[Plugin]) -> "Domain": + domain: Type[Plugin] | None + if (domain := self._domains.get(name)) is None: + raise ValueError(name) # noqa + + def domain_type_f(p: Plugin) -> typing.TypeGuard[Plugin]: + return isinstance(p, domain) + + p: List[Plugin] = list(filter(domain_type_f, plugins)) return Domain(domain.Context, p) @property diff --git a/aiopenapi3/request.py b/aiopenapi3/request.py index 4a2089bc..961344e4 100644 --- a/aiopenapi3/request.py +++ b/aiopenapi3/request.py @@ -1,8 +1,8 @@ import abc import collections -import io +import typing from contextlib import closing -from typing import Dict, Tuple, Union, Any, Optional, List +from typing import Dict, Tuple, Any, List, NamedTuple, Optional, Iterator, Union, cast import httpx import pydantic @@ -23,27 +23,55 @@ async def aclosing(thing): await thing.aclose() -from .base import HTTP_METHODS +from .base import HTTP_METHODS, ReferenceBase from .version import __version__ from .errors import RequestError, OperationIdDuplicationError +if typing.TYPE_CHECKING: + from ._types import ( + RequestParameters, + RequestData, + RequestFiles, + RequestContent, + AuthTypes, + SchemaType, + ParameterType, + PathItemType, + OperationType, + JSON, + RootType, + ResponseDataType, + ResponseHeadersType, + ) + from aiopenapi3 import OpenAPI + + class RequestParameter: - def __init__(self, url: yarl.URL): + def __init__(self, url: yarl.URL | str): self.url: str = str(url) - self.auth: Optional[Union["BasicAuth", "DigestAuth"]] = None + self.auth: Optional["AuthTypes"] = None self.cookies: Dict[str, str] = {} # self.path = {} self.params: Dict[str, str] = {} - self.content = None + self.content: Optional["RequestContent"] = None self.headers: Dict[str, str] = {} self.data: Dict[str, str] = {} # form-data - self.files: Dict[str, Tuple[str, io.BaseIO, str]] = {} # form-data files + self.files: Optional["RequestFiles"] = {} # form-data files self.cert: Any = None class RequestBase: - StreamResponse = collections.namedtuple("StreamResponse", field_names=["headers", "schema", "session", "result"]) + class StreamResponse(NamedTuple): + headers: Dict[str, str] + schema: "SchemaType" + session: httpx.Client + result: httpx.Response + + class Response(NamedTuple): + headers: Dict[str, str] + data: Any + result: httpx.Response """ A Request compiles all required information to call an Operation @@ -56,15 +84,15 @@ class RequestBase: - :meth:`~aiopenapi3.request.RequestBase.request` """ - def __init__(self, api: "OpenAPI", method: str, path: str, operation: "Operation"): - self.api = api - self.root = api._root - self.method = method - self.path = path - self.operation = operation + def __init__(self, api: "OpenAPI", method: str, path: str, operation: "OperationType"): + self.api: "OpenAPI" = api + self.root = api._root # pylint: disable=W0212 + self.method: str = method + self.path: str = path + self.operation: "OperationType" = operation self.req: RequestParameter = RequestParameter(self.path) - def __call__(self, *args, return_headers: bool = False, **kwargs) -> Union[Any, Tuple[Dict[str, str], Any]]: + def __call__(self, *args, return_headers: bool = False, **kwargs) -> Union["JSON", Tuple[Dict[str, str], "JSON"]]: """ :param args: :param return_headers: if set return a tuple (header, body) @@ -87,7 +115,9 @@ def _session_factory_default_args(self) -> Dict[str, Any]: """ return {"cert": self.req.cert, "auth": self.req.auth, "headers": {"user-agent": f"aiopenapi3/{__version__}"}} - def _send(self, session, data, parameters): + def _send( + self, session: httpx.Client, data: Optional["RequestData"], parameters: Optional["RequestParameters"] + ) -> httpx.Response: req = self._build_req(session) try: result = session.send(req, stream=True) @@ -96,26 +126,43 @@ def _send(self, session, data, parameters): return result @abc.abstractmethod - def _process_stream(self, result: httpx.Response) -> Tuple[Dict[str, Any], "SchemaBase"]: + def _process_stream(self, result: httpx.Response) -> Tuple["ResponseHeadersType", Optional["SchemaType"]]: """ process response headers lookup the schema for the stream """ - pass # noqa + ... @abc.abstractmethod - def _process_request(self, result: httpx.Response) -> Tuple[Dict[str, str], Union[pydantic.BaseModel, str]]: + def _process_request(self, result: httpx.Response) -> Tuple["ResponseHeadersType", "ResponseDataType"]: """ process response headers lookup Model """ - pass # noqa + ... + + @abc.abstractmethod + def _prepare(self, data: Optional["RequestData"], parameters: Optional["RequestParameters"]) -> None: + ... + + def _build_req(self, session: Union[httpx.Client, httpx.AsyncClient]) -> httpx.Request: + req = session.build_request( + self.method, + str(self.api.url / self.req.url[1:]), + headers=self.req.headers, + cookies=self.req.cookies, + params=self.req.params, + content=self.req.content, + data=self.req.data, + files=self.req.files, + ) + return req def request( self, - data=Union[Dict[str, Any], pydantic.BaseModel], - parameters: Dict[str, Union[str, pydantic.BaseModel]] = None, - ) -> Tuple[Dict[str, Any], Any, httpx.Response]: + data: Optional["RequestData"] = None, + parameters: Optional["RequestParameters"] = None, + ) -> "RequestBase.Response": """ Sends an HTTP request as described by this Path @@ -137,13 +184,13 @@ def request( result.read() headers, data = self._process_request(result) - return headers, data, result + return RequestBase.Response(headers, data, result) def stream( self, - data=Union[Dict[str, Any], pydantic.BaseModel], - parameters: Dict[str, Union[str, pydantic.BaseModel]] = None, - ) -> Tuple["SchemaBase", httpx.Client, httpx.Response]: + data: Optional["RequestData"] = None, + parameters: Optional["RequestParameters"] = None, + ) -> "RequestBase.StreamResponse": """ Sends an HTTP request as described by this Path - but do not process the result * returns a tuple of Schema, httpx.Client, httpx.Response @@ -168,37 +215,49 @@ def stream( @property @abc.abstractmethod - def data(self) -> "SchemaBase": + def data(self) -> Optional["SchemaType"]: """ :return: the Schema for the body """ - pass # noqa + ... @property @abc.abstractmethod - def parameters(self) -> List["ParameterBase"]: + def parameters(self) -> List["ParameterType"]: """ :return: list of :class:`aiopenapi3.base.ParameterBase` which can be used to inspect the required/optional parameters of the requested Operation """ - pass # noqa + ... class AsyncRequestBase(RequestBase): - async def __call__(self, *args, return_headers: bool = False, **kwargs): + class StreamResponse(NamedTuple): + headers: Dict[str, str] + schema: "SchemaType" + session: httpx.AsyncClient + result: httpx.Response + + async def __call__( # type: ignore[override] + self, *args, return_headers: bool = False, **kwargs + ) -> Union["JSON", Tuple[Dict[str, str], "JSON"]]: headers, data, result = await self.request(*args, **kwargs) if return_headers: return headers, data return data - async def _send(self, session: httpx.AsyncClient, data, parameters) -> httpx.Response: + async def _send( + self, session: httpx.AsyncClient, data: Optional["RequestData"], parameters: Optional["RequestParameters"] + ) -> httpx.Response: # type: ignore[override] req = self._build_req(session) try: result = await session.send(req, stream=True) except Exception as e: - raise RequestError(self.operation, req, data, parameters) from e + raise RequestError(self.operation, req, data, parameters or dict()) from e return result - async def request(self, data=None, parameters=None) -> Tuple[Dict[str, Any], Any, httpx.Response]: + async def request( # type: ignore[override] + self, data: Optional["RequestData"] = None, parameters: Optional["RequestParameters"] = None + ) -> "RequestBase.Response": self._prepare(data, parameters) async with aclosing(self.api._session_factory(**self._session_factory_default_args)) as session: result = await self._send(session, data, parameters) @@ -211,39 +270,38 @@ async def request(self, data=None, parameters=None) -> Tuple[Dict[str, Any], Any await result.aread() headers, data = self._process_request(result) - return headers, data, result + return RequestBase.Response(headers, data, result) - async def stream( - self, - data=None, - parameters=None, - ) -> Tuple["aiopenapi3.base.SchemaBase", httpx.AsyncClient, httpx.Response]: + async def stream( # type: ignore[override] + self, data: Optional["RequestData"] = None, parameters: Optional["RequestParameters"] = None + ) -> "AsyncRequestBase.StreamResponse": self._prepare(data, parameters) session = self.api._session_factory(**self._session_factory_default_args) result = await self._send(session, data, parameters) headers, schema_ = self._process_stream(result) - return RequestBase.StreamResponse(headers, schema_, session, result) + return AsyncRequestBase.StreamResponse(headers, schema_, session, result) class OperationIndex: class OperationTag: - def __init__(self, oi): + def __init__(self, oi: "OperationIndex") -> None: self._oi = oi - self._operations: Dict[str, "Operation"] = dict() + self._operations: Dict[str, Tuple[str, str, "OperationType"]] = dict() - def __getattr__(self, item): + def __getattr__(self, item) -> RequestBase: (method, path, op) = self._operations[item] return self._oi._api._createRequest(self._oi._api, method, path, op) class Iter: def __init__(self, spec: "OpenAPI", use_operation_tags: bool): self.operations = [] - self.r = 0 - pi: "PathItem" + self.r: Iterator[int] + pi: "PathItemType" for path, pi in spec.paths.items(): - op: "Operation" + op: "OperationType" if pi.ref: - pi = pi.ref._target + # pi = pi.ref._target + pi = cast("PathItemType", cast(ReferenceBase, pi.ref)._target) for method in pi.model_fields_set & HTTP_METHODS: op = getattr(pi, method) @@ -264,12 +322,14 @@ def __next__(self): def __init__(self, api: "OpenAPI", use_operation_tags: bool): self._api: "OpenAPI" = api - self._root: "RootBase" = api._root + self._root: "RootType" = api._root - self._operations: Dict[str, "Operation"] = dict() - self._tags: Dict[str, "OperationTag"] = collections.defaultdict(lambda: OperationIndex.OperationTag(self)) + self._operations: Dict[str, Tuple[str, str, "OperationType"]] = dict() + self._tags: Dict[str, "OperationIndex.OperationTag"] = collections.defaultdict( + lambda: OperationIndex.OperationTag(self) + ) for path, pi in self._root.paths.items(): - op: "Operation" + op: "OperationType" if pi.ref: pi = pi.ref._target for method in pi.model_fields_set & HTTP_METHODS: diff --git a/aiopenapi3/v20/general.py b/aiopenapi3/v20/general.py index 680cb43e..8f93bda7 100644 --- a/aiopenapi3/v20/general.py +++ b/aiopenapi3/v20/general.py @@ -1,10 +1,14 @@ -from typing import Optional +import typing +from typing import Optional, Any -from pydantic import Field +from pydantic import Field, ConfigDict, PrivateAttr from ..base import ObjectExtended, ObjectBase, ReferenceBase +if typing.TYPE_CHECKING: + from .._types import SchemaType + class ExternalDocumentation(ObjectExtended): """ @@ -27,11 +31,11 @@ class Reference(ObjectBase, ReferenceBase): ref: str = Field(alias="$ref") - _target: object = None + _target: "SchemaType" = PrivateAttr() - model_config = dict(extra="ignore") + model_config = ConfigDict(extra="ignore") - def __getattr__(self, item): + def __getattr__(self, item: str) -> Any: if item != "_target": return getattr(self._target, item) else: diff --git a/aiopenapi3/v20/glue.py b/aiopenapi3/v20/glue.py index 4f215db9..2f1105da 100644 --- a/aiopenapi3/v20/glue.py +++ b/aiopenapi3/v20/glue.py @@ -1,53 +1,79 @@ -from typing import List, Union, cast, Tuple, Dict +import typing +from typing import List, Union, cast, Tuple, Dict, Optional, TypeGuard, Sequence import json import httpx import pydantic -from ..base import SchemaBase, ParameterBase +from ..base import SchemaBase, ParameterBase, ReferenceBase from ..request import RequestBase, AsyncRequestBase from ..errors import HTTPStatusError, ContentTypeError, ResponseSchemaError, ResponseDecodingError, HeadersMissingError from .parameter import Parameter +from .root import Root try: import httpx_auth -except: +except ImportError: httpx_auth = None +if typing.TYPE_CHECKING: + from .._types import ParameterType, ReferenceType, RequestParameters, RequestData + from .schemas import Schema + from .general import Reference + from .paths import Response as v20ResponseType + + +def in_body(x: Union["Parameter", "Reference"]) -> TypeGuard["Parameter"]: + if isinstance(x, Parameter): + return x.in_ == "body" + return in_body(x._target) + + +def in_not_body(x: Union["Parameter", "Reference"]) -> TypeGuard["Parameter"]: + if isinstance(x, Parameter): + return x.in_ != "body" + return in_not_body(x._target) + class Request(RequestBase): + root: Root + @property def security(self): return self.api._security @property - def _data_parameter(self) -> Parameter: - for i in filter(lambda x: x.in_ == "body", self.operation.parameters): + def _data_parameter(self) -> "Parameter": + for i in filter(in_body, self.operation.parameters): return i raise ValueError("body") @property - def data(self) -> SchemaBase: + def data(self) -> Optional["Schema"]: try: return self._data_parameter.schema_ except ValueError: return None @property - def parameters(self) -> List[ParameterBase]: - return list( - filter(lambda x: x.in_ != "body", self.operation.parameters + self.root.paths[self.path].parameters) - ) + def parameters(self) -> List["Parameter"]: + return list(filter(in_not_body, self.operation.parameters + self.root.paths[self.path].parameters)) def args(self, content_type: str = "application/json"): op = self.operation parameters = op.parameters + self.root.paths[self.path].parameters - schema = op.requestBody.content[content_type].schema_ + if op.requestBody and op.requestBody.content and (media := op.requestBody.content[content_type]): + schema = media.schema_ + else: + schema = None return {"parameters": parameters, "data": schema} - def return_value(self, http_status: int = 200, content_type: str = "application/json") -> SchemaBase: - return self.operation.responses[str(http_status)].schema_ + def return_value(self, http_status: int = 200, content_type: str = "application/json") -> Optional["Schema"]: + try: + return self.operation.responses[str(http_status)].schema_ + except KeyError: + return None def _prepare_security(self): security = self.operation.security or self.api._root.security @@ -79,13 +105,14 @@ def _prepare_security(self): f"No security requirement satisfied (accepts {options} given {{{' and '.join(sorted(self.security.keys()))}}})" ) - def _prepare_secschemes(self, scheme: str, value: Union[str, List[str]]): + def _prepare_secschemes(self, scheme: str, value: Union[str, Sequence[str]]) -> None: if httpx_auth is not None: - return self._prepare_secschemes_extra(scheme, value) + self._prepare_secschemes_extra(scheme, value) else: - return self._prepare_secschemes_default(scheme, value) + self._prepare_secschemes_default(scheme, value) - def _prepare_secschemes_default(self, scheme: str, value: Union[str, List[str]]): + def _prepare_secschemes_default(self, scheme: str, value: Union[str, Sequence[str]]) -> None: + assert scheme in self.root.securityDefinitions and self.root.securityDefinitions[scheme] is not None ss = self.root.securityDefinitions[scheme].root if ss.type == "basic": @@ -102,7 +129,8 @@ def _prepare_secschemes_default(self, scheme: str, value: Union[str, List[str]]) # apiKey in query header data self.req.headers[ss.name] = value - def _prepare_secschemes_extra(self, scheme: str, value: Union[str, List[str]]): + def _prepare_secschemes_extra(self, scheme: str, value: Union[str, Sequence[str]]) -> None: + assert scheme in self.root.securityDefinitions and self.root.securityDefinitions[scheme] is not None ss = self.root.securityDefinitions[scheme].root if ss.type == "basic": @@ -119,7 +147,7 @@ def _prepare_secschemes_extra(self, scheme: str, value: Union[str, List[str]]): # apiKey in query header data self.req.auth = httpx_auth.HeaderApiKey(value, ss.name) - def _prepare_parameters(self, provided): + def _prepare_parameters(self, provided: Optional["RequestParameters"]): provided = provided or dict() possible = {_.name: _ for _ in self.operation.parameters + self.root.paths[self.path].parameters} @@ -171,7 +199,7 @@ def _prepare_parameters(self, provided): self.req.url = self.req.url.format(**path_parameters) - def _prepare_body(self, data): + def _prepare_body(self, data: Optional["RequestData"]): try: required = self._data_parameter.required except ValueError: @@ -199,25 +227,12 @@ def _prepare_body(self, data): else: raise NotImplementedError(f"unsupported mime types {consumes}") - def _prepare(self, data, parameters): + def _prepare(self, data: Optional["RequestData"], parameters: Optional["RequestParameters"]): self._prepare_security() self._prepare_parameters(parameters) self._prepare_body(data) - def _build_req(self, session): - req = session.build_request( - self.method, - str(self.api.url / self.req.url[1:]), - headers=self.req.headers, - cookies=self.req.cookies, - params=self.req.params, - content=self.req.content, - data=self.req.data, - files=self.req.files, - ) - return req - - def _process__status_code(self, result: httpx.Response, status_code: str): + def _process__status_code(self, result: httpx.Response, status_code: str) -> "v20ResponseType": # find the response model in spec we received expected_response = None if status_code in self.operation.responses: @@ -235,7 +250,9 @@ def _process__status_code(self, result: httpx.Response, status_code: str): ) return expected_response - def _process__headers(self, result, headers, expected_response): + def _process__headers( + self, result: httpx.Response, headers: Dict[str, str], expected_response: "v20ResponseType" + ) -> Dict[str, str]: rheaders = dict() if expected_response.headers: required = dict(map(lambda x: (x[0].lower(), x[1]), expected_response.headers.items())) @@ -253,13 +270,13 @@ def _process__headers(self, result, headers, expected_response): rheaders[name] = header._schema.model(header._decode(data)) return rheaders - def _process_stream(self, result: httpx.Response) -> "SchemaBase": + def _process_stream(self, result: httpx.Response) -> Tuple[Dict[str, str], Optional["Schema"]]: status_code = str(result.status_code) expected_response = self._process__status_code(result, status_code) headers = self._process__headers(result, result.headers, expected_response) return headers, expected_response.schema_ - def _process_request(self, result: httpx.Response) -> Tuple[Dict[str, str], Union[pydantic.BaseModel, str]]: + def _process_request(self, result: httpx.Response) -> Tuple[Dict[str, str], pydantic.BaseModel | str | None]: rheaders = dict() # spec enforces these are strings status_code = str(result.status_code) @@ -297,7 +314,7 @@ def _process_request(self, result: httpx.Response) -> Tuple[Dict[str, str], Unio ).parsed if expected_response.schema_ is None: - raise ResponseSchemaError(self.operation, expected_response, expected_response.schema_, result, None) + raise ResponseSchemaError(self.operation, expected_response, None, result, None) try: data = expected_response.schema_.model(data) @@ -308,7 +325,7 @@ def _process_request(self, result: httpx.Response) -> Tuple[Dict[str, str], Unio operationId=self.operation.operationId, unmarshalled=data ).unmarshalled return rheaders, data - elif content_type in self.operation.produces: + elif self.operation.produces and content_type in self.operation.produces: return rheaders, result.content else: raise ContentTypeError( @@ -320,7 +337,7 @@ def _process_request(self, result: httpx.Response) -> Tuple[Dict[str, str], Unio class AsyncRequest(Request, AsyncRequestBase): - def _prepare_secschemes(self, scheme: str, value: Union[str, List[str]]): + def _prepare_secschemes(self, scheme: str, value: Union[str, Sequence[str]]): """ httpx_auth does not support async yet https://github.com/Colin-b/httpx_auth/pull/48 diff --git a/aiopenapi3/v20/paths.py b/aiopenapi3/v20/paths.py index 2b6becb2..df98e7c3 100644 --- a/aiopenapi3/v20/paths.py +++ b/aiopenapi3/v20/paths.py @@ -7,7 +7,7 @@ from .parameter import Header, Parameter from .schemas import Schema from .security import SecurityRequirement -from ..base import ObjectExtended, ObjectBase, PathsBase, OperationBase +from ..base import ObjectExtended, PathsBase, OperationBase, PathItemBase class Response(ObjectExtended): @@ -44,7 +44,7 @@ class Operation(ObjectExtended, OperationBase): security: Optional[List[SecurityRequirement]] = Field(default=None) -class PathItem(ObjectExtended): +class PathItem(ObjectExtended, PathItemBase): """ A Path Item, as defined `here`_. Describes the operations available on a single path. diff --git a/aiopenapi3/v30/formdata.py b/aiopenapi3/v30/formdata.py index d6de86ca..78a0eb18 100644 --- a/aiopenapi3/v30/formdata.py +++ b/aiopenapi3/v30/formdata.py @@ -1,10 +1,17 @@ import base64 import quopri -from typing import List, Tuple, Dict, Union +from typing import List, Tuple, Dict, Union, TYPE_CHECKING from email.mime import multipart, nonmultipart from email.message import _unquotevalue, Message import collections +from .parameter import encode_parameter + + +if TYPE_CHECKING: + from pydantic import BaseModel + from .._types import MediaTypeType, SchemaType + class MIMEFormdata(nonmultipart.MIMENonMultipart): def __init__(self, keyname, *args, **kwargs): @@ -22,9 +29,6 @@ def _write_headers(self, generator): pass -from .parameter import encode_parameter - - def parameters_from_multipart(data, media, rbq): params = list() for k in data.model_fields_set: @@ -42,7 +46,7 @@ def parameters_from_multipart(data, media, rbq): elif p.type == "object": ct = "application/json" - if (e := media.encoding.get(k, None)) != None: + if (e := media.encoding.get(k, None)) is not None: ct = e.contentType or ct style = e.style or "form" explode = e.explode if e.explode is not None else (True if style == "form" else False) @@ -69,12 +73,15 @@ def parameters_from_multipart(data, media, rbq): return params -def parameters_from_urlencoded(data: "BaseModel", media: "Media"): +def parameters_from_urlencoded(data: "BaseModel", media: "MediaTypeType"): params = collections.defaultdict(lambda: list()) + k: str for k in data.model_fields_set: v = getattr(data, k) + assert media.encoding is not None if (e := media.encoding.get(k, None)) != None: + assert e explode = e.explode allowReserved = e.allowReserved style = e.style @@ -83,6 +90,7 @@ def parameters_from_urlencoded(data: "BaseModel", media: "Media"): allowReserved = False style = "form" + assert media.schema_ is not None and media.schema_.properties is not None m = media.schema_.properties[k] if isinstance(v, list): for i in v: @@ -94,7 +102,7 @@ def parameters_from_urlencoded(data: "BaseModel", media: "Media"): return params -def encode_content(data, codec): +def encode_content(data: bytes, codec: str) -> bytes: """ … supports all encodings defined in [RFC4648], including “base64” and “base64url”, as well as “quoted-printable” from [RFC2045]. :param data: @@ -110,14 +118,16 @@ def encode_content(data, codec): r = base64.b64encode(data) elif codec == "base64url": r = base64.urlsafe_b64encode(data).rstrip(b"=") - return r.decode() + return r elif codec == "quoted-printable": return quopri.encodestring(data) else: raise ValueError(f"unsupported codec {codec}") -def encode_multipart_parameters(fields: List[Tuple[str, str, Union[str, bytes], Dict[str, str], "Schema"]]): +def encode_multipart_parameters( + fields: List[Tuple[str, str, Union[str, bytes], Dict[str, str], "SchemaType"]] +) -> MIMEMultipart: """ As shown in https://julien.danjou.info/handling-multipart-form-data-python/ @@ -132,9 +142,9 @@ def encode_multipart_parameters(fields: List[Tuple[str, str, Union[str, bytes], if type in ["image", "audio", "application"]: if isinstance(value, bytes): - v = value + v: bytes = value else: - v = value.encode() + v: bytes = value.encode() codec = "base64" @@ -146,7 +156,7 @@ def encode_multipart_parameters(fields: List[Tuple[str, str, Union[str, bytes], else: """OpenAPI 3.0""" - data = encode_content(v, codec) + data: bytes = encode_content(v, codec) """ email.message_from_… uses content-transfer-encoding @@ -154,16 +164,17 @@ def encode_multipart_parameters(fields: List[Tuple[str, str, Union[str, bytes], headers["Content-Transfer-Encoding"] = codec elif type in ["text", "rfc822"]: - data = value + data: Union[str, bytes] = value else: type, subtype = "text", "plain" - data = value + data: Union[str, bytes] = value env = MIMEFormdata(field, type, subtype) for header, value in headers.items(): env.add_header(header, value) + v: str for k, v in params: env.set_param(k, v, "Content-Type") @@ -183,5 +194,5 @@ def decode_content_type(value: str) -> Tuple[str, str, List[Tuple[str, str]]]: m.add_header("content-type", value) ct, *params = m.get_params() - type, _, subtype = ct[0].partition("/") - return type, subtype, params + type_, _, subtype = ct[0].partition("/") + return type_, subtype, params diff --git a/aiopenapi3/v30/general.py b/aiopenapi3/v30/general.py index de191b71..808e26f4 100644 --- a/aiopenapi3/v30/general.py +++ b/aiopenapi3/v30/general.py @@ -1,11 +1,17 @@ -from typing import Optional +import typing +from typing import Optional, Union, Any -from pydantic import Field +from pydantic import Field, PrivateAttr, ConfigDict from ..base import ObjectExtended, ObjectBase, ReferenceBase +if typing.TYPE_CHECKING: + from .schemas import Schema + from .parameter import Parameter + + class ExternalDocumentation(ObjectExtended): """ An `External Documentation Object`_ references external resources for extended @@ -27,13 +33,13 @@ class Reference(ObjectBase, ReferenceBase): ref: str = Field(alias="$ref") - _target: object = None + _target: Union["Schema", "Parameter", "Reference"] = PrivateAttr() - model_config = dict( + model_config = ConfigDict( extra="ignore", # """This object cannot be extended with additional properties and any properties added SHALL be ignored.""" ) - def __getattr__(self, item): + def __getattr__(self, item: str) -> Any: if item != "_target": return getattr(self._target, item) else: diff --git a/aiopenapi3/v30/glue.py b/aiopenapi3/v30/glue.py index c0ca86aa..3a417b1e 100644 --- a/aiopenapi3/v30/glue.py +++ b/aiopenapi3/v30/glue.py @@ -1,5 +1,5 @@ import io -from typing import List, Union, cast +from typing import List, Union, cast, TYPE_CHECKING, Dict, Optional, cast, Any, Tuple, Sequence import json import urllib.parse @@ -8,12 +8,10 @@ try: import httpx_auth from httpx_auth.authentication import SupportMultiAuth -except: - httpx_auth = None - -if httpx_auth is not None: import inspect - +except ImportError: + httpx_auth = None +else: HTTPX_AUTH_METHODS = { name.lower(): getattr(httpx_auth, name) for name in httpx_auth.__all__ @@ -31,30 +29,67 @@ from ..errors import HTTPStatusError, ContentTypeError, ResponseDecodingError, ResponseSchemaError, HeadersMissingError from .formdata import parameters_from_multipart, parameters_from_urlencoded, encode_multipart_parameters +from .root import Root as v30Root +from ..v31.root import Root as v31Root + +if TYPE_CHECKING: + from .._types import ( + SchemaType, + RequestParameters, + RequestData, + ParameterType, + RequestFilesParameter, + RequestFileParameter, + ) + + from .paths import Response as v30Response, MediaType as v30MediaType + from ..v31.paths import Response as v31Response, MediaType as v31MediaType + + v3xResponseType = Union[v30Response, v31Response] + v3xMediaTypeType = Union[v30MediaType, v31MediaType] + class Request(RequestBase): + root: Union[v30Root, v31Root] + @property def security(self): return self.api._security @property - def data(self) -> SchemaBase: - return self.operation.requestBody.content["application/json"].schema_ + def data(self) -> Optional["SchemaType"]: + if ( + self.operation.requestBody is not None + and self.operation.requestBody.content is not None + and (ex := self.operation.requestBody.content.get("application/json", None)) is not None + ): + return ex.schema_ + return None @property - def parameters(self) -> List[ParameterBase]: + def parameters(self) -> List["ParameterType"]: return self.operation.parameters + self.root.paths[self.path].parameters - def args(self, content_type: str = "application/json"): + def args(self, content_type: str = "application/json") -> Dict[str, Any]: op = self.operation parameters = op.parameters + self.root.paths[self.path].parameters - schema = op.requestBody.content[content_type].schema_ + if ( + op.requestBody + and op.requestBody.content + and (media := op.requestBody.content.get(content_type, None)) is not None + ): + schema = media.schema_ + else: + schema = None return {"parameters": parameters, "data": schema} - def return_value(self, http_status: int = 200, content_type: str = "application/json") -> SchemaBase: - return self.operation.responses[str(http_status)].content[content_type].schema_ + def return_value(self, http_status: int = 200, content_type: str = "application/json") -> Optional["SchemaType"]: + if (a := self.operation.responses.get(str(http_status), None)) is not None: + if (b := a.content.get(content_type, None)) is not None: + return b.schema_ + return None - def _prepare_security(self): + def _prepare_security(self) -> None: security = self.operation.security or self.api._root.security if not security: @@ -84,16 +119,30 @@ def _prepare_security(self): f"No security requirement satisfied (accepts {options} given {{{' and '.join(sorted(self.security.keys()))}}}" ) - def _prepare_secschemes(self, scheme: str, value: Union[str, List[str]]): + def _prepare_secschemes(self, scheme: str, value: Union[str, Sequence[str]]) -> None: + assert ( + self.root.components + and self.root.components.securitySchemes + and scheme in self.root.components.securitySchemes + and self.root.components.securitySchemes[scheme].root + ) if httpx_auth is not None: - return self._prepare_secschemes_extra(scheme, value) + self._prepare_secschemes_extra(scheme, value) else: - return self._prepare_secschemes_default(scheme, value) - - def _prepare_secschemes_default(self, scheme: str, value: Union[str, List[str]]): + self._prepare_secschemes_default(scheme, value) + + def _prepare_secschemes_default(self, scheme: str, value: Union[str, Sequence[str]]) -> None: + assert ( + self.root.components + and self.root.components.securitySchemes + and scheme in self.root.components.securitySchemes + and self.root.components.securitySchemes[scheme].root + ) ss = self.root.components.securitySchemes[scheme].root + from .. import v30, v31 if ss.type == "http": + assert isinstance(ss, (v30.security._SecuritySchemes.http, v31.security._SecuritySchemes.http)) if ss.scheme_ == "basic": self.req.auth = httpx.BasicAuth(*value) elif ss.scheme_ == "digest": @@ -109,6 +158,7 @@ def _prepare_secschemes_default(self, scheme: str, value: Union[str, List[str]]) value = cast(str, value) if ss.type == "apiKey": + assert isinstance(ss, (v30.security._SecuritySchemes.apiKey, v31.security._SecuritySchemes.apiKey)) if ss.in_ == "query": # apiKey in query parameter self.req.params[ss.name] = value @@ -120,11 +170,20 @@ def _prepare_secschemes_default(self, scheme: str, value: Union[str, List[str]]) if ss.in_ == "cookie": self.req.cookies = {ss.name: value} - def _prepare_secschemes_extra(self, scheme: str, value: Union[str, List[str]]): + def _prepare_secschemes_extra(self, scheme: str, value: Union[str, Sequence[str]]) -> None: + assert ( + self.root.components + and self.root.components.securitySchemes + and scheme in self.root.components.securitySchemes + and self.root.components.securitySchemes[scheme].root + ) ss = self.root.components.securitySchemes[scheme].root auths = [] + from .. import v30, v31 + if ss.type == "oauth2": + assert isinstance(ss, (v30.security._SecuritySchemes.oauth2, v31.security._SecuritySchemes.oauth2)) # NOTE: refresh_url is not currently supported by httpx_auth # REF: https://github.com/Colin-b/httpx_auth/issues/17 if flow := ss.flows.implicit: @@ -166,6 +225,7 @@ def _prepare_secschemes_extra(self, scheme: str, value: Union[str, List[str]]): ) if ss.type == "http": + assert isinstance(ss, (v30.security._SecuritySchemes.http, v31.security._SecuritySchemes.http)) if auth := HTTPX_AUTH_METHODS.get(ss.scheme_, None): if isinstance(value, tuple): auths.append(auth(*value)) @@ -182,6 +242,7 @@ def _prepare_secschemes_extra(self, scheme: str, value: Union[str, List[str]]): value = cast(str, value) if ss.type == "apiKey": + assert isinstance(ss, (v30.security._SecuritySchemes.apiKey, v31.security._SecuritySchemes.apiKey)) if auth := HTTPX_AUTH_METHODS.get((ss.in_ + ss.type).lower(), None): auths.append(auth(value, ss.name)) @@ -194,7 +255,7 @@ def _prepare_secschemes_extra(self, scheme: str, value: Union[str, List[str]]): else: self.req.auth = auth - def _prepare_parameters(self, provided): + def _prepare_parameters(self, provided: Optional["RequestParameters"]) -> Dict[str, str]: """ assigns the parameters provided to the header/path/cookie … @@ -206,11 +267,17 @@ def _prepare_parameters(self, provided): provided = provided or dict() possible = {_.name: _ for _ in self.operation.parameters + self.root.paths[self.path].parameters} + from .. import v30, v31 + + assert isinstance(self.operation, (v30.Operation, v31.Operation)) + if self.operation.requestBody: - rbq = dict() # requestBody Parameters + rbq: Dict[str, str] = dict() # requestBody Parameters ct = "multipart/form-data" if ct in self.operation.requestBody.content: + assert self.operation.requestBody.content[ct].encoding is not None for k, v in self.operation.requestBody.content[ct].encoding.items(): + assert v.headers is not None and isinstance(v.headers, dict) rbq.update(v.headers) possible.update(rbq) @@ -236,7 +303,7 @@ def _prepare_parameters(self, provided): values = spec._encode(name, value) assert isinstance(values, dict) - if isinstance(spec, (aiopenapi3.v30.parameter.Header, aiopenapi3.v31.parameter.Header)): + if isinstance(spec, (v30.parameter.Header, v31.parameter.Header)): rbqh.update(values) elif spec.in_ == "header": self.req.headers.update(values) @@ -253,25 +320,29 @@ def _prepare_parameters(self, provided): self.req.url = self.req.url.format(**path_parameters) return rbqh - def _prepare_body(self, data, rbq): + def _prepare_body(self, data_: Optional["RequestData"], rbq: Dict[str, str]) -> None: + from .. import v30, v31 + + assert isinstance(self.operation, (v30.Operation, v31.Operation)) + if not self.operation.requestBody: return - if data is None and self.operation.requestBody.required: + if data_ is None and self.operation.requestBody.required: raise ValueError("Request Body is required but none was provided.") if "application/json" in self.operation.requestBody.content: - if isinstance(data, (dict, list)): - pass - elif isinstance(data, pydantic.BaseModel): - data = data.model_dump(mode="json") + if isinstance(data_, (dict, list)): + data = data_ + elif isinstance(data_, pydantic.BaseModel): + data = data_.model_dump(mode="json") else: - raise TypeError(data) + raise TypeError(data_) data = self.api.plugins.message.marshalled( operationId=self.operation.operationId, marshalled=data ).marshalled - data = json.dumps(data) - data = data.encode() + data: str = json.dumps(data) + data: bytes = data.encode() # type: ignore[union-attr] self.req.headers["Content-Type"] = "application/json" ctx = self.api.plugins.message.sending( operationId=self.operation.operationId, sending=data, headers=self.req.headers @@ -284,80 +355,75 @@ def _prepare_body(self, data, rbq): https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#media-type-object """ media: aiopenapi3.v30.media.MediaType = self.operation.requestBody.content[ct] - if media.schema_ and isinstance(data, media.schema_.get_type()): + if media.schema_ and isinstance(data_, media.schema_.get_type()): """data is a model""" - params = parameters_from_multipart(data, media, rbq) + params = parameters_from_multipart(data_, media, rbq) msg = encode_multipart_parameters(params) self.req.content = msg.as_string() self.req.headers["Content-Type"] = f'{msg.get_content_type()}; boundary="{msg.get_boundary()}"' - elif isinstance(data, list): - _files = list() - _data = dict() - for name, value in data: + elif isinstance(data_, list): + rfiles = list() + rdata: Dict[str, str] = dict() + name: str + value: Tuple[str, Any] + for name, value in cast(Sequence[Tuple[str, Any]], data_): if isinstance(value, tuple): alias = fh = content_type = None - headers = {} + headers: Dict[str, str] = {} if len(value) == 4: - (alias, fh, content_type, headers) = value + (alias, fh, content_type, headers) = cast(Tuple[str, Any, str, Dict[str, str]], value) elif len(value) == 3: - (alias, fh, content_type) = value + (alias, fh, content_type) = cast(Tuple[str, Any, str], value) elif len(value) == 2: - (alias, fh) = value + (alias, fh) = cast(Tuple[str, Any], value) elif len(value) == 1: - (alias, fh) = value + fh = cast(Any, value) + assert media.encoding is not None if (e := media.encoding.get(name)) is not None: + assert e.headers headers.update({name: rbq[name] for name in e.headers.keys() if name in rbq}) _value = (alias, fh, content_type, headers) - _files.append((name, _value)) + rfiles.append((name, _value)) + elif isinstance(value, str): + rdata[name] = value else: - _data[name] = value - self.req.files = _files - self.req.data = _data + raise TypeError(type(value)) # noqa + self.req.files = rfiles + self.req.data = rdata else: - raise TypeError((type(data), media.schema_.get_type())) + assert media.schema_ + raise TypeError((type(data_), media.schema_.get_type())) elif (ct := "application/x-www-form-urlencoded") in self.operation.requestBody.content: self.req.headers["Content-Type"] = ct media: aiopenapi3.v30.media.MediaType = self.operation.requestBody.content[ct] - if not media.schema_ or not isinstance(data, media.schema_.get_type()): + assert media + if not media.schema_ or not isinstance(data_, media.schema_.get_type()): """expect the data to be a model""" - raise TypeError((type(data), media.schema_.get_type())) + raise TypeError((type(data_), media.schema_)) - params = parameters_from_urlencoded(data, media) - msg = urllib.parse.urlencode(params, doseq=True) - self.req.content = msg + params = parameters_from_urlencoded(data_, media) + content = urllib.parse.urlencode(params, doseq=True) + self.req.content = content elif (ct := "application/octet-stream") in self.operation.requestBody.content: self.req.headers["Content-Type"] = ct - value = data - if isinstance(data, tuple) and len(data) >= 2: + value: "RequestFileParameter" + if isinstance(data_, tuple) and len(data_) >= 2: # (name, file-like-object, …) - value = data[1] - if isinstance(value, (io.IOBase, str, bytes)): - self.req.content = value + self.req.content = data_[1] + elif isinstance(data_, (io.IOBase, str, bytes)): + self.req.content = data_ else: - raise TypeError(data) + raise TypeError(data_) else: raise NotImplementedError(self.operation.requestBody.content) - def _prepare(self, data, parameters): + def _prepare(self, data: Optional["RequestData"], parameters: Optional["RequestParameters"]) -> None: self._prepare_security() rbq = self._prepare_parameters(parameters) self._prepare_body(data, rbq) - def _build_req(self, session): - req = session.build_request( - self.method, - str(self.api.url / self.req.url[1:]), - headers=self.req.headers, - cookies=self.req.cookies, - params=self.req.params, - content=self.req.content, - data=self.req.data, - files=self.req.files, - ) - return req - - def _process__status_code(self, result, status_code): + def _process__status_code(self, result: httpx.Response, status_code: str) -> "v3xResponseType": # find the response model in spec we received expected_response = None if status_code in self.operation.responses: @@ -375,7 +441,9 @@ def _process__status_code(self, result, status_code): ) return expected_response - def _process__headers(self, result, headers, expected_response): + def _process__headers( + self, result: httpx.Response, headers: Dict[str, str], expected_response: "v3xResponseType" + ) -> Dict[str, str]: rheaders = dict() if expected_response.headers: required = dict( @@ -385,7 +453,6 @@ def _process__headers(self, result, headers, expected_response): ) ) available = frozenset(headers.keys()) - available = frozenset(headers.keys()) if missing := (required.keys() - available): missing = {k: required[k] for k in missing} raise HeadersMissingError(self.operation, missing, result) @@ -395,10 +462,12 @@ def _process__headers(self, result, headers, expected_response): rheaders[name] = header.schema_.model(header._decode(data)) return rheaders - def _process__content_type(self, result, expected_response, content_type): + def _process__content_type( + self, result: httpx.Response, expected_response: "v3xResponseType", content_type: str | None + ) -> Tuple[str, "v3xMediaTypeType"]: if content_type: content_type, _, encoding = content_type.partition(";") - expected_media = expected_response.content.get(content_type, None) + expected_media: "v3xMediaTyeType" = expected_response.content.get(content_type, None) if expected_media is None and "/" in content_type: # accept media type ranges in the spec. the most specific matching # type should always be chosen, but if we do not have a match here @@ -421,7 +490,7 @@ def _process__content_type(self, result, expected_response, content_type): ) return content_type, expected_media - def _process_stream(self, result): + def _process_stream(self, result: httpx.Response) -> Tuple[Dict[str, str], Optional["SchemaType"]]: status_code = str(result.status_code) content_type = result.headers.get("Content-Type", None) @@ -432,7 +501,7 @@ def _process_stream(self, result): return headers, expected_media.schema_ - def _process_request(self, result): + def _process_request(self, result: httpx.Response) -> Tuple[Dict[str, str], pydantic.BaseModel | str | None]: rheaders = dict() # spec enforces these are strings status_code = str(result.status_code) diff --git a/aiopenapi3/v30/media.py b/aiopenapi3/v30/media.py index 39e51558..1ce15a9a 100644 --- a/aiopenapi3/v30/media.py +++ b/aiopenapi3/v30/media.py @@ -1,3 +1,4 @@ +import typing from typing import Union, Optional, Dict, Any from pydantic import Field @@ -8,6 +9,9 @@ from .general import Reference from .schemas import Schema +if typing.TYPE_CHECKING: + from .paths import Header + class Encoding(ObjectExtended): """ @@ -16,8 +20,6 @@ class Encoding(ObjectExtended): .. _Encoding: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#encoding-object """ - model_config = dict(undefined_types_warning=False) - contentType: Optional[str] = Field(default=None) headers: Dict[str, Union["Header", Reference]] = Field(default_factory=dict) style: Optional[str] = Field(default=None) @@ -33,8 +35,6 @@ class MediaType(ObjectExtended): .. _MediaType: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#media-type-object """ - model_config = dict(undefined_types_warning=False) - schema_: Optional[Union[Schema, Reference]] = Field(default=None, alias="schema") example: Optional[Any] = Field(default=None) # 'any' type examples: Dict[str, Union[Example, Reference]] = Field(default_factory=dict) diff --git a/aiopenapi3/v30/parameter.py b/aiopenapi3/v30/parameter.py index cb2229e2..a92a0dc8 100644 --- a/aiopenapi3/v30/parameter.py +++ b/aiopenapi3/v30/parameter.py @@ -1,6 +1,7 @@ import enum import datetime import decimal +import typing import uuid from typing import Union, Optional, Dict, Any from collections.abc import MutableMapping @@ -15,6 +16,9 @@ from .general import Reference from .schemas import Schema +if typing.TYPE_CHECKING: + from .paths import MediaType + class _ParameterCodec: def _codec(self): @@ -236,8 +240,6 @@ class ParameterBase(ObjectExtended, ParameterBase_): .. _Parameter Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#external-documentation-object """ - model_config = dict(undefined_types_warning=False) - description: Optional[str] = Field(default=None) required: Optional[bool] = Field(default=None) deprecated: Optional[bool] = Field(default=None) @@ -271,7 +273,13 @@ def validate_Parameter(cls, p: "ParameterBase"): def encode_parameter( - name: str, value: object, style: str, explode: bool, allowReserved: bool, in_: str, schema_: Schema + name: str, + value: object, + style: str | None, + explode: bool | None, + allowReserved: bool | None, + in_: str, + schema_: Schema, ) -> Union[str, bytes]: p = Parameter(name=name, style=style, explode=explode, allowReserved=allowReserved, **{"in": in_, "schema": None}) p.schema_ = schema_ diff --git a/aiopenapi3/v30/paths.py b/aiopenapi3/v30/paths.py index 1567bc54..50cc5b8b 100644 --- a/aiopenapi3/v30/paths.py +++ b/aiopenapi3/v30/paths.py @@ -2,7 +2,7 @@ from pydantic import Field, model_validator, RootModel -from ..base import ObjectExtended, PathsBase, OperationBase +from ..base import ObjectExtended, PathsBase, OperationBase, PathItemBase from .general import ExternalDocumentation from .general import Reference from .media import MediaType @@ -30,8 +30,6 @@ class Link(ObjectExtended): .. _Link Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#link-object """ - model_config = dict(undefined_types_warning=False) - operationRef: Optional[str] = Field(default=None) operationId: Optional[str] = Field(default=None) parameters: Optional[Dict[str, Union[str, Any, "RuntimeExpression"]]] = Field(default=None) @@ -41,7 +39,7 @@ class Link(ObjectExtended): @model_validator(mode="after") @classmethod - def validate_Link_operation(cls, l: '__types["Link"]'): + def validate_Link_operation(cls, l: '__types["Link"]'): # type: ignore[name-defined] assert not ( l.operationId != None and l.operationRef != None ), "operationId and operationRef are mutually exclusive, only one of them is allowed" @@ -59,8 +57,6 @@ class Response(ObjectExtended): .. _Response Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#responses-object """ - model_config = dict(undefined_types_warning=False) - description: str = Field(...) headers: Dict[str, Union[Header, Reference]] = Field(default_factory=dict) content: Dict[str, MediaType] = Field(default_factory=dict) @@ -74,8 +70,6 @@ class Operation(ObjectExtended, OperationBase): .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#operation-object """ - model_config = dict(undefined_types_warning=False) - tags: Optional[List[str]] = Field(default=None) summary: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None) @@ -90,7 +84,7 @@ class Operation(ObjectExtended, OperationBase): servers: Optional[List[Server]] = Field(default=None) -class PathItem(ObjectExtended): +class PathItem(ObjectExtended, PathItemBase): """ A Path Item, as defined `here`_. Describes the operations available on a single path. @@ -98,8 +92,6 @@ class PathItem(ObjectExtended): .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#paths-object """ - model_config = dict(undefined_types_warning=False) - ref: Optional[str] = Field(default=None, alias="$ref") summary: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None) @@ -140,8 +132,6 @@ class Callback(RootModel): This object MAY be extended with Specification Extensions. """ - model_config = dict(undefined_types_warning=False) - root: Dict[str, PathItem] diff --git a/aiopenapi3/v30/root.py b/aiopenapi3/v30/root.py index e0587da0..a5629250 100644 --- a/aiopenapi3/v30/root.py +++ b/aiopenapi3/v30/root.py @@ -23,8 +23,6 @@ class Root(ObjectExtended, RootBase): .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#openapi-object """ - model_config = dict(undefined_types_warning=False) - openapi: str = Field(...) info: Info = Field(...) servers: List[Server] = Field(default_factory=list) diff --git a/aiopenapi3/v30/schemas.py b/aiopenapi3/v30/schemas.py index 253cea2d..b8efd9f5 100644 --- a/aiopenapi3/v30/schemas.py +++ b/aiopenapi3/v30/schemas.py @@ -1,6 +1,6 @@ from typing import Union, List, Any, Optional, Dict -from pydantic import Field, model_validator, PrivateAttr +from pydantic import Field, model_validator, PrivateAttr, ConfigDict from ..base import ObjectExtended, SchemaBase, DiscriminatorBase from .general import Reference @@ -61,9 +61,10 @@ class Schema(ObjectExtended, SchemaBase): example: Optional[Any] = Field(default=None) deprecated: Optional[bool] = Field(default=None) - model_config = dict(extra="forbid") + model_config = ConfigDict(extra="forbid") @model_validator(mode="after") + @classmethod def validate_Schema_number_type(cls, s: "Schema"): if s.type == "integer": for i in ["minimum", "maximum"]: diff --git a/aiopenapi3/v30/security.py b/aiopenapi3/v30/security.py index fab8ad4f..92cece62 100644 --- a/aiopenapi3/v30/security.py +++ b/aiopenapi3/v30/security.py @@ -52,7 +52,7 @@ class apiKey(_SecurityScheme): class http(_SecurityScheme): type: Literal["http"] - scheme_: constr(to_lower=True) = Field(default=None, alias="scheme") + scheme_: constr(to_lower=True) = Field(default=None, alias="scheme") # type: ignore[valid-type] bearerFormat: Optional[str] = Field(default=None) class oauth2(_SecurityScheme): diff --git a/aiopenapi3/v30/servers.py b/aiopenapi3/v30/servers.py index 7b42232e..054eaf01 100644 --- a/aiopenapi3/v30/servers.py +++ b/aiopenapi3/v30/servers.py @@ -13,7 +13,7 @@ class ServerVariable(ObjectExtended): """ enum: Optional[List[str]] = Field(default=None) - default: str = Field(...) + default: str | None = Field(...) description: Optional[str] = Field(default=None) diff --git a/aiopenapi3/v31/general.py b/aiopenapi3/v31/general.py index 82163ae5..b612888b 100644 --- a/aiopenapi3/v31/general.py +++ b/aiopenapi3/v31/general.py @@ -1,10 +1,15 @@ -from typing import Optional +import typing +from typing import Optional, Union, Any -from pydantic import Field, AnyUrl, PrivateAttr +from pydantic import Field, AnyUrl, PrivateAttr, ConfigDict from ..base import ObjectExtended, ObjectBase, ReferenceBase +if typing.TYPE_CHECKING: + from .schemas import Schema + from .paths import Parameter, PathItem + class ExternalDocumentation(ObjectExtended): """ @@ -29,14 +34,14 @@ class Reference(ObjectBase, ReferenceBase): summary: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None) - _target: object = PrivateAttr() + _target: Union["Schema", "Parameter", "Reference", "PathItem"] = PrivateAttr() - model_config = dict( + model_config = ConfigDict( # """This object cannot be extended with additional properties and any properties added SHALL be ignored.""" extra="ignore" ) - def __getattr__(self, item): + def __getattr__(self, item: str) -> Any: if item != "_target": return getattr(self._target, item) else: diff --git a/aiopenapi3/v31/info.py b/aiopenapi3/v31/info.py index 4e4b0c70..55bd93a5 100644 --- a/aiopenapi3/v31/info.py +++ b/aiopenapi3/v31/info.py @@ -29,6 +29,7 @@ class License(ObjectExtended): url: Optional[str] = Field(default=None) @model_validator(mode="after") + @classmethod def validate_License(cls, l: "License"): """ 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. diff --git a/aiopenapi3/v31/media.py b/aiopenapi3/v31/media.py index f06fd9e2..c7b68a58 100644 --- a/aiopenapi3/v31/media.py +++ b/aiopenapi3/v31/media.py @@ -17,8 +17,6 @@ class Encoding(ObjectExtended): .. _Encoding: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#encodingObject """ - model_config = dict(undefined_types_warning=False) - contentType: Optional[str] = Field(default=None) headers: Dict[str, Union[Header, Reference]] = Field(default_factory=dict) style: Optional[str] = Field(default=None) @@ -34,7 +32,6 @@ class MediaType(ObjectExtended): .. _MediaType: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#media-type-object """ - model_config = dict(undefined_types_warning=False) schema_: Optional[Schema] = Field(default=None, alias="schema") example: Optional[Any] = Field(default=None) # 'any' type examples: Dict[str, Union[Example, Reference]] = Field(default_factory=dict) diff --git a/aiopenapi3/v31/parameter.py b/aiopenapi3/v31/parameter.py index 7c681ad9..0538cdf2 100644 --- a/aiopenapi3/v31/parameter.py +++ b/aiopenapi3/v31/parameter.py @@ -1,9 +1,10 @@ import enum +import typing from typing import Union, Optional, Dict, Any from pydantic import Field -from ..base import ObjectExtended +from ..base import ObjectExtended, ParameterBase as _ParameterBase from .example import Example from .general import Reference @@ -11,16 +12,17 @@ from ..v30.parameter import _ParameterCodec +if typing.TYPE_CHECKING: + from .paths import MediaType -class ParameterBase(ObjectExtended): + +class ParameterBase(ObjectExtended, _ParameterBase): """ A `Parameter Object`_ defines a single operation parameter. .. _Parameter Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#parameterObject """ - model_config = dict(undefined_types_warning=False) - description: Optional[str] = Field(default=None) required: Optional[bool] = Field(default=None) deprecated: Optional[bool] = Field(default=None) @@ -54,7 +56,5 @@ class Header(ParameterBase, _ParameterCodec): .. _HeaderObject: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#headerObject """ - model_config = dict(undefined_types_warning=False) - def _codec(self): return "simple", False diff --git a/aiopenapi3/v31/paths.py b/aiopenapi3/v31/paths.py index 63ee2698..a9f5c327 100644 --- a/aiopenapi3/v31/paths.py +++ b/aiopenapi3/v31/paths.py @@ -1,8 +1,8 @@ from typing import Union, List, Optional, Dict, Any -from pydantic import Field, model_validator, RootModel +from pydantic import Field, model_validator, RootModel, ConfigDict -from ..base import ObjectBase, ObjectExtended, PathsBase, OperationBase +from ..base import ObjectBase, ObjectExtended, PathsBase, OperationBase, PathItemBase from .general import ExternalDocumentation from .general import Reference from .media import MediaType @@ -30,7 +30,6 @@ class Link(ObjectExtended): .. _Link Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#link-object """ - model_config = dict(undefined_types_warning=False) operationRef: Optional[str] = Field(default=None) operationId: Optional[str] = Field(default=None) parameters: Optional[Dict[str, Union[str, Any, "RuntimeExpression"]]] = Field(default=None) @@ -39,7 +38,7 @@ class Link(ObjectExtended): server: Optional[Server] = Field(default=None) @model_validator(mode="after") - def validate_Link_operation(cls, l: '__types["Link"]'): + def validate_Link_operation(cls, l: '__types["Link"]'): # type: ignore[name-defined] assert not ( l.operationId != None and l.operationRef != None ), "operationId and operationRef are mutually exclusive, only one of them is allowed" @@ -57,8 +56,6 @@ class Response(ObjectExtended): .. _Response Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#responseObject """ - model_config = dict(undefined_types_warning=False) - description: str = Field(...) headers: Dict[str, Union[Header, Reference]] = Field(default_factory=dict) content: Dict[str, MediaType] = Field(default_factory=dict) @@ -72,8 +69,6 @@ class Operation(ObjectExtended, OperationBase): .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#operationObject """ - model_config = dict(undefined_types_warning=False) - tags: Optional[List[str]] = Field(default=None) summary: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None) @@ -88,7 +83,7 @@ class Operation(ObjectExtended, OperationBase): servers: Optional[List[Server]] = Field(default=None) -class PathItem(ObjectExtended): +class PathItem(ObjectExtended, PathItemBase): """ A Path Item, as defined `here`_. Describes the operations available on a single path. @@ -96,8 +91,6 @@ class PathItem(ObjectExtended): .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#pathItemObject """ - model_config = dict(undefined_types_warning=False) - ref: Optional[str] = Field(default=None, alias="$ref") summary: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None) @@ -138,8 +131,6 @@ class Callback(RootModel): This object MAY be extended with Specification Extensions. """ - model_config = dict(undefined_types_warning=False) - root: Dict[str, PathItem] diff --git a/aiopenapi3/v31/root.py b/aiopenapi3/v31/root.py index c9c5f437..b94522bf 100644 --- a/aiopenapi3/v31/root.py +++ b/aiopenapi3/v31/root.py @@ -22,8 +22,6 @@ class Root(ObjectExtended, RootBase): .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#openapi-object """ - model_config = dict(undefined_types_warning=False) - openapi: str = Field(...) info: Info = Field(...) jsonSchemaDialect: Optional[str] = Field(default=None) # FIXME should be URI diff --git a/aiopenapi3/v31/schemas.py b/aiopenapi3/v31/schemas.py index 4a614360..67e18161 100644 --- a/aiopenapi3/v31/schemas.py +++ b/aiopenapi3/v31/schemas.py @@ -1,6 +1,6 @@ from typing import Union, List, Any, Optional, Dict -from pydantic import Field, model_validator, PrivateAttr +from pydantic import Field, model_validator, PrivateAttr, ConfigDict from ..base import ObjectExtended, SchemaBase, DiscriminatorBase from .general import Reference @@ -24,7 +24,7 @@ class Schema(ObjectExtended, SchemaBase): .. _Schema Object: https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-00#section-6 """ - model_config = dict(extra="allow") + model_config = ConfigDict(extra="allow") """ JSON Schema: A Media Type for Describing JSON Documents @@ -77,7 +77,6 @@ class Schema(ObjectExtended, SchemaBase): properties: Dict[str, "Schema"] = Field(default_factory=dict) patternProperties: Dict[str, "Schema"] = Field(default_factory=dict) additionalProperties: Optional[Union[bool, "Schema"]] = Field(default=None) - unevaluatedProperties: Optional["Schema"] = Field(default=None) propertyNames: Optional["Schema"] = Field(default=None) """ diff --git a/aiopenapi3/v31/security.py b/aiopenapi3/v31/security.py index 5de29225..987f0853 100644 --- a/aiopenapi3/v31/security.py +++ b/aiopenapi3/v31/security.py @@ -53,19 +53,19 @@ class apiKey(_SecurityScheme): class http(_SecurityScheme): type: Literal["http"] - scheme_: constr(to_lower=True) = Field(default=None, alias="scheme") + scheme_: constr(to_lower=True) = Field(default=None, alias="scheme") # type: ignore[valid-type] bearerFormat: Optional[str] = Field(default=None) class mutualTLS(_SecurityScheme): type: Literal["mutualTLS"] - def validate_authentication_value(self, value): + def validate_authentication_value(self, value) -> None: if not isinstance(value, (list, tuple)): raise TypeError(type(value)) if len(value) != 2: raise ValueError(f"Invalid number of tuple parameters {len(value)} - 2 required") - value: Tuple[Path, Path] = tuple(map(lambda x: x if isinstance(x, Path) else Path(x), value)) - if missing := sorted(filter(lambda x: not (x.exists() and x.is_file()), value)): + files: Tuple[Path, Path] = (Path(value[0]), Path(value[1])) + if missing := sorted(filter(lambda x: not (x.exists() and x.is_file()), files)): raise FileNotFoundError(missing) class oauth2(_SecurityScheme): diff --git a/aiopenapi3/v31/servers.py b/aiopenapi3/v31/servers.py index 73602a76..05a250b7 100644 --- a/aiopenapi3/v31/servers.py +++ b/aiopenapi3/v31/servers.py @@ -13,7 +13,7 @@ class ServerVariable(ObjectExtended): """ enum: Optional[List[str]] = Field(default=None) - default: str = Field(...) + default: str | None = Field(...) description: Optional[str] = Field(default=None) @model_validator(mode="after") diff --git a/pyproject.toml b/pyproject.toml index 48e558f2..29f77335 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,3 +126,7 @@ plugins = [ filename = "requirements.txt" groups = ["default"] without-hashes = "true" + + +[tool.mypy] +allow_redefinition = true From 7c5ac10040e781be0e3acf61bad23ae2225c0acf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Fri, 1 Sep 2023 09:53:22 +0200 Subject: [PATCH 6/8] mypy - TypeGuard is python 3.10 --- aiopenapi3/base.py | 7 ++++++- aiopenapi3/model.py | 20 +++++++++----------- aiopenapi3/openapi.py | 19 +++++++++++++------ aiopenapi3/plugin.py | 9 ++++++++- aiopenapi3/v20/glue.py | 9 ++++++++- pyproject.toml | 2 +- 6 files changed, 45 insertions(+), 21 deletions(-) diff --git a/aiopenapi3/base.py b/aiopenapi3/base.py index d01a6900..94f7f38b 100644 --- a/aiopenapi3/base.py +++ b/aiopenapi3/base.py @@ -1,6 +1,6 @@ import typing import warnings -from typing import Optional, Any, List, Dict, ForwardRef, Union, Tuple, cast, Type, TypeGuard, FrozenSet, Sequence +from typing import Optional, Any, List, Dict, ForwardRef, Union, Tuple, cast, Type, FrozenSet, Sequence import re import builtins @@ -13,6 +13,11 @@ else: from pathlib3x import Path +if sys.version_info >= (3, 10): + from typing import TypeGuard +else: + from typing_extensions import TypeGuard + from pydantic import BaseModel, Field, AnyUrl, model_validator, PrivateAttr, ConfigDict from .json import JSONPointer, JSONReference diff --git a/aiopenapi3/model.py b/aiopenapi3/model.py index 94199489..3e745e66 100644 --- a/aiopenapi3/model.py +++ b/aiopenapi3/model.py @@ -5,20 +5,13 @@ import logging import re import sys -from typing import Any, Set, Type, cast, TypeGuard, TypeVar +from typing import Any, Set, Type, cast, TypeVar import typing -import pydantic - -if sys.version_info >= (3, 9): - pass +if sys.version_info >= (3, 10): + from typing import TypeGuard else: - from pathlib3x import Path - - -from .base import ReferenceBase, SchemaBase -from . import me -from .pydanticv2 import field_class_to_schema + from typing_extensions import TypeGuard if sys.version_info >= (3, 9): from typing import List, Optional, Union, Tuple, Dict, Annotated, Literal @@ -27,6 +20,11 @@ from typing_extensions import Annotated, Literal from pydantic import BaseModel, Field, RootModel, ConfigDict +import pydantic + +from .base import ReferenceBase, SchemaBase +from . import me +from .pydanticv2 import field_class_to_schema if typing.TYPE_CHECKING: from .base import DiscriminatorBase diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index 9409ec08..06a18d66 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -1,18 +1,25 @@ import sys import typing -if sys.version_info >= (3, 9): - import pathlib -else: - import pathlib3x as pathlib - -from typing import List, Dict, Set, Callable, Tuple, Any, Union, cast, Optional, TypeGuard, Type, ForwardRef +from typing import List, Dict, Set, Callable, Tuple, Any, Union, cast, Optional, Type, ForwardRef import collections import inspect import logging import copy import pickle +if sys.version_info >= (3, 9): + import pathlib +else: + import pathlib3x as pathlib + + +if sys.version_info >= (3, 10): + from typing import TypeGuard +else: + from typing_extensions import TypeGuard + + import httpx import yarl from pydantic import BaseModel diff --git a/aiopenapi3/plugin.py b/aiopenapi3/plugin.py index e55b136a..9694c6c1 100644 --- a/aiopenapi3/plugin.py +++ b/aiopenapi3/plugin.py @@ -2,6 +2,13 @@ import typing from typing import TYPE_CHECKING, List, Any, Dict, Optional, Type import abc +import sys + +if sys.version_info >= (3, 10): + from typing import TypeGuard +else: + from typing_extensions import TypeGuard + from pydantic import BaseModel @@ -194,7 +201,7 @@ def _get_domain(self, name: str, plugins: List[Plugin]) -> "Domain": if (domain := self._domains.get(name)) is None: raise ValueError(name) # noqa - def domain_type_f(p: Plugin) -> typing.TypeGuard[Plugin]: + def domain_type_f(p: Plugin) -> TypeGuard[Plugin]: return isinstance(p, domain) p: List[Plugin] = list(filter(domain_type_f, plugins)) diff --git a/aiopenapi3/v20/glue.py b/aiopenapi3/v20/glue.py index 2f1105da..e2d1471e 100644 --- a/aiopenapi3/v20/glue.py +++ b/aiopenapi3/v20/glue.py @@ -1,6 +1,13 @@ import typing -from typing import List, Union, cast, Tuple, Dict, Optional, TypeGuard, Sequence +from typing import List, Union, cast, Tuple, Dict, Optional, Sequence import json +import sys + +if sys.version_info >= (3, 10): + from typing import TypeGuard +else: + from typing_extensions import TypeGuard + import httpx import pydantic diff --git a/pyproject.toml b/pyproject.toml index 29f77335..4ba6ca03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "yarl==1.8.1", "httpx", "more-itertools", - 'typing_extensions; python_version<"3.9"', + 'typing_extensions; python_version<"3.10"', 'pathlib3x; python_version<"3.9"', "jmespath", ] From 4c907c6a536725a884db6675d446a7c378134683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Fri, 1 Sep 2023 10:07:53 +0200 Subject: [PATCH 7/8] mypy - use Optional & Union for python <3.10 compatibility --- aiopenapi3/_types.py | 13 ++----------- aiopenapi3/base.py | 10 +++++----- aiopenapi3/cli.py | 8 +++++--- aiopenapi3/loader.py | 16 ++++++++-------- aiopenapi3/log.py | 4 ++-- aiopenapi3/model.py | 12 +++++------- aiopenapi3/openapi.py | 26 +++++++++++++------------- aiopenapi3/plugin.py | 6 +++--- aiopenapi3/request.py | 3 ++- aiopenapi3/v20/glue.py | 4 +++- aiopenapi3/v30/glue.py | 6 ++++-- aiopenapi3/v30/parameter.py | 6 +++--- aiopenapi3/v30/servers.py | 2 +- aiopenapi3/v31/servers.py | 2 +- 14 files changed, 57 insertions(+), 61 deletions(-) diff --git a/aiopenapi3/_types.py b/aiopenapi3/_types.py index e462a65f..ad5b217d 100644 --- a/aiopenapi3/_types.py +++ b/aiopenapi3/_types.py @@ -1,15 +1,6 @@ from . import v20, v30, v31 -from typing import ( - TYPE_CHECKING, - Dict, - List, - Sequence, - Tuple, - Union, - TypeAlias, - Type, -) +from typing import TYPE_CHECKING, Dict, List, Sequence, Tuple, Union, TypeAlias, Type, Optional import yaml @@ -23,7 +14,7 @@ RequestFileParameter = Tuple[str, FileTypes] RequestFilesParameter = Sequence[RequestFileParameter] -JSON: TypeAlias = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None +JSON: TypeAlias = Optional[Union[dict[str, "JSON"], list["JSON"], str, int, float, bool]] """ Define a JSON type https://github.com/python/typing/issues/182#issuecomment-1320974824 diff --git a/aiopenapi3/base.py b/aiopenapi3/base.py index 94f7f38b..a586b0eb 100644 --- a/aiopenapi3/base.py +++ b/aiopenapi3/base.py @@ -394,8 +394,8 @@ def _get_identity(self, prefix="XLS", name=None): def set_type( self, - names: List[str] | None = None, - discriminators: Sequence[DiscriminatorBase] | None = None, + names: Optional[List[str]] = None, + discriminators: Optional[Sequence[DiscriminatorBase]] = None, extra: Optional["SchemaBase"] = None, ) -> Type[BaseModel]: from .model import Model @@ -417,8 +417,8 @@ def set_type( def get_type( self, - names: List[str] | None = None, - discriminators: Sequence[DiscriminatorBase] | None = None, + names: Optional[List[str]] = None, + discriminators: Optional[Sequence[DiscriminatorBase]] = None, extra: Optional["SchemaBase"] = None, fwdref: bool = False, ) -> Union[Type[BaseModel], ForwardRef]: @@ -462,7 +462,7 @@ def model(self, data: "JSON") -> Union[BaseModel, List[BaseModel]]: class OperationBase: - # parameters: Optional[List[ParameterBase | ReferenceBase]] + # parameters: Optional[List[Union[ParameterBase, ReferenceBase]]] parameters: List[Any] def _validate_path_parameters(self, pi_: "PathItemBase", path_: str, loc: Tuple[Any, str]): diff --git a/aiopenapi3/cli.py b/aiopenapi3/cli.py index 6b95651f..f3773e61 100644 --- a/aiopenapi3/cli.py +++ b/aiopenapi3/cli.py @@ -12,7 +12,6 @@ import tracemalloc import linecache import logging -from typing import Callable import jmespath import yaml @@ -34,8 +33,11 @@ from .loader import ChainLoader, RedirectLoader, WebLoader import aiopenapi3.loader +from .log import init -log: Callable[[...], None] | None = None +init() + +# log: Callable[..., None] | None = None def loader_prepare(args, session_factory): @@ -62,7 +64,7 @@ def plugins_load(baseurl, plugins: List[str]) -> List[aiopenapi3.plugin.Plugin]: raise ValueError("importlib") if (module := importlib.util.module_from_spec(spec)) is None: raise ValueError("importlib") - assert spec and module + assert spec and spec.loader and module spec.loader.exec_module(module) for c in clsp: plugin = getattr(module, c) diff --git a/aiopenapi3/loader.py b/aiopenapi3/loader.py index 8789a3fe..2541c124 100644 --- a/aiopenapi3/loader.py +++ b/aiopenapi3/loader.py @@ -2,7 +2,7 @@ import json import logging import typing - +from typing import Optional import yaml import httpx import yarl @@ -112,7 +112,7 @@ def __init__(self, yload: "YAMLLoaderType" = YAML12Loader): self.yload = yload @abc.abstractmethod - def load(self, plugins: Plugins, url: yarl.URL, codec: str | None = None): + def load(self, plugins: Plugins, url: yarl.URL, codec: Optional[str] = None): """ load and decode description document @@ -124,7 +124,7 @@ def load(self, plugins: Plugins, url: yarl.URL, codec: str | None = None): raise NotImplementedError("load") @classmethod - def decode(cls, data: bytes, codec: str | None) -> str: + def decode(cls, data: bytes, codec: Optional[str]) -> str: """ decode bytes to ascii or utf-8 @@ -196,7 +196,7 @@ class NullLoader(Loader): Loader does not load anything """ - def load(self, plugins: Plugins, url: yarl.URL, codec: str | None = None): + def load(self, plugins: Plugins, url: yarl.URL, codec: Optional[str] = None): raise NotImplementedError("load") @@ -211,7 +211,7 @@ def __init__(self, baseurl: yarl.URL, session_factory=httpx.Client, yload: "YAML self.baseurl: yarl.URL = baseurl self.session_factory = session_factory - def load(self, plugins: Plugins, url: yarl.URL, codec: str | None = None) -> "JSON": + def load(self, plugins: Plugins, url: yarl.URL, codec: Optional[str] = None) -> "JSON": url = self.baseurl.join(url) with self.session_factory() as session: data = session.get(str(url)) @@ -239,7 +239,7 @@ def __init__(self, base: Path, yload: "YAMLLoaderType" = YAML12Loader): assert isinstance(base, Path) self.base = base - def load(self, plugins: Plugins, url: yarl.URL, codec: str | None = None): + def load(self, plugins: Plugins, url: yarl.URL, codec: Optional[str] = None): assert isinstance(url, yarl.URL) assert plugins file = Path(url.path) @@ -262,7 +262,7 @@ class RedirectLoader(FileSystemLoader): everything but the "name" is stripped of the url """ - def load(self, plugins: "Plugins", url: yarl.URL, codec: str | None = None): + def load(self, plugins: "Plugins", url: yarl.URL, codec: Optional[str] = None): return super().load(plugins, yarl.URL(url.name), codec) @@ -280,7 +280,7 @@ def __init__(self, *loaders, yload: "YAMLLoaderType" = YAML12Loader): Loader.__init__(self, yload) self.loaders = loaders - def load(self, plugins: "Plugins", url: yarl.URL, codec: str | None = None): + def load(self, plugins: "Plugins", url: yarl.URL, codec: Optional[str] = None): log.debug(f"load {url}") errors = [] for i in self.loaders: diff --git a/aiopenapi3/log.py b/aiopenapi3/log.py index 7a737e6f..0d8d0574 100644 --- a/aiopenapi3/log.py +++ b/aiopenapi3/log.py @@ -1,14 +1,14 @@ import sys import logging.config import os -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional if sys.version_info >= (3, 9): from pathlib import Path else: from pathlib3x import Path -handlers: List[str] | None = None +handlers: Optional[List[str]] = None def init(force: bool = False) -> None: diff --git a/aiopenapi3/model.py b/aiopenapi3/model.py index 3e745e66..b6f0dd79 100644 --- a/aiopenapi3/model.py +++ b/aiopenapi3/model.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import collections import dataclasses import logging @@ -114,9 +112,9 @@ class Model: # (BaseModel): def from_schema( cls, schema: "SchemaType", - schemanames: List[str] | None = None, - discriminators: List["DiscriminatorType"] | None = None, - extra: "SchemaType" | None = None, + schemanames: Optional[List[str]] = None, + discriminators: Optional[List["DiscriminatorType"]] = None, + extra: Optional["SchemaType"] = None, ) -> Type[BaseModel]: if schemanames is None: schemanames = [] @@ -322,7 +320,7 @@ def configof(schema: "SchemaType"): @staticmethod def typeof( - schema: Optional[Union["SchemaType", "ReferenceType"]], _type: str | None = None, fwdref: bool = False + schema: Optional[Union["SchemaType", "ReferenceType"]], _type: Optional[str] = None, fwdref: bool = False ) -> Type: if schema is None: return BaseModel @@ -517,7 +515,7 @@ def is_type(schema: "SchemaType", type_) -> bool: return isinstance(schema.type, str) and schema.type == type_ or Model.or_type(schema, type_, l=None) @staticmethod - def or_type(schema: "SchemaType", type_: str, l: int | None = 2) -> bool: + def or_type(schema: "SchemaType", type_: str, l: Optional[int] = 2) -> bool: return isinstance((t := schema.type), list) and (l is None or len(t) == l) and type_ in t @staticmethod diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index 06a18d66..9c2689d2 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -87,8 +87,8 @@ def load_sync( cls, url, session_factory: Callable[..., httpx.Client] = httpx.Client, - loader: Loader | None = None, - plugins: List[Plugin] | None = None, + loader: Optional[Loader] = None, + plugins: Optional[List[Plugin]] = None, use_operation_tags: bool = False, ) -> "OpenAPI": """ @@ -110,8 +110,8 @@ async def load_async( cls, url: str, session_factory: Callable[..., httpx.AsyncClient] = httpx.AsyncClient, - loader: Loader | None = None, - plugins: List[Plugin] | None = None, + loader: Optional[Loader] = None, + plugins: Optional[List[Plugin]] = None, use_operation_tags: bool = False, ) -> "OpenAPI": """ @@ -139,8 +139,8 @@ def load_file( url: str, path: Union[str, pathlib.Path, yarl.URL], session_factory: Callable[..., Union[httpx.AsyncClient, httpx.Client]] = httpx.AsyncClient, - loader: Loader | None = None, - plugins: List[Plugin] | None = None, + loader: Optional[Loader] = None, + plugins: Optional[List[Plugin]] = None, use_operation_tags: bool = False, ) -> "OpenAPI": """ @@ -166,8 +166,8 @@ def loads( url: str, data: str, session_factory: Callable[..., Union[httpx.AsyncClient, httpx.Client]] = httpx.AsyncClient, - loader: Loader | None = None, - plugins: List[Plugin] | None = None, + loader: Optional[Loader] = None, + plugins: Optional[List[Plugin]] = None, use_operation_tags: bool = False, ) -> "OpenAPI": """ @@ -213,8 +213,8 @@ def __init__( url: str, document: "JSON", session_factory: Callable[..., Union[httpx.Client, httpx.AsyncClient]] = httpx.AsyncClient, - loader: Loader | None = None, - plugins: List[Plugin] | None = None, + loader: Optional[Loader] = None, + plugins: Optional[List[Plugin]] = None, use_operation_tags: bool = True, ) -> None: """ @@ -233,7 +233,7 @@ def __init__( self._session_factory: Callable[..., Union[httpx.Client, httpx.AsyncClient]] = session_factory - self.loader: Loader | None = loader + self.loader: Optional[Loader] = loader """ Loader - loading referenced documents """ @@ -722,7 +722,7 @@ def __copy__(self) -> "OpenAPI": api.loader = self.loader return api - def clone(self, baseurl: yarl.URL | None = None) -> "OpenAPI": + def clone(self, baseurl: Optional[yarl.URL] = None) -> "OpenAPI": """ shallwo copy the api object optional set a base url @@ -735,7 +735,7 @@ def clone(self, baseurl: yarl.URL | None = None) -> "OpenAPI": return api @staticmethod - def cache_load(path: pathlib.Path, plugins: List[Plugin] | None = None, session_factory=None) -> "OpenAPI": + def cache_load(path: pathlib.Path, plugins: Optional[List[Plugin]] = None, session_factory=None) -> "OpenAPI": """ read a pickle api object from path and init the schema types diff --git a/aiopenapi3/plugin.py b/aiopenapi3/plugin.py index 9694c6c1..24a65958 100644 --- a/aiopenapi3/plugin.py +++ b/aiopenapi3/plugin.py @@ -49,9 +49,9 @@ class Init(Plugin): class Context: initialized: Optional["OpenAPI"] = None """available in :func:`~aiopenapi3.plugin.Init.initialized`""" - schemas: Dict[str, "SchemaBase"] | None = None + schemas: Optional[Dict[str, "SchemaBase"]] = None """available in :func:`~aiopenapi3.plugin.Init.schemas`""" - paths: Dict[str, "PathItemBase"] | None = None + paths: Optional[Dict[str, "PathItemBase"]] = None """available in :func:`~aiopenapi3.plugin.Init.paths`""" def schemas(self, ctx: "Init.Context") -> "Init.Context": # pragma: no cover @@ -197,7 +197,7 @@ def __init__(self, plugins: List[Plugin]): self._message = self._get_domain("message", plugins) def _get_domain(self, name: str, plugins: List[Plugin]) -> "Domain": - domain: Type[Plugin] | None + domain: Optional[Type[Plugin]] if (domain := self._domains.get(name)) is None: raise ValueError(name) # noqa diff --git a/aiopenapi3/request.py b/aiopenapi3/request.py index 961344e4..632e61bf 100644 --- a/aiopenapi3/request.py +++ b/aiopenapi3/request.py @@ -10,6 +10,7 @@ from aiopenapi3.errors import ContentLengthExceededError + try: from contextlib import aclosing except: # <= Python 3.10 @@ -48,7 +49,7 @@ async def aclosing(thing): class RequestParameter: - def __init__(self, url: yarl.URL | str): + def __init__(self, url: Union[yarl.URL, str]): self.url: str = str(url) self.auth: Optional["AuthTypes"] = None self.cookies: Dict[str, str] = {} diff --git a/aiopenapi3/v20/glue.py b/aiopenapi3/v20/glue.py index e2d1471e..ec3112a0 100644 --- a/aiopenapi3/v20/glue.py +++ b/aiopenapi3/v20/glue.py @@ -283,7 +283,9 @@ def _process_stream(self, result: httpx.Response) -> Tuple[Dict[str, str], Optio headers = self._process__headers(result, result.headers, expected_response) return headers, expected_response.schema_ - def _process_request(self, result: httpx.Response) -> Tuple[Dict[str, str], pydantic.BaseModel | str | None]: + def _process_request( + self, result: httpx.Response + ) -> Tuple[Dict[str, str], Optional[Union[pydantic.BaseModel, str]]]: rheaders = dict() # spec enforces these are strings status_code = str(result.status_code) diff --git a/aiopenapi3/v30/glue.py b/aiopenapi3/v30/glue.py index 3a417b1e..bdbebd23 100644 --- a/aiopenapi3/v30/glue.py +++ b/aiopenapi3/v30/glue.py @@ -463,7 +463,7 @@ def _process__headers( return rheaders def _process__content_type( - self, result: httpx.Response, expected_response: "v3xResponseType", content_type: str | None + self, result: httpx.Response, expected_response: "v3xResponseType", content_type: Optional[str] ) -> Tuple[str, "v3xMediaTypeType"]: if content_type: content_type, _, encoding = content_type.partition(";") @@ -501,7 +501,9 @@ def _process_stream(self, result: httpx.Response) -> Tuple[Dict[str, str], Optio return headers, expected_media.schema_ - def _process_request(self, result: httpx.Response) -> Tuple[Dict[str, str], pydantic.BaseModel | str | None]: + def _process_request( + self, result: httpx.Response + ) -> Tuple[Dict[str, str], Optional[Union[pydantic.BaseModel, str]]]: rheaders = dict() # spec enforces these are strings status_code = str(result.status_code) diff --git a/aiopenapi3/v30/parameter.py b/aiopenapi3/v30/parameter.py index a92a0dc8..9568f88c 100644 --- a/aiopenapi3/v30/parameter.py +++ b/aiopenapi3/v30/parameter.py @@ -275,9 +275,9 @@ def validate_Parameter(cls, p: "ParameterBase"): def encode_parameter( name: str, value: object, - style: str | None, - explode: bool | None, - allowReserved: bool | None, + style: Optional[str], + explode: Optional[bool], + allowReserved: Optional[bool], in_: str, schema_: Schema, ) -> Union[str, bytes]: diff --git a/aiopenapi3/v30/servers.py b/aiopenapi3/v30/servers.py index 054eaf01..cae8fce0 100644 --- a/aiopenapi3/v30/servers.py +++ b/aiopenapi3/v30/servers.py @@ -13,7 +13,7 @@ class ServerVariable(ObjectExtended): """ enum: Optional[List[str]] = Field(default=None) - default: str | None = Field(...) + default: Optional[str] = Field(...) description: Optional[str] = Field(default=None) diff --git a/aiopenapi3/v31/servers.py b/aiopenapi3/v31/servers.py index 05a250b7..b13adfa3 100644 --- a/aiopenapi3/v31/servers.py +++ b/aiopenapi3/v31/servers.py @@ -13,7 +13,7 @@ class ServerVariable(ObjectExtended): """ enum: Optional[List[str]] = Field(default=None) - default: str | None = Field(...) + default: Optional[str] = Field(...) description: Optional[str] = Field(default=None) @model_validator(mode="after") From ed1d5441282892ecc77386cf672190cd44838b5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20K=C3=B6tter?= Date: Tue, 5 Sep 2023 05:53:29 +0200 Subject: [PATCH 8/8] models/Reference - default value for _target --- aiopenapi3/v20/general.py | 7 ++++--- aiopenapi3/v30/general.py | 2 +- aiopenapi3/v31/general.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/aiopenapi3/v20/general.py b/aiopenapi3/v20/general.py index 8f93bda7..ad07831a 100644 --- a/aiopenapi3/v20/general.py +++ b/aiopenapi3/v20/general.py @@ -1,5 +1,5 @@ import typing -from typing import Optional, Any +from typing import Optional, Any, Union from pydantic import Field, ConfigDict, PrivateAttr @@ -7,7 +7,8 @@ from ..base import ObjectExtended, ObjectBase, ReferenceBase if typing.TYPE_CHECKING: - from .._types import SchemaType + from .schemas import Schema + from .parameter import Parameter class ExternalDocumentation(ObjectExtended): @@ -31,7 +32,7 @@ class Reference(ObjectBase, ReferenceBase): ref: str = Field(alias="$ref") - _target: "SchemaType" = PrivateAttr() + _target: Union["Schema", "Parameter", "Reference"] = PrivateAttr(default=None) model_config = ConfigDict(extra="ignore") diff --git a/aiopenapi3/v30/general.py b/aiopenapi3/v30/general.py index 808e26f4..d4dc171f 100644 --- a/aiopenapi3/v30/general.py +++ b/aiopenapi3/v30/general.py @@ -33,7 +33,7 @@ class Reference(ObjectBase, ReferenceBase): ref: str = Field(alias="$ref") - _target: Union["Schema", "Parameter", "Reference"] = PrivateAttr() + _target: Union["Schema", "Parameter", "Reference"] = PrivateAttr(default=None) model_config = ConfigDict( extra="ignore", # """This object cannot be extended with additional properties and any properties added SHALL be ignored.""" diff --git a/aiopenapi3/v31/general.py b/aiopenapi3/v31/general.py index b612888b..7a681e06 100644 --- a/aiopenapi3/v31/general.py +++ b/aiopenapi3/v31/general.py @@ -34,7 +34,7 @@ class Reference(ObjectBase, ReferenceBase): summary: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None) - _target: Union["Schema", "Parameter", "Reference", "PathItem"] = PrivateAttr() + _target: Union["Schema", "Parameter", "Reference", "PathItem"] = PrivateAttr(default=None) model_config = ConfigDict( # """This object cannot be extended with additional properties and any properties added SHALL be ignored."""