From c29340ce4d45575bd5d34e2e58be9f1392fff976 Mon Sep 17 00:00:00 2001 From: chbndrhnns Date: Fri, 8 Sep 2023 14:22:31 +0200 Subject: [PATCH 01/10] Failing test for v1.BaseModel --- tests/test_response_model_v1.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/test_response_model_v1.py diff --git a/tests/test_response_model_v1.py b/tests/test_response_model_v1.py new file mode 100644 index 000000000..d5c5ca195 --- /dev/null +++ b/tests/test_response_model_v1.py @@ -0,0 +1,25 @@ +from typing import List + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pydantic import BaseModel, v1 + + +class Model(v1.BaseModel): + name: str + + +app = FastAPI() + + +@app.get("/valid", response_model=Model) +def valid1(): + pass + + +client = TestClient(app) + + +def test_path_operations(): + response = client.get("/valid") + assert response.status_code == 200, response.text From b9d2e9b6a13460832802084a4ad7a134c63ccf20 Mon Sep 17 00:00:00 2001 From: chbndrhnns Date: Fri, 8 Sep 2023 17:38:06 +0200 Subject: [PATCH 02/10] Allow v1.BaseModels to be used when pydantic v2 is installed --- fastapi/_compat.py | 68 ++++++++++++++++++++------- tests/test_pydantic_v1_models.py | 80 ++++++++++++++++++++++++++++++++ tests/test_response_model_v1.py | 25 ---------- 3 files changed, 131 insertions(+), 42 deletions(-) create mode 100644 tests/test_pydantic_v1_models.py delete mode 100644 tests/test_response_model_v1.py diff --git a/fastapi/_compat.py b/fastapi/_compat.py index eb55b08f2..daa8abbfa 100644 --- a/fastapi/_compat.py +++ b/fastapi/_compat.py @@ -19,14 +19,13 @@ from typing import ( from fastapi.exceptions import RequestErrorModel from fastapi.types import IncEx, ModelNameMap, UnionType -from pydantic import BaseModel, create_model +from pydantic import BaseModel, PydanticDeprecatedSince20, create_model, v1 from pydantic.version import VERSION as PYDANTIC_VERSION from starlette.datastructures import UploadFile from typing_extensions import Annotated, Literal, get_args, get_origin PYDANTIC_V2 = PYDANTIC_VERSION.startswith("2.") - sequence_annotation_to_type = { Sequence: list, List: list, @@ -98,9 +97,12 @@ if PYDANTIC_V2: return self.field_info.annotation def __post_init__(self) -> None: - self._type_adapter: TypeAdapter[Any] = TypeAdapter( - Annotated[self.field_info.annotation, self.field_info] - ) + try: + self._type_adapter: TypeAdapter[Any] = TypeAdapter( + Annotated[self.field_info.annotation, self.field_info] + ) + except PydanticDeprecatedSince20: + pass def get_default(self) -> Any: if self.field_info.is_required(): @@ -123,6 +125,14 @@ if PYDANTIC_V2: return None, _regenerate_error_with_loc( errors=exc.errors(), loc_prefix=loc ) + except AttributeError: + # pydantic v1 + try: + return v1.parse_obj_as(self.type_, value), None + except v1.ValidationError as exc: + return None, _regenerate_error_with_loc( + errors=exc.errors(), loc_prefix=loc + ) def serialize( self, @@ -136,18 +146,42 @@ if PYDANTIC_V2: exclude_defaults: bool = False, exclude_none: bool = False, ) -> Any: - # What calls this code passes a value that already called - # self._type_adapter.validate_python(value) - return self._type_adapter.dump_python( - value, - mode=mode, - include=include, - exclude=exclude, - by_alias=by_alias, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, - exclude_none=exclude_none, - ) + try: + # What calls this code passes a value that already called + # self._type_adapter.validate_python(value) + return self._type_adapter.dump_python( + value, + mode=mode, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + except AttributeError: + # pydantic v1 + try: + return value.dict( + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + except AttributeError: + return [ + item.dict( + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + for item in value + ] def __hash__(self) -> int: # Each ModelField is unique for our purposes, to allow making a dict from diff --git a/tests/test_pydantic_v1_models.py b/tests/test_pydantic_v1_models.py new file mode 100644 index 000000000..e3af655f4 --- /dev/null +++ b/tests/test_pydantic_v1_models.py @@ -0,0 +1,80 @@ +from typing import Annotated + +import pytest +from fastapi import Body, FastAPI +from fastapi.exceptions import ResponseValidationError +from fastapi.testclient import TestClient +from pydantic import v1 + + +class Item(v1.BaseModel): + name: str + description: str | None = None + price: float + tax: float | None = None + tags: list = [] + + +class Model(v1.BaseModel): + name: str + + +app = FastAPI() + + +@app.post("/request_body") +async def request_body(body: Annotated[Item, Body()]): + return body + + +@app.get("/response_model", response_model=Model) +async def response_model(): + return Model(name="valid_model") + + +@app.get("/response_model__invalid", response_model=Model) +async def response_model__invalid(): + return 1 + + +@app.get("/response_model_list", response_model=list[Model]) +async def response_model_list(): + return [Model(name="valid_model")] + + +@app.get("/response_model_list__invalid", response_model=list[Model]) +async def response_model_list__invalid(): + return [1] + + +client = TestClient(app) + + +class TestResponseModel: + def test_simple__valid(self): + response = client.get("/response_model") + assert response.status_code == 200 + assert response.json() == {"name": "valid_model"} + + def test_simple__invalid(self): + with pytest.raises(ResponseValidationError): + client.get("/response_model__invalid") + + def test_list__valid(self): + response = client.get("/response_model_list") + assert response.status_code == 200 + assert response.json() == [{"name": "valid_model"}] + + def test_list__invalid(self): + with pytest.raises(ResponseValidationError): + client.get("/response_model_list__invalid") + + +class TestRequestBody: + def test_model__valid(self): + response = client.post("/request_body", json={"name": "myname", "price": 1.0}) + assert response.status_code == 200, response.text + + def test_model__invalid(self): + response = client.post("/request_body", json={"name": "myname"}) + assert response.status_code == 422, response.text diff --git a/tests/test_response_model_v1.py b/tests/test_response_model_v1.py deleted file mode 100644 index d5c5ca195..000000000 --- a/tests/test_response_model_v1.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import List - -from fastapi import FastAPI -from fastapi.testclient import TestClient -from pydantic import BaseModel, v1 - - -class Model(v1.BaseModel): - name: str - - -app = FastAPI() - - -@app.get("/valid", response_model=Model) -def valid1(): - pass - - -client = TestClient(app) - - -def test_path_operations(): - response = client.get("/valid") - assert response.status_code == 200, response.text From 9fda7849e18c439f1d29f635d69e6af33debdc5a Mon Sep 17 00:00:00 2001 From: chbndrhnns Date: Fri, 8 Sep 2023 17:45:05 +0200 Subject: [PATCH 03/10] Inline imports which only exist in v2 --- fastapi/_compat.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fastapi/_compat.py b/fastapi/_compat.py index daa8abbfa..64200c685 100644 --- a/fastapi/_compat.py +++ b/fastapi/_compat.py @@ -19,7 +19,7 @@ from typing import ( from fastapi.exceptions import RequestErrorModel from fastapi.types import IncEx, ModelNameMap, UnionType -from pydantic import BaseModel, PydanticDeprecatedSince20, create_model, v1 +from pydantic import BaseModel, create_model from pydantic.version import VERSION as PYDANTIC_VERSION from starlette.datastructures import UploadFile from typing_extensions import Annotated, Literal, get_args, get_origin @@ -97,6 +97,7 @@ if PYDANTIC_V2: return self.field_info.annotation def __post_init__(self) -> None: + from pydantic import PydanticDeprecatedSince20 try: self._type_adapter: TypeAdapter[Any] = TypeAdapter( Annotated[self.field_info.annotation, self.field_info] @@ -127,6 +128,7 @@ if PYDANTIC_V2: ) except AttributeError: # pydantic v1 + from pydantic import v1 try: return v1.parse_obj_as(self.type_, value), None except v1.ValidationError as exc: From a26d4cafa2cfdc39398354282c4483d908371bd3 Mon Sep 17 00:00:00 2001 From: chbndrhnns Date: Fri, 8 Sep 2023 17:45:35 +0200 Subject: [PATCH 04/10] Black --- fastapi/_compat.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fastapi/_compat.py b/fastapi/_compat.py index 64200c685..928f0b1f8 100644 --- a/fastapi/_compat.py +++ b/fastapi/_compat.py @@ -98,6 +98,7 @@ if PYDANTIC_V2: def __post_init__(self) -> None: from pydantic import PydanticDeprecatedSince20 + try: self._type_adapter: TypeAdapter[Any] = TypeAdapter( Annotated[self.field_info.annotation, self.field_info] @@ -129,6 +130,7 @@ if PYDANTIC_V2: except AttributeError: # pydantic v1 from pydantic import v1 + try: return v1.parse_obj_as(self.type_, value), None except v1.ValidationError as exc: From 16a8e8dc25b347066d91c6e83b51ea8794b60403 Mon Sep 17 00:00:00 2001 From: chbndrhnns Date: Fri, 8 Sep 2023 18:12:40 +0200 Subject: [PATCH 05/10] Require pydantic v2 for tests --- tests/test_pydantic_v1_models.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_pydantic_v1_models.py b/tests/test_pydantic_v1_models.py index e3af655f4..825bacd2c 100644 --- a/tests/test_pydantic_v1_models.py +++ b/tests/test_pydantic_v1_models.py @@ -6,6 +6,8 @@ from fastapi.exceptions import ResponseValidationError from fastapi.testclient import TestClient from pydantic import v1 +from tests.utils import needs_pydanticv2 + class Item(v1.BaseModel): name: str @@ -50,6 +52,7 @@ async def response_model_list__invalid(): client = TestClient(app) +@needs_pydanticv2 class TestResponseModel: def test_simple__valid(self): response = client.get("/response_model") @@ -70,6 +73,7 @@ class TestResponseModel: client.get("/response_model_list__invalid") +@needs_pydanticv2 class TestRequestBody: def test_model__valid(self): response = client.post("/request_body", json={"name": "myname", "price": 1.0}) From 17c7181ed72c21fed294f8f4ec15b63f52a5e877 Mon Sep 17 00:00:00 2001 From: chbndrhnns Date: Fri, 8 Sep 2023 18:15:17 +0200 Subject: [PATCH 06/10] Import Annotated from typing_extensions --- tests/test_pydantic_v1_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_pydantic_v1_models.py b/tests/test_pydantic_v1_models.py index 825bacd2c..e433e63c9 100644 --- a/tests/test_pydantic_v1_models.py +++ b/tests/test_pydantic_v1_models.py @@ -1,4 +1,4 @@ -from typing import Annotated +from typing_extensions import Annotated import pytest from fastapi import Body, FastAPI From 1b1346c7243505f1cb2c8c289f7dccf2096d136d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 8 Sep 2023 16:15:56 +0000 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_pydantic_v1_models.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_pydantic_v1_models.py b/tests/test_pydantic_v1_models.py index e433e63c9..35f0195cc 100644 --- a/tests/test_pydantic_v1_models.py +++ b/tests/test_pydantic_v1_models.py @@ -1,10 +1,9 @@ -from typing_extensions import Annotated - import pytest from fastapi import Body, FastAPI from fastapi.exceptions import ResponseValidationError from fastapi.testclient import TestClient from pydantic import v1 +from typing_extensions import Annotated from tests.utils import needs_pydanticv2 From b72c7f04d698d29c4af235c41807d72d05f156dc Mon Sep 17 00:00:00 2001 From: chbndrhnns Date: Fri, 8 Sep 2023 18:19:53 +0200 Subject: [PATCH 08/10] Black --- tests/test_pydantic_v1_models.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/tests/test_pydantic_v1_models.py b/tests/test_pydantic_v1_models.py index 35f0195cc..a719ee4b4 100644 --- a/tests/test_pydantic_v1_models.py +++ b/tests/test_pydantic_v1_models.py @@ -1,23 +1,37 @@ import pytest from fastapi import Body, FastAPI +from fastapi._compat import PYDANTIC_V2 from fastapi.exceptions import ResponseValidationError from fastapi.testclient import TestClient -from pydantic import v1 from typing_extensions import Annotated from tests.utils import needs_pydanticv2 +if PYDANTIC_V2: + from pydantic import v1 -class Item(v1.BaseModel): - name: str - description: str | None = None - price: float - tax: float | None = None - tags: list = [] + class Item(v1.BaseModel): + name: str + description: str | None = None + price: float + tax: float | None = None + tags: list = [] + class Model(v1.BaseModel): + name: str -class Model(v1.BaseModel): - name: str +else: + from pydantic import BaseModel + + class Item(BaseModel): + name: str + description: str | None = None + price: float + tax: float | None = None + tags: list = [] + + class Model(BaseModel): + name: str app = FastAPI() From 4eb2bc418066a61dd9534109da9e3616a65c40b7 Mon Sep 17 00:00:00 2001 From: chbndrhnns Date: Fri, 8 Sep 2023 18:21:40 +0200 Subject: [PATCH 09/10] Use Optional --- tests/test_pydantic_v1_models.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_pydantic_v1_models.py b/tests/test_pydantic_v1_models.py index a719ee4b4..36bbcf146 100644 --- a/tests/test_pydantic_v1_models.py +++ b/tests/test_pydantic_v1_models.py @@ -1,3 +1,5 @@ +from typing import Optional + import pytest from fastapi import Body, FastAPI from fastapi._compat import PYDANTIC_V2 @@ -12,9 +14,9 @@ if PYDANTIC_V2: class Item(v1.BaseModel): name: str - description: str | None = None + description: Optional[str] = None price: float - tax: float | None = None + tax: Optional[float] = None tags: list = [] class Model(v1.BaseModel): @@ -25,9 +27,9 @@ else: class Item(BaseModel): name: str - description: str | None = None + description: Optional[str] = None price: float - tax: float | None = None + tax: Optional[float] = None tags: list = [] class Model(BaseModel): From 7cf32131a2421e8328c1430c2c0d27f209cb66d6 Mon Sep 17 00:00:00 2001 From: chbndrhnns Date: Fri, 8 Sep 2023 18:22:58 +0200 Subject: [PATCH 10/10] Use List instead of list --- tests/test_pydantic_v1_models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_pydantic_v1_models.py b/tests/test_pydantic_v1_models.py index 36bbcf146..2728d9f15 100644 --- a/tests/test_pydantic_v1_models.py +++ b/tests/test_pydantic_v1_models.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import List, Optional import pytest from fastapi import Body, FastAPI @@ -54,12 +54,12 @@ async def response_model__invalid(): return 1 -@app.get("/response_model_list", response_model=list[Model]) +@app.get("/response_model_list", response_model=List[Model]) async def response_model_list(): return [Model(name="valid_model")] -@app.get("/response_model_list__invalid", response_model=list[Model]) +@app.get("/response_model_list__invalid", response_model=List[Model]) async def response_model_list__invalid(): return [1]