Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pydantic v2 test #81

Merged
merged 9 commits into from
Jan 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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
)

```

Expand Down
10 changes: 5 additions & 5 deletions example_app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand All @@ -60,7 +60,7 @@ def post():

@app.route("/form", methods=["POST"])
@validate(form=FormModel, query=QueryModel)
def post():
def form_post():
"""
Basic example with both query and form-data parameters, response object serialization.
"""
Expand Down Expand Up @@ -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 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
Expand Down
8 changes: 4 additions & 4 deletions example_app/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

class RequestBodyModel(BaseModel):
name: str
nickname: Optional[str]
nickname: Optional[str] = None


class QueryModel(BaseModel):
Expand All @@ -18,7 +18,7 @@ class QueryModel(BaseModel):

class FormModel(BaseModel):
name: str
nickname: Optional[str]
nickname: Optional[str] = None


@app.route("/", methods=["GET"])
Expand All @@ -41,7 +41,7 @@ class ResponseModel(BaseModel):
id: int
age: int
name: str
nickname: Optional[str]
nickname: Optional[str] = None


@app.route("/character/<character_id>/", methods=["GET"])
Expand Down Expand Up @@ -92,7 +92,7 @@ def post(body: RequestBodyModel):

@app.route("/form", methods=["POST"])
@validate()
def post(form: FormModel):
def form_post(form: FormModel):
name = form.name
nickname = form.nickname
return ResponseModel(name=name, nickname=nickname, id=0, age=1000)
Expand Down
19 changes: 17 additions & 2 deletions flask_pydantic/converters.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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)
},
}
22 changes: 10 additions & 12 deletions flask_pydantic/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion requirements/base.pip
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
Flask
pydantic>=1.7
pydantic>=2.0
typing_extensions>=4.1.1; python_version < '3.8'
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 14 additions & 5 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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

Expand All @@ -62,14 +62,23 @@ class Response(BaseModel):
return Response


def is_excluded(post: dict, exclude: Optional[str]) -> bool:
@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
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"])
Expand Down
Loading
Loading