From 7fc0b2174aa33026ed5110fd2de314570da429c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jouni=20K=2E=20Sepp=C3=A4nen?= Date: Thu, 3 Aug 2023 19:02:12 +0300 Subject: [PATCH 1/9] Require pydantic 2.0 --- requirements/base.pip | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/base.pip b/requirements/base.pip index 6640194..d59a468 100644 --- a/requirements/base.pip +++ b/requirements/base.pip @@ -1,2 +1,2 @@ Flask -pydantic>=1.7 +pydantic>=2.0 diff --git a/setup.py b/setup.py index 09be0eb..f31972d 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ def find_version(file_path: Path = VERSION_FILE_PATH) -> str: long_description_content_type="text/markdown", packages=["flask_pydantic"], install_requires=list(get_install_requires()), - python_requires=">=3.6", + python_requires=">=3.7", classifiers=[ "Environment :: Web Environment", "Framework :: Flask", From 198df33893e24209510c02f1efaa7d8fc4064985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jouni=20K=2E=20Sepp=C3=A4nen?= Date: Thu, 3 Aug 2023 19:06:33 +0300 Subject: [PATCH 2/9] The simple pydantic v2 renames .dict() is now .model_dump(), etc __root__ is now root and you have to subclass RootModel --- flask_pydantic/core.py | 22 ++++++++++----------- tests/func/test_app.py | 15 +++++++------- tests/unit/test_core.py | 44 ++++++++++++++++++++++++++--------------- 3 files changed, 46 insertions(+), 35 deletions(-) diff --git a/flask_pydantic/core.py b/flask_pydantic/core.py index 912415a..68bbfe0 100644 --- a/flask_pydantic/core.py +++ b/flask_pydantic/core.py @@ -2,8 +2,7 @@ from typing import Any, Callable, Iterable, List, Optional, Tuple, Type, Union from flask import Response, current_app, jsonify, make_response, request -from pydantic import BaseModel, ValidationError -from pydantic.tools import parse_obj_as +from pydantic import BaseModel, ValidationError, TypeAdapter, RootModel from .converters import convert_query_params from .exceptions import ( @@ -28,9 +27,9 @@ def make_json_response( ) -> Response: """serializes model, creates JSON response with given status code""" if many: - js = f"[{', '.join([model.json(exclude_none=exclude_none, by_alias=by_alias) for model in content])}]" + js = f"[{', '.join([model.model_dump_json(exclude_none=exclude_none, by_alias=by_alias) for model in content])}]" else: - js = content.json(exclude_none=exclude_none, by_alias=by_alias) + js = content.model_dump_json(exclude_none=exclude_none, by_alias=by_alias) response = make_response(js, status_code) response.mimetype = "application/json" return response @@ -75,8 +74,8 @@ def validate_path_params(func: Callable, kwargs: dict) -> Tuple[dict, list]: if name in {"query", "body", "form", "return"}: continue try: - value = parse_obj_as(type_, kwargs.get(name)) - validated[name] = value + adapter = TypeAdapter(type_) + validated[name] = adapter.validate_python(kwargs.get(name)) except ValidationError as e: err = e.errors()[0] err["loc"] = [name] @@ -182,9 +181,9 @@ def wrapper(*args, **kwargs): body_model = body_in_kwargs or body if body_model: body_params = get_body_dict(**(get_json_params or {})) - if "__root__" in body_model.__fields__: + if issubclass(body_model, RootModel): try: - b = body_model(__root__=body_params).__root__ + b = body_model(body_params) except ValidationError as ve: err["body_params"] = ve.errors() elif request_body_many: @@ -208,9 +207,9 @@ def wrapper(*args, **kwargs): form_model = form_in_kwargs or form if form_model: form_params = request.form - if "__root__" in form_model.__fields__: + if issubclass(form_model, RootModel): try: - f = form_model(__root__=form_params).__root__ + f = form_model(form_params) except ValidationError as ve: err["form_params"] = ve.errors() else: @@ -245,8 +244,7 @@ def wrapper(*args, **kwargs): "FLASK_PYDANTIC_VALIDATION_ERROR_STATUS_CODE", 400 ) return make_response( - jsonify({"validation_error": err}), - status_code + jsonify({"validation_error": err}), status_code ) res = func(*args, **kwargs) diff --git a/tests/func/test_app.py b/tests/func/test_app.py index a01edd7..c553e63 100644 --- a/tests/func/test_app.py +++ b/tests/func/test_app.py @@ -3,7 +3,7 @@ import pytest from flask import jsonify, request from flask_pydantic import validate, ValidationError -from pydantic import BaseModel +from pydantic import BaseModel, RootModel, ConfigDict class ArrayModel(BaseModel): @@ -91,8 +91,11 @@ class Person(BaseModel): name: str age: Optional[int] - class PersonBulk(BaseModel): - __root__: List[Person] + class PersonBulk(RootModel): + root: List[Person] + + def __len__(self): + return len(self.root) @app.route("/root_type", methods=["POST"]) @validate() @@ -127,13 +130,11 @@ class RequestModel(BaseModel): y: int class ResultModel(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + result_of_addition: int result_of_multiplication: int - class Config: - alias_generator = to_camel - allow_population_by_field_name = True - @app.route("/compute", methods=["GET"]) @validate(response_by_alias=True) def compute(query: RequestModel): diff --git a/tests/unit/test_core.py b/tests/unit/test_core.py index 62b1baf..1e24a41 100644 --- a/tests/unit/test_core.py +++ b/tests/unit/test_core.py @@ -8,7 +8,7 @@ InvalidIterableOfModelsException, JsonBodyParsingError, ) -from pydantic import BaseModel +from pydantic import BaseModel, RootModel from werkzeug.datastructures import ImmutableMultiDict @@ -50,8 +50,8 @@ class FormModel(BaseModel): f2: str = None -class RequestBodyModelRoot(BaseModel): - __root__: Union[str, RequestBodyModel] +class RequestBodyModelRoot(RootModel): + root: Union[str, RequestBodyModel] validate_test_cases = [ @@ -191,11 +191,11 @@ def f(): body = {} query = {} if mock_request.form_params: - body = mock_request.form_params.dict() + body = mock_request.form_params.model_dump() if mock_request.body_params: - body = mock_request.body_params.dict() + body = mock_request.body_params.model_dump() if mock_request.query_params: - query = mock_request.query_params.dict() + query = mock_request.query_params.model_dump() return parameters.response_model(**body, **query) response = validate( @@ -212,11 +212,15 @@ def f(): assert response.json == parameters.expected_response_body if 200 <= response.status_code < 300: assert ( - mock_request.body_params.dict(exclude_none=True, exclude_defaults=True) + mock_request.body_params.model_dump( + exclude_none=True, exclude_defaults=True + ) == parameters.request_body ) assert ( - mock_request.query_params.dict(exclude_none=True, exclude_defaults=True) + mock_request.query_params.model_dump( + exclude_none=True, exclude_defaults=True + ) == parameters.request_query.to_dict() ) @@ -233,7 +237,7 @@ def f( form: parameters.form_model, ): return parameters.response_model( - **body.dict(), **query.dict(), **form.dict() + **body.model_dump(), **query.model_dump(), **form.model_dump() ) response = validate( @@ -247,11 +251,15 @@ def f( assert response.status_code == parameters.expected_status_code if 200 <= response.status_code < 300: assert ( - mock_request.body_params.dict(exclude_none=True, exclude_defaults=True) + mock_request.body_params.model_dump( + exclude_none=True, exclude_defaults=True + ) == parameters.request_body ) assert ( - mock_request.query_params.dict(exclude_none=True, exclude_defaults=True) + mock_request.query_params.model_dump( + exclude_none=True, exclude_defaults=True + ) == parameters.request_query.to_dict() ) @@ -403,11 +411,11 @@ def f() -> Any: body = {} query = {} if mock_request.form_params: - body = mock_request.form_params.dict() + body = mock_request.form_params.model_dump() if mock_request.body_params: - body = mock_request.body_params.dict() + body = mock_request.body_params.model_dump() if mock_request.query_params: - query = mock_request.query_params.dict() + query = mock_request.query_params.model_dump() return parameters.response_model(**body, **query) response = validate( @@ -424,11 +432,15 @@ def f() -> Any: assert response.json == parameters.expected_response_body if 200 <= response.status_code < 300: assert ( - mock_request.body_params.dict(exclude_none=True, exclude_defaults=True) + mock_request.body_params.model_dump( + exclude_none=True, exclude_defaults=True + ) == parameters.request_body ) assert ( - mock_request.query_params.dict(exclude_none=True, exclude_defaults=True) + mock_request.query_params.model_dump( + exclude_none=True, exclude_defaults=True + ) == parameters.request_query.to_dict() ) From aff11f8abcd4b398d3177cdd27796ecb030c3d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jouni=20K=2E=20Sepp=C3=A4nen?= Date: Thu, 3 Aug 2023 19:08:41 +0300 Subject: [PATCH 3/9] Add default values in test code Pydantic now treats Optional[int] as a required parameter that may be int or None. You have to provide a default value to make it an optional parameter. --- tests/conftest.py | 13 ++++++++----- tests/func/test_app.py | 4 ++-- tests/unit/test_core.py | 2 +- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index e2b8ed1..fc02ca4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,7 +20,7 @@ def posts() -> List[dict]: def query_model() -> Type[BaseModel]: class Query(BaseModel): limit: int = 2 - min_views: Optional[int] + min_views: Optional[int] = None return Query @@ -29,7 +29,7 @@ class Query(BaseModel): def body_model() -> Type[BaseModel]: class Body(BaseModel): search_term: str - exclude: Optional[str] + exclude: Optional[str] = None return Body @@ -38,7 +38,7 @@ class Body(BaseModel): def form_model() -> Type[BaseModel]: class Form(BaseModel): search_term: str - exclude: Optional[str] + exclude: Optional[str] = None return Form @@ -62,14 +62,17 @@ class Response(BaseModel): return Response -def is_excluded(post: dict, exclude: Optional[str]) -> bool: +def is_excluded(post: dict, exclude: Optional[str] = None) -> bool: if exclude is None: return False return exclude in post["title"] or exclude in post["text"] def pass_search( - post: dict, search_term: str, exclude: Optional[str], min_views: Optional[int] + post: dict, + search_term: str, + exclude: Optional[str] = None, + min_views: Optional[int] = None, ) -> bool: return ( (search_term in post["title"] or search_term in post["text"]) diff --git a/tests/func/test_app.py b/tests/func/test_app.py index c553e63..0e9d974 100644 --- a/tests/func/test_app.py +++ b/tests/func/test_app.py @@ -8,7 +8,7 @@ class ArrayModel(BaseModel): arr1: List[str] - arr2: Optional[List[int]] + arr2: Optional[List[int]] = None @pytest.fixture @@ -89,7 +89,7 @@ def int_path_param(obj_id): def app_with_custom_root_type(app): class Person(BaseModel): name: str - age: Optional[int] + age: Optional[int] = None class PersonBulk(RootModel): root: List[Person] diff --git a/tests/unit/test_core.py b/tests/unit/test_core.py index 1e24a41..2d26ee7 100644 --- a/tests/unit/test_core.py +++ b/tests/unit/test_core.py @@ -32,7 +32,7 @@ class ResponseModel(BaseModel): q1: int q2: str b1: float - b2: Optional[str] + b2: Optional[str] = None class QueryModel(BaseModel): From be10f3d1cc370d3f2f661eeded31fb5f1aae9164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jouni=20K=2E=20Sepp=C3=A4nen?= Date: Thu, 3 Aug 2023 19:11:30 +0300 Subject: [PATCH 4/9] Detect lists via type annotations Pydantic no longer has an .is_complex() method. But the meaning of the code seems to have been to detect lists so that queries like a=1&a=2&a=3 can be unflattened to a=['1', '2', '3'] when a is List[str]. This implementation should work for both List[...] and Optional[List[...]] but requires a small dependency on older versions of Python. --- flask_pydantic/converters.py | 19 +++++++++++++++++-- requirements/base.pip | 1 + 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/flask_pydantic/converters.py b/flask_pydantic/converters.py index 0fed087..fa7e24a 100644 --- a/flask_pydantic/converters.py +++ b/flask_pydantic/converters.py @@ -1,9 +1,23 @@ -from typing import Type +from typing import Type, Union + +try: + from typing import get_args, get_origin +except ImportError: + from typing_extensions import get_args, get_origin from pydantic import BaseModel from werkzeug.datastructures import ImmutableMultiDict +def _is_list(type_: Type) -> bool: + origin = get_origin(type_) + if origin is list: + return True + if origin is Union: + return any(_is_list(t) for t in get_args(type_)) + return False + + def convert_query_params( query_params: ImmutableMultiDict, model: Type[BaseModel] ) -> dict: @@ -19,6 +33,7 @@ def convert_query_params( **{ key: value for key, value in query_params.to_dict(flat=False).items() - if key in model.__fields__ and model.__fields__[key].is_complex() + if key in model.model_fields + and _is_list(model.model_fields[key].annotation) }, } diff --git a/requirements/base.pip b/requirements/base.pip index d59a468..7bd5feb 100644 --- a/requirements/base.pip +++ b/requirements/base.pip @@ -1,2 +1,3 @@ Flask pydantic>=2.0 +typing_extensions>=4.1.1; python_version < '3.8' From d88882499aa7684ab941f1449a15a69abd350009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jouni=20K=2E=20Sepp=C3=A4nen?= Date: Thu, 3 Aug 2023 20:12:20 +0300 Subject: [PATCH 5/9] Fix various test outputs --- tests/func/test_app.py | 46 ++++++++++++++------- tests/unit/test_core.py | 92 ++++++++++++++++++++++++++++++----------- 2 files changed, 98 insertions(+), 40 deletions(-) diff --git a/tests/func/test_app.py b/tests/func/test_app.py index 0e9d974..ae3d070 100644 --- a/tests/func/test_app.py +++ b/tests/func/test_app.py @@ -153,11 +153,13 @@ def compute(query: RequestModel): "validation_error": { "query_params": [ { + "input": "limit", "loc": ["limit"], - "msg": "value is not a valid integer", - "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/v/int_parsing", } - ] + ], } }, id="invalid limit", @@ -170,11 +172,13 @@ def compute(query: RequestModel): "validation_error": { "body_params": [ { + "input": {}, "loc": ["search_term"], - "msg": "field required", - "type": "value_error.missing", + "msg": "Field required", + "type": "missing", + "url": "https://errors.pydantic.dev/2.1/v/missing", } - ] + ], } }, id="missing required body parameter", @@ -210,9 +214,11 @@ def compute(query: RequestModel): "validation_error": { "form_params": [ { + "input": {}, "loc": ["search_term"], - "msg": "field required", - "type": "value_error.missing", + "msg": "Field required", + "type": "missing", + "url": "https://errors.pydantic.dev/2.1/v/missing", } ] } @@ -309,9 +315,11 @@ def test_no_param_raises(self, client): "validation_error": { "query_params": [ { + "input": {}, "loc": ["arr1"], - "msg": "field required", - "type": "value_error.missing", + "msg": "Field required", + "type": "missing", + "url": "https://errors.pydantic.dev/2.1/v/missing", } ] } @@ -357,9 +365,11 @@ def test_string_parameter(self, client): "validation_error": { "path_params": [ { + "input": "not_an_int", "loc": ["obj_id"], - "msg": "value is not a valid integer", - "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/v/int_parsing", } ] } @@ -407,9 +417,11 @@ def test_silent(self, client): "validation_error": { "body_params": [ { + "input": {}, "loc": ["param"], - "msg": "field required", - "type": "value_error.missing", + "msg": "Field required", + "type": "missing", + "url": "https://errors.pydantic.dev/2.1/v/missing", } ] } @@ -425,9 +437,11 @@ def test_silent(self, client): assert response.json["title"] == "validation error" assert response.json["body"] == [ { + "input": {}, "loc": ["param"], - "msg": "field required", - "type": "value_error.missing", + "msg": "Field required", + "type": "missing", + "url": "https://errors.pydantic.dev/2.1/v/missing", } ] assert response.status_code == 422 diff --git a/tests/unit/test_core.py b/tests/unit/test_core.py index 2d26ee7..b925919 100644 --- a/tests/unit/test_core.py +++ b/tests/unit/test_core.py @@ -89,9 +89,11 @@ class RequestBodyModelRoot(RootModel): "validation_error": { "query_params": [ { + "input": {}, "loc": ["q1"], - "msg": "field required", - "type": "value_error.missing", + "msg": "Field required", + "type": "missing", + "url": "https://errors.pydantic.dev/2.1/v/missing", } ] } @@ -126,9 +128,11 @@ class RequestBodyModelRoot(RootModel): "validation_error": { "body_params": [ { + "input": {}, "loc": ["b1"], - "msg": "field required", - "type": "value_error.missing", + "msg": "Field required", + "type": "missing", + "url": "https://errors.pydantic.dev/2.1/v/missing", } ] } @@ -144,9 +148,11 @@ class RequestBodyModelRoot(RootModel): "validation_error": { "body_params": [ { + "input": {}, "loc": ["b1"], - "msg": "field required", - "type": "value_error.missing", + "msg": "Field required", + "type": "missing", + "url": "https://errors.pydantic.dev/2.1/v/missing", } ] } @@ -165,9 +171,11 @@ class RequestBodyModelRoot(RootModel): "validation_error": { "form_params": [ { + "input": {}, "loc": ["f1"], - "msg": "field required", - "type": "value_error.missing", + "msg": "Field required", + "type": "missing", + "url": "https://errors.pydantic.dev/2.1/v/missing", } ] } @@ -368,14 +376,25 @@ def test_invalid_body_model_root(self, request_ctx, mocker): body_model = RequestBodyModelRoot response = validate(body_model)(lambda x: x)() assert response.status_code == 400 + assert response.json == { "validation_error": { "body_params": [ { - "loc": ["__root__"], - "msg": "none is not an allowed value", - "type": "type_error.none.not_allowed", - } + "input": None, + "loc": ["str"], + "msg": "Input should be a valid string", + "type": "string_type", + "url": "https://errors.pydantic.dev/2.1/v/string_type", + }, + { + "ctx": {"class_name": "RequestBodyModel"}, + "input": None, + "loc": ["RequestBodyModel"], + "msg": "Input should be a valid dictionary or instance of RequestBodyModel", + "type": "model_type", + "url": "https://errors.pydantic.dev/2.1/v/model_type", + }, ] } } @@ -453,14 +472,25 @@ def test_fail_validation_custom_status_code(self, app, request_ctx, mocker): body_model = RequestBodyModelRoot response = validate(body_model)(lambda x: x)() assert response.status_code == 422 + assert response.json == { "validation_error": { "body_params": [ { - "loc": ["__root__"], - "msg": "none is not an allowed value", - "type": "type_error.none.not_allowed", - } + "input": None, + "loc": ["str"], + "msg": "Input should be a valid string", + "type": "string_type", + "url": "https://errors.pydantic.dev/2.1/v/string_type", + }, + { + "ctx": {"class_name": "RequestBodyModel"}, + "input": None, + "loc": ["RequestBodyModel"], + "msg": "Input should be a valid dictionary or instance of RequestBodyModel", + "type": "model_type", + "url": "https://errors.pydantic.dev/2.1/v/model_type", + }, ] } } @@ -476,10 +506,20 @@ def test_body_fail_validation_raise_exception(self, app, request_ctx, mocker): validate(body_model)(lambda x: x)() assert excinfo.value.body_params == [ { - "loc": ("__root__",), - "msg": "none is not an allowed value", - "type": "type_error.none.not_allowed", - } + "input": None, + "loc": ("str",), + "msg": "Input should be a valid string", + "type": "string_type", + "url": "https://errors.pydantic.dev/2.1/v/string_type", + }, + { + "ctx": {"class_name": "RequestBodyModel"}, + "input": None, + "loc": ("RequestBodyModel",), + "msg": "Input should be a valid dictionary or instance of RequestBodyModel", + "type": "model_type", + "url": "https://errors.pydantic.dev/2.1/v/model_type", + }, ] def test_query_fail_validation_raise_exception(self, app, request_ctx, mocker): @@ -493,9 +533,11 @@ def test_query_fail_validation_raise_exception(self, app, request_ctx, mocker): validate(query=query_model)(lambda x: x)() assert excinfo.value.query_params == [ { + "input": {}, "loc": ("q1",), - "msg": "field required", - "type": "value_error.missing", + "msg": "Field required", + "type": "missing", + "url": "https://errors.pydantic.dev/2.1/v/missing", } ] @@ -510,9 +552,11 @@ def test_form_fail_validation_raise_exception(self, app, request_ctx, mocker): validate(form=form_model)(lambda x: x)() assert excinfo.value.form_params == [ { + "input": {}, "loc": ("f1",), - "msg": "field required", - "type": "value_error.missing", + "msg": "Field required", + "type": "missing", + "url": "https://errors.pydantic.dev/2.1/v/missing", } ] From 345c790600e2031f1a9fbe32f5b06ab6f4d1d03e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jouni=20K=2E=20Sepp=C3=A4nen?= Date: Thu, 3 Aug 2023 20:25:25 +0300 Subject: [PATCH 6/9] Update documentation and examples --- HISTORY.md | 4 ++++ README.md | 13 +++++++------ example_app/app.py | 10 +++++----- example_app/example.py | 8 ++++---- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 0f6e873..930ebdd 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,9 @@ # Release history +## next release +### Features +- Support Pydantic 2. Drop support for Pydantic 1. + ## 0.11.0 (2022-09-25) ### Features - Allow raising `flask_pydantic.ValidationError` by setting `FLASK_PYDANTIC_VALIDATION_ERROR_RAISE=True` diff --git a/README.md b/README.md index cb585b8..ca8db87 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ class ResponseModel(BaseModel): id: int age: int name: str - nickname: Optional[str] + nickname: Optional[str] = None # Example 1: query parameters only @app.route("/", methods=["GET"]) @@ -155,7 +155,7 @@ def get_character(character_id: int): ```python class RequestBodyModel(BaseModel): name: str - nickname: Optional[str] + nickname: Optional[str] = None # Example2: request body only @app.route("/", methods=["POST"]) @@ -198,7 +198,7 @@ def get_and_post(body: RequestBodyModel,query: QueryModel): ```python class RequestFormDataModel(BaseModel): name: str - nickname: Optional[str] + nickname: Optional[str] = None # Example2: request body only @app.route("/", methods=["POST"]) @@ -282,9 +282,10 @@ def modify_key(text: str) -> str: class MyModel(BaseModel): ... - class Config: - alias_generator = modify_key - allow_population_by_field_name = True + model_config = ConfigDict( + alias_generator=modify_key, + populate_by_name=True + ) ``` diff --git a/example_app/app.py b/example_app/app.py index f1be9e5..be7b4aa 100644 --- a/example_app/app.py +++ b/example_app/app.py @@ -26,19 +26,19 @@ class IndexParam(BaseModel): class BodyModel(BaseModel): name: str - nickname: Optional[str] + nickname: Optional[str] = None class FormModel(BaseModel): name: str - nickname: Optional[str] + nickname: Optional[str] = None class ResponseModel(BaseModel): id: int age: int name: str - nickname: Optional[str] + nickname: Optional[str] = None @app.route("/", methods=["POST"]) @@ -60,7 +60,7 @@ def post(): @app.route("/form", methods=["POST"]) @validate(form=FormModel, query=QueryModel) -def post(): +def post2(): """ Basic example with both query and form-data parameters, response object serialization. """ @@ -90,7 +90,7 @@ def post_kwargs(body: BodyModel, query: QueryModel): @app.route("/form/kwargs", methods=["POST"]) @validate() -def post_kwargs(form: FormModel, query: QueryModel): +def post_kwargs2(form: FormModel, query: QueryModel): """ Basic example with both query and form-data parameters, response object serialization. This time using the decorated function kwargs `form` and `query` type hinting diff --git a/example_app/example.py b/example_app/example.py index 87a6b3a..0f7a9f2 100644 --- a/example_app/example.py +++ b/example_app/example.py @@ -9,7 +9,7 @@ class RequestBodyModel(BaseModel): name: str - nickname: Optional[str] + nickname: Optional[str] = None class QueryModel(BaseModel): @@ -18,7 +18,7 @@ class QueryModel(BaseModel): class FormModel(BaseModel): name: str - nickname: Optional[str] + nickname: Optional[str] = None @app.route("/", methods=["GET"]) @@ -41,7 +41,7 @@ class ResponseModel(BaseModel): id: int age: int name: str - nickname: Optional[str] + nickname: Optional[str] = None @app.route("/character//", methods=["GET"]) @@ -92,7 +92,7 @@ def post(body: RequestBodyModel): @app.route("/form", methods=["POST"]) @validate() -def post(form: FormModel): +def post2(form: FormModel): name = form.name nickname = form.nickname return ResponseModel(name=name, nickname=nickname, id=0, age=1000) From 4791c3003667a3f9d25b00ab5f8638224c6c9f31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jouni=20K=2E=20Sepp=C3=A4nen?= Date: Mon, 25 Sep 2023 16:55:15 +0300 Subject: [PATCH 7/9] More descriptive names for the form-based example functions. Co-authored-by: Aaron Deadman --- example_app/app.py | 4 ++-- example_app/example.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/example_app/app.py b/example_app/app.py index be7b4aa..076b6c1 100644 --- a/example_app/app.py +++ b/example_app/app.py @@ -60,7 +60,7 @@ def post(): @app.route("/form", methods=["POST"]) @validate(form=FormModel, query=QueryModel) -def post2(): +def form_post(): """ Basic example with both query and form-data parameters, response object serialization. """ @@ -90,7 +90,7 @@ def post_kwargs(body: BodyModel, query: QueryModel): @app.route("/form/kwargs", methods=["POST"]) @validate() -def post_kwargs2(form: FormModel, query: QueryModel): +def form_post_kwargs(form: FormModel, query: QueryModel): """ Basic example with both query and form-data parameters, response object serialization. This time using the decorated function kwargs `form` and `query` type hinting diff --git a/example_app/example.py b/example_app/example.py index 0f7a9f2..d346a29 100644 --- a/example_app/example.py +++ b/example_app/example.py @@ -92,7 +92,7 @@ def post(body: RequestBodyModel): @app.route("/form", methods=["POST"]) @validate() -def post2(form: FormModel): +def form_post(form: FormModel): name = form.name nickname = form.nickname return ResponseModel(name=name, nickname=nickname, id=0, age=1000) From a460a282ee140085f2236f536caf545916e84efc Mon Sep 17 00:00:00 2001 From: Jouni Seppanen Date: Sun, 7 Jan 2024 16:56:00 +0200 Subject: [PATCH 8/9] Don't require exact errors in tests Pydantic errors now include a versioned URL so every minor upgrade changes the message. Introduce a utility function that matches results recursively, matching with regular expressions where specified. --- tests/func/test_app.py | 117 +++++++++++++--------- tests/unit/test_core.py | 211 +++++++++++++++++++++++----------------- tests/util.py | 31 ++++++ 3 files changed, 224 insertions(+), 135 deletions(-) create mode 100644 tests/util.py diff --git a/tests/func/test_app.py b/tests/func/test_app.py index ae3d070..e38e413 100644 --- a/tests/func/test_app.py +++ b/tests/func/test_app.py @@ -1,3 +1,5 @@ +from ..util import assert_matches +import re from typing import List, Optional import pytest @@ -157,7 +159,9 @@ def compute(query: RequestModel): "loc": ["limit"], "msg": "Input should be a valid integer, unable to parse string as an integer", "type": "int_parsing", - "url": "https://errors.pydantic.dev/2.1/v/int_parsing", + "url": re.compile( + r"https://errors\.pydantic\.dev/.*/v/int_parsing" + ), } ], } @@ -176,7 +180,9 @@ def compute(query: RequestModel): "loc": ["search_term"], "msg": "Field required", "type": "missing", - "url": "https://errors.pydantic.dev/2.1/v/missing", + "url": re.compile( + r"https://errors\.pydantic\.dev/.*/v/missing" + ), } ], } @@ -218,7 +224,9 @@ def compute(query: RequestModel): "loc": ["search_term"], "msg": "Field required", "type": "missing", - "url": "https://errors.pydantic.dev/2.1/v/missing", + "url": re.compile( + r"https://errors\.pydantic\.dev/.*/v/missing" + ), } ] } @@ -252,13 +260,13 @@ class TestSimple: @pytest.mark.parametrize("query,body,expected_status,expected_response", test_cases) def test_post(self, client, query, body, expected_status, expected_response): response = client.post(f"/search{query}", json=body) - assert response.json == expected_response + assert_matches(expected_response, response.json) assert response.status_code == expected_status @pytest.mark.parametrize("query,body,expected_status,expected_response", test_cases) def test_post_kwargs(self, client, query, body, expected_status, expected_response): response = client.post(f"/search/kwargs{query}", json=body) - assert response.json == expected_response + assert_matches(expected_response, response.json) assert response.status_code == expected_status @pytest.mark.parametrize( @@ -271,7 +279,7 @@ def test_post_kwargs_form( f"/search/form/kwargs{query}", data=form, ) - assert response.json == expected_response + assert_matches(expected_response, response.json) assert response.status_code == expected_status def test_error_status_code(self, app, mocker, client): @@ -311,19 +319,24 @@ def test_custom_headers(client): class TestArrayQueryParam: def test_no_param_raises(self, client): response = client.get("/arr") - assert response.json == { - "validation_error": { - "query_params": [ - { - "input": {}, - "loc": ["arr1"], - "msg": "Field required", - "type": "missing", - "url": "https://errors.pydantic.dev/2.1/v/missing", - } - ] - } - } + assert_matches( + { + "validation_error": { + "query_params": [ + { + "input": {}, + "loc": ["arr1"], + "msg": "Field required", + "type": "missing", + "url": re.compile( + r"https://errors\.pydantic\.dev/.*/v/missing" + ), + } + ] + } + }, + response.json, + ) def test_correctly_returns_first_arr(self, client): response = client.get("/arr?arr1=first&arr1=second") @@ -349,7 +362,7 @@ def test_correctly_returns_both_arrays(self, client): @pytest.mark.parametrize("x,y,expected_result", aliases_test_cases) def test_aliases(x, y, expected_result, client): response = client.get(f"/compute?x={x}&y={y}") - assert response.json == expected_result + assert_matches(expected_result, response.json) @pytest.mark.usefixtures("app_with_int_path_param_route") @@ -358,7 +371,7 @@ def test_correct_param_passes(self, client): id_ = 12 expected_response = {"id": id_} response = client.get(f"/path_param/{id_}/") - assert response.json == expected_response + assert_matches(expected_response, response.json) def test_string_parameter(self, client): expected_response = { @@ -369,14 +382,16 @@ def test_string_parameter(self, client): "loc": ["obj_id"], "msg": "Input should be a valid integer, unable to parse string as an integer", "type": "int_parsing", - "url": "https://errors.pydantic.dev/2.1/v/int_parsing", + "url": re.compile( + r"https://errors\.pydantic\.dev/.*/v/int_parsing" + ), } ] } } response = client.get("/path_param/not_an_int/") - assert response.json == expected_response + assert_matches(expected_response, response.json) assert response.status_code == 400 @@ -387,14 +402,14 @@ def test_int_str_param_passes(self, client): expected_response = {"id": str(id_)} response = client.get(f"/path_param/{id_}/") - assert response.json == expected_response + assert_matches(expected_response, response.json) def test_str_param_passes(self, client): id_ = "twelve" expected_response = {"id": id_} response = client.get(f"/path_param/{id_}/") - assert response.json == expected_response + assert_matches(expected_response, response.json) @pytest.mark.usefixtures("app_with_optional_body") @@ -413,19 +428,24 @@ def test_empty_body_fails(self, client): def test_silent(self, client): response = client.post("/silent", headers={"Content-Type": "application/json"}) - assert response.json == { - "validation_error": { - "body_params": [ - { - "input": {}, - "loc": ["param"], - "msg": "Field required", - "type": "missing", - "url": "https://errors.pydantic.dev/2.1/v/missing", - } - ] - } - } + assert_matches( + { + "validation_error": { + "body_params": [ + { + "input": {}, + "loc": ["param"], + "msg": "Field required", + "type": "missing", + "url": re.compile( + r"https://errors\.pydantic\.dev/.*/v/missing" + ), + } + ] + } + }, + response.json, + ) assert response.status_code == 400 @@ -435,13 +455,16 @@ def test_silent(self, client): response = client.post("/silent", headers={"Content-Type": "application/json"}) assert response.json["title"] == "validation error" - assert response.json["body"] == [ - { - "input": {}, - "loc": ["param"], - "msg": "Field required", - "type": "missing", - "url": "https://errors.pydantic.dev/2.1/v/missing", - } - ] + assert_matches( + [ + { + "input": {}, + "loc": ["param"], + "msg": "Field required", + "type": "missing", + "url": re.compile(r"https://errors\.pydantic\.dev/.*/v/missing"), + } + ], + response.json["body"], + ) assert response.status_code == 422 diff --git a/tests/unit/test_core.py b/tests/unit/test_core.py index b925919..7a61419 100644 --- a/tests/unit/test_core.py +++ b/tests/unit/test_core.py @@ -1,4 +1,6 @@ +import re from typing import Any, List, NamedTuple, Optional, Type, Union +from ..util import assert_matches import pytest from flask import jsonify @@ -93,7 +95,9 @@ class RequestBodyModelRoot(RootModel): "loc": ["q1"], "msg": "Field required", "type": "missing", - "url": "https://errors.pydantic.dev/2.1/v/missing", + "url": re.compile( + r"https://errors\.pydantic\.dev/.*/v/missing" + ), } ] } @@ -132,7 +136,9 @@ class RequestBodyModelRoot(RootModel): "loc": ["b1"], "msg": "Field required", "type": "missing", - "url": "https://errors.pydantic.dev/2.1/v/missing", + "url": re.compile( + r"https://errors\.pydantic\.dev/.*/v/missing" + ), } ] } @@ -152,7 +158,9 @@ class RequestBodyModelRoot(RootModel): "loc": ["b1"], "msg": "Field required", "type": "missing", - "url": "https://errors.pydantic.dev/2.1/v/missing", + "url": re.compile( + r"https://errors\.pydantic\.dev/.*/v/missing" + ), } ] } @@ -175,7 +183,9 @@ class RequestBodyModelRoot(RootModel): "loc": ["f1"], "msg": "Field required", "type": "missing", - "url": "https://errors.pydantic.dev/2.1/v/missing", + "url": re.compile( + r"https://errors\.pydantic\.dev/.*/v/missing" + ), } ] } @@ -217,7 +227,7 @@ def f(): )(f)() assert response.status_code == parameters.expected_status_code - assert response.json == parameters.expected_response_body + assert_matches(parameters.expected_response_body, response.json) if 200 <= response.status_code < 300: assert ( mock_request.body_params.model_dump( @@ -255,7 +265,7 @@ def f( request_body_many=parameters.request_body_many, )(f)() - assert response.json == parameters.expected_response_body + assert_matches(parameters.expected_response_body, response.json) assert response.status_code == parameters.expected_status_code if 200 <= response.status_code < 300: assert ( @@ -281,7 +291,7 @@ def f(): response = validate()(f)() assert response.status_code == expected_status_code - assert response.json == expected_response_body + assert_matches(expected_response_body, response.json) @pytest.mark.usefixtures("request_ctx") def test_response_already_response(self): @@ -291,7 +301,7 @@ def f(): return jsonify(expected_response_body) response = validate()(f)() - assert response.json == expected_response_body + assert_matches(expected_response_body, response.json) @pytest.mark.usefixtures("request_ctx") def test_response_many_response_objs(self): @@ -310,7 +320,7 @@ def f(): return response_content response = validate(exclude_none=True, response_many=True)(f)() - assert response.json == expected_response_body + assert_matches(expected_response_body, response.json) @pytest.mark.usefixtures("request_ctx") def test_invalid_many_raises(self): @@ -353,7 +363,7 @@ def f(): )(f)() assert response.status_code == 200 - assert response.json == expected_response_body + assert_matches(expected_response_body, response.json) def test_unsupported_media_type(self, request_ctx, mocker): mock_request = mocker.patch.object(request_ctx, "request") @@ -377,27 +387,34 @@ def test_invalid_body_model_root(self, request_ctx, mocker): response = validate(body_model)(lambda x: x)() assert response.status_code == 400 - assert response.json == { - "validation_error": { - "body_params": [ - { - "input": None, - "loc": ["str"], - "msg": "Input should be a valid string", - "type": "string_type", - "url": "https://errors.pydantic.dev/2.1/v/string_type", - }, - { - "ctx": {"class_name": "RequestBodyModel"}, - "input": None, - "loc": ["RequestBodyModel"], - "msg": "Input should be a valid dictionary or instance of RequestBodyModel", - "type": "model_type", - "url": "https://errors.pydantic.dev/2.1/v/model_type", - }, - ] - } - } + assert_matches( + { + "validation_error": { + "body_params": [ + { + "input": None, + "loc": ["str"], + "msg": "Input should be a valid string", + "type": "string_type", + "url": re.compile( + r"https://errors\.pydantic\.dev/.*/v/string_type" + ), + }, + { + "ctx": {"class_name": "RequestBodyModel"}, + "input": None, + "loc": ["RequestBodyModel"], + "msg": "Input should be a valid dictionary or instance of RequestBodyModel", + "type": "model_type", + "url": re.compile( + r"https://errors\.pydantic\.dev/.*/v/model_type" + ), + }, + ] + } + }, + response.json, + ) def test_damaged_request_body_json_with_charset(self, request_ctx, mocker): mock_request = mocker.patch.object(request_ctx, "request") @@ -448,7 +465,7 @@ def f() -> Any: )(f)() assert response.status_code == parameters.expected_status_code - assert response.json == parameters.expected_response_body + assert_matches(parameters.expected_response_body, response.json) if 200 <= response.status_code < 300: assert ( mock_request.body_params.model_dump( @@ -473,27 +490,34 @@ def test_fail_validation_custom_status_code(self, app, request_ctx, mocker): response = validate(body_model)(lambda x: x)() assert response.status_code == 422 - assert response.json == { - "validation_error": { - "body_params": [ - { - "input": None, - "loc": ["str"], - "msg": "Input should be a valid string", - "type": "string_type", - "url": "https://errors.pydantic.dev/2.1/v/string_type", - }, - { - "ctx": {"class_name": "RequestBodyModel"}, - "input": None, - "loc": ["RequestBodyModel"], - "msg": "Input should be a valid dictionary or instance of RequestBodyModel", - "type": "model_type", - "url": "https://errors.pydantic.dev/2.1/v/model_type", - }, - ] - } - } + assert_matches( + { + "validation_error": { + "body_params": [ + { + "input": None, + "loc": ["str"], + "msg": "Input should be a valid string", + "type": "string_type", + "url": re.compile( + r"https://errors\.pydantic\.dev/.*/v/string_type" + ), + }, + { + "ctx": {"class_name": "RequestBodyModel"}, + "input": None, + "loc": ["RequestBodyModel"], + "msg": "Input should be a valid dictionary or instance of RequestBodyModel", + "type": "model_type", + "url": re.compile( + r"https://errors\.pydantic\.dev/.*/v/model_type" + ), + }, + ] + } + }, + response.json, + ) def test_body_fail_validation_raise_exception(self, app, request_ctx, mocker): app.config["FLASK_PYDANTIC_VALIDATION_ERROR_RAISE"] = True @@ -504,23 +528,28 @@ def test_body_fail_validation_raise_exception(self, app, request_ctx, mocker): body_model = RequestBodyModelRoot with pytest.raises(ValidationError) as excinfo: validate(body_model)(lambda x: x)() - assert excinfo.value.body_params == [ - { - "input": None, - "loc": ("str",), - "msg": "Input should be a valid string", - "type": "string_type", - "url": "https://errors.pydantic.dev/2.1/v/string_type", - }, - { - "ctx": {"class_name": "RequestBodyModel"}, - "input": None, - "loc": ("RequestBodyModel",), - "msg": "Input should be a valid dictionary or instance of RequestBodyModel", - "type": "model_type", - "url": "https://errors.pydantic.dev/2.1/v/model_type", - }, - ] + assert_matches( + [ + { + "input": None, + "loc": ("str",), + "msg": "Input should be a valid string", + "type": "string_type", + "url": re.compile( + r"https://errors\.pydantic\.dev/.*/v/string_type" + ), + }, + { + "ctx": {"class_name": "RequestBodyModel"}, + "input": None, + "loc": ("RequestBodyModel",), + "msg": "Input should be a valid dictionary or instance of RequestBodyModel", + "type": "model_type", + "url": re.compile(r"https://errors\.pydantic\.dev/.*/v/model_type"), + }, + ], + excinfo.value.body_params, + ) def test_query_fail_validation_raise_exception(self, app, request_ctx, mocker): app.config["FLASK_PYDANTIC_VALIDATION_ERROR_RAISE"] = True @@ -531,15 +560,18 @@ def test_query_fail_validation_raise_exception(self, app, request_ctx, mocker): query_model = QueryModel with pytest.raises(ValidationError) as excinfo: validate(query=query_model)(lambda x: x)() - assert excinfo.value.query_params == [ - { - "input": {}, - "loc": ("q1",), - "msg": "Field required", - "type": "missing", - "url": "https://errors.pydantic.dev/2.1/v/missing", - } - ] + assert_matches( + [ + { + "input": {}, + "loc": ("q1",), + "msg": "Field required", + "type": "missing", + "url": re.compile(r"https://errors\.pydantic\.dev/.*/v/missing"), + } + ], + excinfo.value.query_params, + ) def test_form_fail_validation_raise_exception(self, app, request_ctx, mocker): app.config["FLASK_PYDANTIC_VALIDATION_ERROR_RAISE"] = True @@ -550,15 +582,18 @@ def test_form_fail_validation_raise_exception(self, app, request_ctx, mocker): form_model = FormModel with pytest.raises(ValidationError) as excinfo: validate(form=form_model)(lambda x: x)() - assert excinfo.value.form_params == [ - { - "input": {}, - "loc": ("f1",), - "msg": "Field required", - "type": "missing", - "url": "https://errors.pydantic.dev/2.1/v/missing", - } - ] + assert_matches( + [ + { + "input": {}, + "loc": ("f1",), + "msg": "Field required", + "type": "missing", + "url": re.compile(r"https://errors\.pydantic\.dev/.*/v/missing"), + } + ], + excinfo.value.form_params, + ) class TestIsIterableOfModels: diff --git a/tests/util.py b/tests/util.py new file mode 100644 index 0000000..9ef1b28 --- /dev/null +++ b/tests/util.py @@ -0,0 +1,31 @@ +import re +from typing import Dict, List, Union + +ExpectedType = Union[re.Pattern, str, List["ExpectedType"], Dict[str, "ExpectedType"]] +ActualType = Union[str, List["ActualType"], Dict[str, "ActualType"]] + + +def assert_matches(expected: ExpectedType, actual: ActualType): + """ + Recursively compare the expected and actual values. + + Args: + expected: The expected value. If this is a compiled regex, + it will be matched against the actual value. + actual: The actual value. + + Raises: + AssertionError: If the expected and actual values do not match. + """ + if isinstance(expected, dict): + assert set(expected.keys()) == set(actual.keys()) + for key, value in expected.items(): + assert_matches(value, actual[key]) + elif isinstance(expected, list): + assert len(expected) == len(actual) + for a, b in zip(expected, actual): + assert_matches(a, b) + elif isinstance(expected, re.Pattern): + assert expected.match(actual) + else: + assert expected == actual From 333bf2a49093bef6218b3861de7a71a2ac1ececb Mon Sep 17 00:00:00 2001 From: Jouni Seppanen Date: Sun, 7 Jan 2024 17:09:52 +0200 Subject: [PATCH 9/9] Add our own request_ctx implementation Removed from recent versions of pytest-flask Co-authored-by: yctomwang --- tests/conftest.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index fc02ca4..83729a5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -62,6 +62,12 @@ class Response(BaseModel): return Response +@pytest.fixture +def request_ctx(app): + with app.test_request_context() as ctx: + yield ctx + + def is_excluded(post: dict, exclude: Optional[str] = None) -> bool: if exclude is None: return False