feat: update pydantic

This commit is contained in:
Georg K 2023-07-14 03:31:33 +03:00
parent 26fd6fa19f
commit a94c9d4863
9 changed files with 159 additions and 138 deletions

View File

@ -10,6 +10,8 @@ from aiohttp.web_exceptions import HTTPMethodNotAllowed
from aiohttp.web_response import StreamResponse from aiohttp.web_response import StreamResponse
from pydantic import ValidationError from pydantic import ValidationError
from pydantic_core import ErrorDetails
from .injectors import ( from .injectors import (
AbstractInjector, AbstractInjector,
BodyGetter, BodyGetter,
@ -22,6 +24,10 @@ from .injectors import (
) )
class PydanticValidationError(ErrorDetails):
loc_in: CONTEXT
class PydanticView(AbstractView): class PydanticView(AbstractView):
""" """
An AIOHTTP View that validate request using function annotations. An AIOHTTP View that validate request using function annotations.
@ -91,7 +97,7 @@ class PydanticView(AbstractView):
return injectors return injectors
async def on_validation_error( async def on_validation_error(
self, exception: ValidationError, context: CONTEXT self, exception: ValidationError, context: CONTEXT
) -> StreamResponse: ) -> StreamResponse:
""" """
This method is a hook to intercept ValidationError. This method is a hook to intercept ValidationError.
@ -101,14 +107,13 @@ class PydanticView(AbstractView):
"headers", "path" or "query string" "headers", "path" or "query string"
""" """
errors = exception.errors() errors = exception.errors()
for error in errors: own_errors = [PydanticValidationError(**x, loc_in=context) for x in errors]
error["in"] = context
return json_response(data=errors, status=400) return json_response(data=own_errors, status=400)
def inject_params( 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 Decorator to unpack the query string, route path, body and http header in
@ -146,6 +151,7 @@ def is_pydantic_view(obj) -> bool:
__all__ = ( __all__ = (
"PydanticValidationError",
"AbstractInjector", "AbstractInjector",
"BodyGetter", "BodyGetter",
"HeadersGetter", "HeadersGetter",

View File

@ -1,28 +1,8 @@
aiohttp==3.8.1 aiohttp==3.8.4
aiosignal==1.2.0 pydantic==2.0.2
async-timeout==4.0.2 jinja2==3.1.2
atomicwrites==1.4.1 swagger-4-ui-bundle==0.0.4
attrs==21.4.0 pytest==7.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
pytest-aiohttp==1.0.4 pytest-aiohttp==1.0.4
pytest-asyncio==0.19.0 pytest-asyncio==0.21.1
pytest-cov==3.0.0 pytest-cov==4.1.0
readme-renderer==35.0
six==1.16.0
tomli==2.0.1
webencodings==0.5.1
yarl==1.7.2

View File

@ -18,8 +18,8 @@ classifiers =
Programming Language :: Python Programming Language :: Python
Programming Language :: Python :: 3 Programming Language :: Python :: 3
Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.11
Topic :: Software Development :: Libraries :: Application Frameworks Topic :: Software Development :: Libraries :: Application Frameworks
Framework :: aiohttp Framework :: aiohttp
License :: OSI Approved :: MIT License License :: OSI Approved :: MIT License
@ -28,10 +28,10 @@ classifiers =
zip_safe = False zip_safe = False
include_package_data = True include_package_data = True
packages = find: packages = find:
python_requires = >=3.8 python_requires = >=3.10
install_requires = install_requires =
aiohttp aiohttp
pydantic>=1.7 pydantic>=2.0.0
swagger-4-ui-bundle swagger-4-ui-bundle
[options.extras_require] [options.extras_require]

View File

@ -4,9 +4,10 @@ from typing import Iterator, List, Optional
from aiohttp import web from aiohttp import web
from aiohttp.web_response import json_response from aiohttp.web_response import json_response
from pydantic import BaseModel from pydantic import BaseModel, RootModel
from aiohttp_pydantic import PydanticView from aiohttp_pydantic import PydanticView
from aiohttp_pydantic.view import PydanticValidationError
class ArticleModel(BaseModel): class ArticleModel(BaseModel):
@ -14,11 +15,11 @@ class ArticleModel(BaseModel):
nb_page: Optional[int] nb_page: Optional[int]
class ArticleModels(BaseModel): class ArticleModels(RootModel):
__root__: List[ArticleModel] root: List[ArticleModel]
def __iter__(self) -> Iterator[ArticleModel]: def __iter__(self) -> Iterator[ArticleModel]:
return iter(self.__root__) return iter(self.root)
class ArticleView(PydanticView): class ArticleView(PydanticView):
@ -30,14 +31,12 @@ class ArticleView(PydanticView):
async def on_validation_error(self, exception, context): async def on_validation_error(self, exception, context):
errors = exception.errors() errors = exception.errors()
for error in errors: own_errors = [PydanticValidationError(**x, loc_in=context) for x in errors]
error["in"] = context return json_response(data=own_errors, status=400)
error["custom"] = "custom"
return json_response(data=errors, status=400)
async def test_post_an_article_with_wrong_type_field_should_return_an_error_message( 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 = web.Application()
app.router.add_view("/article", ArticleView) 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.status == 400
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
assert await resp.json() == [ assert await resp.json() == [
{ {
"in": "body", 'loc_in': 'body',
"loc": ["nb_page"], 'input': 'foo',
"msg": "value is not a valid integer", 'loc': ['nb_page'],
"custom": "custom", 'msg': 'Input should be a valid integer, unable to parse string as an integer',
"type": "type_error.integer", 'type': 'int_parsing',
'url': 'https://errors.pydantic.dev/2.1.2/v/int_parsing'
} }
] ]

View File

@ -6,7 +6,7 @@ from uuid import UUID
import pytest import pytest
from aiohttp import web from aiohttp import web
from pydantic import Field from pydantic import Field, RootModel
from pydantic.main import BaseModel from pydantic.main import BaseModel
from aiohttp_pydantic import PydanticView, oas from aiohttp_pydantic import PydanticView, oas
@ -52,8 +52,7 @@ class Dog(BaseModel):
barks: float barks: float
class Animal(BaseModel): Animal = RootModel[Annotated[Union[Cat, Dog], Field(discriminator='pet_type')]]
__root__: Annotated[Union[Cat, Dog], Field(discriminator='pet_type')]
class PetCollectionView(PydanticView): class PetCollectionView(PydanticView):

View File

@ -3,21 +3,21 @@ from __future__ import annotations
from typing import Iterator, List, Optional from typing import Iterator, List, Optional
from aiohttp import web from aiohttp import web
from pydantic import BaseModel from pydantic import BaseModel, RootModel
from aiohttp_pydantic import PydanticView from aiohttp_pydantic import PydanticView
class ArticleModel(BaseModel): class ArticleModel(BaseModel):
name: str name: str
nb_page: Optional[int] nb_page: Optional[int] = None
class ArticleModels(BaseModel): class ArticleModels(RootModel):
__root__: List[ArticleModel] root: List[ArticleModel]
def __iter__(self) -> Iterator[ArticleModel]: def __iter__(self) -> Iterator[ArticleModel]:
return iter(self.__root__) return iter(self.root)
class ArticleView(PydanticView): class ArticleView(PydanticView):
@ -29,7 +29,7 @@ class ArticleView(PydanticView):
async def test_post_an_article_without_required_field_should_return_an_error_message( 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 = web.Application()
app.router.add_view("/article", ArticleView) 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={}) resp = await client.post("/article", json={})
assert resp.status == 400 assert resp.status == 400
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
assert await resp.json() == [ assert await resp.json() == [
{ {
"in": "body", 'input': {},
"loc": ["name"], 'loc': ['name'],
"msg": "field required", 'loc_in': 'body',
"type": "value_error.missing", '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( 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 = web.Application()
app.router.add_view("/article", ArticleView) 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 resp.content_type == "application/json"
assert await resp.json() == [ assert await resp.json() == [
{ {
"in": "body", 'input': 'foo',
"loc": ["nb_page"], 'loc': ['nb_page'],
"msg": "value is not a valid integer", 'loc_in': 'body',
"type": "type_error.integer", '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( 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 = web.Application()
app.router.add_view("/article", ArticleView) 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( 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 = web.Application()
app.router.add_view("/article", ArticleView) 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}) resp = await client.put("/article", json={"name": "foo", "nb_page": 3})
assert resp.status == 400 assert resp.status == 400
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
assert await resp.json() == [ assert await resp.json() == [
{ {
"in": "body", 'input': {'name': 'foo', 'nb_page': 3},
"loc": ["__root__"], 'loc': [],
"msg": "value is not a valid list", 'loc_in': 'body',
"type": "type_error.list", 'msg': 'Input should be a valid list',
'type': 'list_type',
'url': 'https://errors.pydantic.dev/2.1.2/v/list_type'
} }
] ]

View File

@ -50,9 +50,9 @@ class Signature(Group):
class ArticleViewWithSignatureGroup(PydanticView): class ArticleViewWithSignatureGroup(PydanticView):
async def get( async def get(
self, self,
*, *,
signature: Signature, signature: Signature,
): ):
return web.json_response( return web.json_response(
{"expired": signature.expired, "scope": signature.scope}, {"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( 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 = web.Application()
app.router.add_view("/article", ArticleView) 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={}) resp = await client.get("/article", headers={})
assert resp.status == 400 assert resp.status == 400
assert resp.content_type == "application/json" 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", 'type': 'missing',
"loc": ["signature_expired"], 'loc': ['signature_expired'],
"msg": "field required", 'msg': 'Field required',
"type": "value_error.missing", '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( 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 = web.Application()
app.router.add_view("/article", ArticleView) 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"}) resp = await client.get("/article", headers={"signature_expired": "foo"})
assert resp.status == 400 assert resp.status == 400
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
assert await resp.json() == [ assert await resp.json() == [
{ {
"in": "headers", 'type': 'datetime_parsing',
"loc": ["signature_expired"], 'loc': ['signature_expired'],
"msg": "invalid datetime format", 'msg': 'Input should be a valid datetime, input is too short',
"type": "value_error.datetime", '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( 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 = web.Application()
app.router.add_view("/article", ArticleView) 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( 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 = web.Application()
app.router.add_view("/article", ArticleView) 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) client = await aiohttp_client(app)
resp = await client.get("/coord", headers={"format": "WGS84"}) resp = await client.get("/coord", headers={"format": "WGS84"})
assert ( assert (
await resp.json() await resp.json()
== [ == [
{ {
"ctx": {"enum_values": ["UMT", "MGRS"]}, 'ctx': {'expected': "'UMT' or 'MGRS'"},
"in": "headers", 'input': 'WGS84',
"loc": ["format"], 'loc': ['format'],
"msg": "value is not a valid enumeration member; permitted: 'UMT', 'MGRS'", 'loc_in': 'headers',
"type": "type_error.enum", 'msg': "Input should be 'UMT' or 'MGRS'",
} 'type': 'enum'
] }
!= {"signature": "2020-10-04T18:01:00"} ]
!= {"signature": "2020-10-04T18:01:00"}
) )
assert resp.status == 400 assert resp.status == 400
assert resp.content_type == "application/json" assert resp.content_type == "application/json"

View File

@ -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") resp = await client.get("/article/1234/tag/music/before/now")
assert resp.status == 400 assert resp.status == 400
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
assert await resp.json() == [ assert await resp.json() == [
{ {
"in": "path", 'input': 'now',
"loc": ["date"], 'loc': ['date'],
"msg": "value is not a valid integer", 'loc_in': 'path',
"type": "type_error.integer", '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'
} }
] ]

View File

@ -11,11 +11,11 @@ from aiohttp_pydantic.injectors import Group
class ArticleView(PydanticView): class ArticleView(PydanticView):
async def get( async def get(
self, self,
with_comments: bool, with_comments: bool,
age: Optional[int] = None, age: Optional[int] = None,
nb_items: int = 7, nb_items: int = 7,
tags: List[str] = Field(default_factory=list), tags: List[str] = Field(default_factory=list),
): ):
return web.json_response( return web.json_response(
{ {
@ -42,9 +42,9 @@ class Pagination(Group):
class ArticleViewWithPaginationGroup(PydanticView): class ArticleViewWithPaginationGroup(PydanticView):
async def get( async def get(
self, self,
with_comments: bool, with_comments: bool,
page: Pagination, page: Pagination,
): ):
return web.json_response( return web.json_response(
{ {
@ -70,7 +70,7 @@ class ArticleViewWithEnumInQuery(PydanticView):
async def test_get_article_without_required_qs_should_return_an_error_message( 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 = web.Application()
app.router.add_view("/article", ArticleView) 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") resp = await client.get("/article")
assert resp.status == 400 assert resp.status == 400
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
assert await resp.json() == [ assert await resp.json() == [
{ {
"in": "query string", 'input': {},
"loc": ["with_comments"], 'loc': ['with_comments'],
"msg": "field required", 'loc_in': 'query string',
"type": "value_error.missing", '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( 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 = web.Application()
app.router.add_view("/article", ArticleView) 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 resp.content_type == "application/json"
assert await resp.json() == [ assert await resp.json() == [
{ {
"in": "query string", 'input': 'foo',
"loc": ["with_comments"], 'loc': ['with_comments'],
"msg": "value could not be parsed to a boolean", 'loc_in': 'query string',
"type": "type_error.bool", '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( 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 = web.Application()
app.router.add_view("/article", ArticleView) 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( 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 = web.Application()
app.router.add_view("/article", ArticleView) 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( 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 = web.Application()
app.router.add_view("/article", ArticleView) 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}) resp = await client.get("/article", params={"age": ["2", "3"], "with_comments": 1})
assert await resp.json() == [ assert await resp.json() == [
{ {
"in": "query string", 'input': ['2', '3'],
"loc": ["age"], 'loc': ['age'],
"msg": "value is not a valid integer", 'loc_in': 'query string',
"type": "type_error.integer", '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 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}) resp = await client.get("/article", params={"with_comments": 1})
assert await resp.json() == [ assert await resp.json() == [
{ {
"in": "query string", 'input': {'with_comments': '1'},
"loc": ["page_num"], 'loc': ['page_num'],
"msg": "field required", 'loc_in': 'query string',
"type": "value_error.missing", 'msg': 'Field required',
'type': 'missing',
'url': 'https://errors.pydantic.dev/2.1.2/v/missing'
} }
] ]
assert resp.status == 400 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() == [ assert await resp.json() == [
{ {
"in": "query string", 'input': 'large',
"loc": ["page_size"], 'loc': ['page_size'],
"msg": "value is not a valid integer", 'loc_in': 'query string',
"type": "type_error.integer", '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 assert resp.status == 400