From a94c9d48636b74daa39a8f98418dd178ab66478f Mon Sep 17 00:00:00 2001 From: Georg K Date: Fri, 14 Jul 2023 03:31:33 +0300 Subject: [PATCH] feat: update pydantic --- aiohttp_pydantic/view.py | 16 ++++-- requirements/test.txt | 34 +++--------- setup.cfg | 8 +-- tests/test_hook_to_custom_response.py | 29 +++++----- tests/test_oas/test_view.py | 5 +- tests/test_validation_body.py | 50 +++++++++-------- tests/test_validation_header.py | 66 +++++++++++++---------- tests/test_validation_path.py | 11 ++-- tests/test_validation_query_string.py | 78 +++++++++++++++------------ 9 files changed, 159 insertions(+), 138 deletions(-) diff --git a/aiohttp_pydantic/view.py b/aiohttp_pydantic/view.py index 3b7cd65..8fd7ce3 100644 --- a/aiohttp_pydantic/view.py +++ b/aiohttp_pydantic/view.py @@ -10,6 +10,8 @@ from aiohttp.web_exceptions import HTTPMethodNotAllowed from aiohttp.web_response import StreamResponse from pydantic import ValidationError +from pydantic_core import ErrorDetails + from .injectors import ( AbstractInjector, BodyGetter, @@ -22,6 +24,10 @@ from .injectors import ( ) +class PydanticValidationError(ErrorDetails): + loc_in: CONTEXT + + class PydanticView(AbstractView): """ An AIOHTTP View that validate request using function annotations. @@ -91,7 +97,7 @@ class PydanticView(AbstractView): return injectors async def on_validation_error( - self, exception: ValidationError, context: CONTEXT + self, exception: ValidationError, context: CONTEXT ) -> StreamResponse: """ This method is a hook to intercept ValidationError. @@ -101,14 +107,13 @@ class PydanticView(AbstractView): "headers", "path" or "query string" """ errors = exception.errors() - for error in errors: - error["in"] = context + own_errors = [PydanticValidationError(**x, loc_in=context) for x in errors] - return json_response(data=errors, status=400) + return json_response(data=own_errors, status=400) def inject_params( - handler, parse_func_signature: Callable[[Callable], Iterable[AbstractInjector]] + handler, parse_func_signature: Callable[[Callable], Iterable[AbstractInjector]] ): """ Decorator to unpack the query string, route path, body and http header in @@ -146,6 +151,7 @@ def is_pydantic_view(obj) -> bool: __all__ = ( + "PydanticValidationError", "AbstractInjector", "BodyGetter", "HeadersGetter", diff --git a/requirements/test.txt b/requirements/test.txt index e247ce6..1565277 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,28 +1,8 @@ -aiohttp==3.8.1 -aiosignal==1.2.0 -async-timeout==4.0.2 -atomicwrites==1.4.1 -attrs==21.4.0 -bleach==5.0.1 -charset-normalizer==2.1.0 -colorama==0.4.5 -coverage==6.4.2 -docutils==0.19 -frozenlist==1.3.0 -idna==3.3 -iniconfig==1.1.1 -multidict==6.0.2 -packaging==21.3 -pluggy==1.0.0 -py==1.11.0 -Pygments==2.12.0 -pyparsing==3.0.9 -pytest==7.1.2 +aiohttp==3.8.4 +pydantic==2.0.2 +jinja2==3.1.2 +swagger-4-ui-bundle==0.0.4 +pytest==7.4.0 pytest-aiohttp==1.0.4 -pytest-asyncio==0.19.0 -pytest-cov==3.0.0 -readme-renderer==35.0 -six==1.16.0 -tomli==2.0.1 -webencodings==0.5.1 -yarl==1.7.2 +pytest-asyncio==0.21.1 +pytest-cov==4.1.0 diff --git a/setup.cfg b/setup.cfg index 181311f..d2863f1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,8 +18,8 @@ classifiers = Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 Topic :: Software Development :: Libraries :: Application Frameworks Framework :: aiohttp License :: OSI Approved :: MIT License @@ -28,10 +28,10 @@ classifiers = zip_safe = False include_package_data = True packages = find: -python_requires = >=3.8 +python_requires = >=3.10 install_requires = aiohttp - pydantic>=1.7 + pydantic>=2.0.0 swagger-4-ui-bundle [options.extras_require] diff --git a/tests/test_hook_to_custom_response.py b/tests/test_hook_to_custom_response.py index 4a8662a..79c5366 100644 --- a/tests/test_hook_to_custom_response.py +++ b/tests/test_hook_to_custom_response.py @@ -4,9 +4,10 @@ from typing import Iterator, List, Optional from aiohttp import web from aiohttp.web_response import json_response -from pydantic import BaseModel +from pydantic import BaseModel, RootModel from aiohttp_pydantic import PydanticView +from aiohttp_pydantic.view import PydanticValidationError class ArticleModel(BaseModel): @@ -14,11 +15,11 @@ class ArticleModel(BaseModel): nb_page: Optional[int] -class ArticleModels(BaseModel): - __root__: List[ArticleModel] +class ArticleModels(RootModel): + root: List[ArticleModel] def __iter__(self) -> Iterator[ArticleModel]: - return iter(self.__root__) + return iter(self.root) class ArticleView(PydanticView): @@ -30,14 +31,12 @@ class ArticleView(PydanticView): async def on_validation_error(self, exception, context): errors = exception.errors() - for error in errors: - error["in"] = context - error["custom"] = "custom" - return json_response(data=errors, status=400) + own_errors = [PydanticValidationError(**x, loc_in=context) for x in errors] + return json_response(data=own_errors, status=400) async def test_post_an_article_with_wrong_type_field_should_return_an_error_message( - aiohttp_client, event_loop + aiohttp_client, event_loop ): app = web.Application() app.router.add_view("/article", ArticleView) @@ -47,12 +46,14 @@ async def test_post_an_article_with_wrong_type_field_should_return_an_error_mess assert resp.status == 400 assert resp.content_type == "application/json" + assert await resp.json() == [ { - "in": "body", - "loc": ["nb_page"], - "msg": "value is not a valid integer", - "custom": "custom", - "type": "type_error.integer", + 'loc_in': 'body', + 'input': 'foo', + 'loc': ['nb_page'], + 'msg': 'Input should be a valid integer, unable to parse string as an integer', + 'type': 'int_parsing', + 'url': 'https://errors.pydantic.dev/2.1.2/v/int_parsing' } ] diff --git a/tests/test_oas/test_view.py b/tests/test_oas/test_view.py index f3283a2..391accd 100644 --- a/tests/test_oas/test_view.py +++ b/tests/test_oas/test_view.py @@ -6,7 +6,7 @@ from uuid import UUID import pytest from aiohttp import web -from pydantic import Field +from pydantic import Field, RootModel from pydantic.main import BaseModel from aiohttp_pydantic import PydanticView, oas @@ -52,8 +52,7 @@ class Dog(BaseModel): barks: float -class Animal(BaseModel): - __root__: Annotated[Union[Cat, Dog], Field(discriminator='pet_type')] +Animal = RootModel[Annotated[Union[Cat, Dog], Field(discriminator='pet_type')]] class PetCollectionView(PydanticView): diff --git a/tests/test_validation_body.py b/tests/test_validation_body.py index 8df3f35..24dec50 100644 --- a/tests/test_validation_body.py +++ b/tests/test_validation_body.py @@ -3,21 +3,21 @@ from __future__ import annotations from typing import Iterator, List, Optional from aiohttp import web -from pydantic import BaseModel +from pydantic import BaseModel, RootModel from aiohttp_pydantic import PydanticView class ArticleModel(BaseModel): name: str - nb_page: Optional[int] + nb_page: Optional[int] = None -class ArticleModels(BaseModel): - __root__: List[ArticleModel] +class ArticleModels(RootModel): + root: List[ArticleModel] def __iter__(self) -> Iterator[ArticleModel]: - return iter(self.__root__) + return iter(self.root) class ArticleView(PydanticView): @@ -29,7 +29,7 @@ class ArticleView(PydanticView): async def test_post_an_article_without_required_field_should_return_an_error_message( - aiohttp_client, event_loop + aiohttp_client, event_loop ): app = web.Application() app.router.add_view("/article", ArticleView) @@ -38,18 +38,21 @@ async def test_post_an_article_without_required_field_should_return_an_error_mes resp = await client.post("/article", json={}) assert resp.status == 400 assert resp.content_type == "application/json" + assert await resp.json() == [ { - "in": "body", - "loc": ["name"], - "msg": "field required", - "type": "value_error.missing", + 'input': {}, + 'loc': ['name'], + 'loc_in': 'body', + 'msg': 'Field required', + 'type': 'missing', + 'url': 'https://errors.pydantic.dev/2.1.2/v/missing' } ] async def test_post_an_article_with_wrong_type_field_should_return_an_error_message( - aiohttp_client, event_loop + aiohttp_client, event_loop ): app = web.Application() app.router.add_view("/article", ArticleView) @@ -60,10 +63,12 @@ async def test_post_an_article_with_wrong_type_field_should_return_an_error_mess assert resp.content_type == "application/json" assert await resp.json() == [ { - "in": "body", - "loc": ["nb_page"], - "msg": "value is not a valid integer", - "type": "type_error.integer", + 'input': 'foo', + 'loc': ['nb_page'], + 'loc_in': 'body', + 'msg': 'Input should be a valid integer, unable to parse string as an integer', + 'type': 'int_parsing', + 'url': 'https://errors.pydantic.dev/2.1.2/v/int_parsing' } ] @@ -81,7 +86,7 @@ async def test_post_an_array_json_is_supported(aiohttp_client, event_loop): async def test_post_an_array_json_to_an_object_model_should_return_an_error( - aiohttp_client, event_loop + aiohttp_client, event_loop ): app = web.Application() app.router.add_view("/article", ArticleView) @@ -101,7 +106,7 @@ async def test_post_an_array_json_to_an_object_model_should_return_an_error( async def test_post_an_object_json_to_a_list_model_should_return_an_error( - aiohttp_client, event_loop + aiohttp_client, event_loop ): app = web.Application() app.router.add_view("/article", ArticleView) @@ -110,12 +115,15 @@ async def test_post_an_object_json_to_a_list_model_should_return_an_error( resp = await client.put("/article", json={"name": "foo", "nb_page": 3}) assert resp.status == 400 assert resp.content_type == "application/json" + assert await resp.json() == [ { - "in": "body", - "loc": ["__root__"], - "msg": "value is not a valid list", - "type": "type_error.list", + 'input': {'name': 'foo', 'nb_page': 3}, + 'loc': [], + 'loc_in': 'body', + 'msg': 'Input should be a valid list', + 'type': 'list_type', + 'url': 'https://errors.pydantic.dev/2.1.2/v/list_type' } ] diff --git a/tests/test_validation_header.py b/tests/test_validation_header.py index 2d7c734..a788094 100644 --- a/tests/test_validation_header.py +++ b/tests/test_validation_header.py @@ -50,9 +50,9 @@ class Signature(Group): class ArticleViewWithSignatureGroup(PydanticView): async def get( - self, - *, - signature: Signature, + self, + *, + signature: Signature, ): return web.json_response( {"expired": signature.expired, "scope": signature.scope}, @@ -61,7 +61,7 @@ class ArticleViewWithSignatureGroup(PydanticView): async def test_get_article_without_required_header_should_return_an_error_message( - aiohttp_client, event_loop + aiohttp_client, event_loop ): app = web.Application() app.router.add_view("/article", ArticleView) @@ -70,18 +70,24 @@ async def test_get_article_without_required_header_should_return_an_error_messag resp = await client.get("/article", headers={}) assert resp.status == 400 assert resp.content_type == "application/json" - assert await resp.json() == [ + + result = await resp.json() + assert len(result) == 1 + result[0].pop('input') + + assert result == [ { - "in": "headers", - "loc": ["signature_expired"], - "msg": "field required", - "type": "value_error.missing", + 'type': 'missing', + 'loc': ['signature_expired'], + 'msg': 'Field required', + 'url': 'https://errors.pydantic.dev/2.1.2/v/missing', + 'loc_in': 'headers' } ] async def test_get_article_with_wrong_header_type_should_return_an_error_message( - aiohttp_client, event_loop + aiohttp_client, event_loop ): app = web.Application() app.router.add_view("/article", ArticleView) @@ -90,18 +96,22 @@ async def test_get_article_with_wrong_header_type_should_return_an_error_message resp = await client.get("/article", headers={"signature_expired": "foo"}) assert resp.status == 400 assert resp.content_type == "application/json" + assert await resp.json() == [ { - "in": "headers", - "loc": ["signature_expired"], - "msg": "invalid datetime format", - "type": "value_error.datetime", + 'type': 'datetime_parsing', + 'loc': ['signature_expired'], + 'msg': 'Input should be a valid datetime, input is too short', + 'input': 'foo', + 'ctx': {'error': 'input is too short'}, + 'url': 'https://errors.pydantic.dev/2.1.2/v/datetime_parsing', + 'loc_in': 'headers' } ] async def test_get_article_with_valid_header_should_return_the_parsed_type( - aiohttp_client, event_loop + aiohttp_client, event_loop ): app = web.Application() app.router.add_view("/article", ArticleView) @@ -116,7 +126,7 @@ async def test_get_article_with_valid_header_should_return_the_parsed_type( async def test_get_article_with_valid_header_containing_hyphen_should_be_returned( - aiohttp_client, event_loop + aiohttp_client, event_loop ): app = web.Application() app.router.add_view("/article", ArticleView) @@ -136,18 +146,20 @@ async def test_wrong_value_to_header_defined_with_str_enum(aiohttp_client, event client = await aiohttp_client(app) resp = await client.get("/coord", headers={"format": "WGS84"}) + assert ( - await resp.json() - == [ - { - "ctx": {"enum_values": ["UMT", "MGRS"]}, - "in": "headers", - "loc": ["format"], - "msg": "value is not a valid enumeration member; permitted: 'UMT', 'MGRS'", - "type": "type_error.enum", - } - ] - != {"signature": "2020-10-04T18:01:00"} + await resp.json() + == [ + { + 'ctx': {'expected': "'UMT' or 'MGRS'"}, + 'input': 'WGS84', + 'loc': ['format'], + 'loc_in': 'headers', + 'msg': "Input should be 'UMT' or 'MGRS'", + 'type': 'enum' + } + ] + != {"signature": "2020-10-04T18:01:00"} ) assert resp.status == 400 assert resp.content_type == "application/json" diff --git a/tests/test_validation_path.py b/tests/test_validation_path.py index dd43c74..b059752 100644 --- a/tests/test_validation_path.py +++ b/tests/test_validation_path.py @@ -33,11 +33,14 @@ async def test_get_article_with_wrong_path_parameters_should_return_error( resp = await client.get("/article/1234/tag/music/before/now") assert resp.status == 400 assert resp.content_type == "application/json" + assert await resp.json() == [ { - "in": "path", - "loc": ["date"], - "msg": "value is not a valid integer", - "type": "type_error.integer", + 'input': 'now', + 'loc': ['date'], + 'loc_in': 'path', + 'msg': 'Input should be a valid integer, unable to parse string as an integer', + 'type': 'int_parsing', + 'url': 'https://errors.pydantic.dev/2.1.2/v/int_parsing' } ] diff --git a/tests/test_validation_query_string.py b/tests/test_validation_query_string.py index 411410a..8302c6b 100644 --- a/tests/test_validation_query_string.py +++ b/tests/test_validation_query_string.py @@ -11,11 +11,11 @@ from aiohttp_pydantic.injectors import Group class ArticleView(PydanticView): async def get( - self, - with_comments: bool, - age: Optional[int] = None, - nb_items: int = 7, - tags: List[str] = Field(default_factory=list), + self, + with_comments: bool, + age: Optional[int] = None, + nb_items: int = 7, + tags: List[str] = Field(default_factory=list), ): return web.json_response( { @@ -42,9 +42,9 @@ class Pagination(Group): class ArticleViewWithPaginationGroup(PydanticView): async def get( - self, - with_comments: bool, - page: Pagination, + self, + with_comments: bool, + page: Pagination, ): return web.json_response( { @@ -70,7 +70,7 @@ class ArticleViewWithEnumInQuery(PydanticView): async def test_get_article_without_required_qs_should_return_an_error_message( - aiohttp_client, event_loop + aiohttp_client, event_loop ): app = web.Application() app.router.add_view("/article", ArticleView) @@ -79,18 +79,21 @@ async def test_get_article_without_required_qs_should_return_an_error_message( resp = await client.get("/article") assert resp.status == 400 assert resp.content_type == "application/json" + assert await resp.json() == [ { - "in": "query string", - "loc": ["with_comments"], - "msg": "field required", - "type": "value_error.missing", + 'input': {}, + 'loc': ['with_comments'], + 'loc_in': 'query string', + 'msg': 'Field required', + 'type': 'missing', + 'url': 'https://errors.pydantic.dev/2.1.2/v/missing' } ] async def test_get_article_with_wrong_qs_type_should_return_an_error_message( - aiohttp_client, event_loop + aiohttp_client, event_loop ): app = web.Application() app.router.add_view("/article", ArticleView) @@ -101,16 +104,18 @@ async def test_get_article_with_wrong_qs_type_should_return_an_error_message( assert resp.content_type == "application/json" assert await resp.json() == [ { - "in": "query string", - "loc": ["with_comments"], - "msg": "value could not be parsed to a boolean", - "type": "type_error.bool", + 'input': 'foo', + 'loc': ['with_comments'], + 'loc_in': 'query string', + 'msg': 'Input should be a valid boolean, unable to interpret input', + 'type': 'bool_parsing', + 'url': 'https://errors.pydantic.dev/2.1.2/v/bool_parsing' } ] async def test_get_article_with_valid_qs_should_return_the_parsed_type( - aiohttp_client, event_loop + aiohttp_client, event_loop ): app = web.Application() app.router.add_view("/article", ArticleView) @@ -129,7 +134,7 @@ async def test_get_article_with_valid_qs_should_return_the_parsed_type( async def test_get_article_with_valid_qs_and_omitted_optional_should_return_default_value( - aiohttp_client, event_loop + aiohttp_client, event_loop ): app = web.Application() app.router.add_view("/article", ArticleView) @@ -148,7 +153,7 @@ async def test_get_article_with_valid_qs_and_omitted_optional_should_return_defa async def test_get_article_with_multiple_value_for_qs_age_must_failed( - aiohttp_client, event_loop + aiohttp_client, event_loop ): app = web.Application() app.router.add_view("/article", ArticleView) @@ -158,10 +163,12 @@ async def test_get_article_with_multiple_value_for_qs_age_must_failed( resp = await client.get("/article", params={"age": ["2", "3"], "with_comments": 1}) assert await resp.json() == [ { - "in": "query string", - "loc": ["age"], - "msg": "value is not a valid integer", - "type": "type_error.integer", + 'input': ['2', '3'], + 'loc': ['age'], + 'loc_in': 'query string', + 'msg': 'Input should be a valid integer', + 'type': 'int_type', + 'url': 'https://errors.pydantic.dev/2.1.2/v/int_type' } ] assert resp.status == 400 @@ -215,10 +222,12 @@ async def test_get_article_without_required_field_page(aiohttp_client, event_loo resp = await client.get("/article", params={"with_comments": 1}) assert await resp.json() == [ { - "in": "query string", - "loc": ["page_num"], - "msg": "field required", - "type": "value_error.missing", + 'input': {'with_comments': '1'}, + 'loc': ['page_num'], + 'loc_in': 'query string', + 'msg': 'Field required', + 'type': 'missing', + 'url': 'https://errors.pydantic.dev/2.1.2/v/missing' } ] assert resp.status == 400 @@ -276,10 +285,13 @@ async def test_get_article_with_page_and_wrong_page_size(aiohttp_client, event_l ) assert await resp.json() == [ { - "in": "query string", - "loc": ["page_size"], - "msg": "value is not a valid integer", - "type": "type_error.integer", + 'input': 'large', + 'loc': ['page_size'], + 'loc_in': 'query string', + 'msg': 'Input should be a valid integer, unable to parse string as an ' + 'integer', + 'type': 'int_parsing', + 'url': 'https://errors.pydantic.dev/2.1.2/v/int_parsing' } ] assert resp.status == 400