From 55071a0239658352cf30b023e3780eb0f0594f01 Mon Sep 17 00:00:00 2001 From: jelmert Date: Mon, 18 Nov 2024 11:24:32 +0100 Subject: [PATCH 1/3] Add coverage TypeAdapter in asend --- tests/test_request_model.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_request_model.py b/tests/test_request_model.py index 1f6fc6c..c78addf 100644 --- a/tests/test_request_model.py +++ b/tests/test_request_model.py @@ -8,6 +8,7 @@ import pytest from fastapi._compat import field_annotation_is_scalar from fastapi._compat import field_annotation_is_sequence +from httpx import AsyncClient from pydantic import BaseModel from pydantic import ValidationError from typeguard import suppress_type_checks @@ -19,6 +20,7 @@ from requestmodel import RequestModel from requestmodel import params from requestmodel.utils import get_annotated_type +from tests.fastapi_server import app from tests.fastapi_server import client from tests.fastapi_server.schema import NameModel from tests.fastapi_server.schema import NameModelList @@ -211,3 +213,15 @@ def test_type_adapter() -> None: response = request.send(client) assert response == [NameModel(name="test")] + + +@suppress_type_checks +@pytest.mark.asyncio +async def test_type_adapter_async() -> None: + request = TypeAdapterRequest() + + async with AsyncClient(app=app, base_url="http://test") as client: + + response = await request.asend(client) + + assert response == [NameModel(name="test")] From c336641183044ec423558ce275761e39d36aff31 Mon Sep 17 00:00:00 2001 From: jelmert Date: Mon, 18 Nov 2024 12:25:54 +0100 Subject: [PATCH 2/3] Type checking against TypeAdapter and BaseModel --- src/requestmodel/adapters/requests.py | 2 +- src/requestmodel/model.py | 25 +++++++++++++++++++------ src/requestmodel/typing.py | 8 +++++++- tests/test_request_model.py | 4 ++-- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/requestmodel/adapters/requests.py b/src/requestmodel/adapters/requests.py index 6c0e427..f77ddcd 100644 --- a/src/requestmodel/adapters/requests.py +++ b/src/requestmodel/adapters/requests.py @@ -50,4 +50,4 @@ def send(self, client: Session) -> ResponseType: r = self.as_request() self.response = client.send(r.prepare()) self.handle_error(self.response) - return self.response_model.model_validate(self.response.json()) + return self.adapt_type(self.response) diff --git a/src/requestmodel/model.py b/src/requestmodel/model.py index b3c7e9e..2442c33 100644 --- a/src/requestmodel/model.py +++ b/src/requestmodel/model.py @@ -1,7 +1,9 @@ +from typing import Any from typing import ClassVar from typing import Generic from typing import Iterator from typing import Optional +from typing import Protocol from typing import Set from typing import Type @@ -13,6 +15,7 @@ from pydantic import BaseModel from pydantic import ConfigDict from pydantic import TypeAdapter +from pydantic._internal._model_construction import ModelMetaclass from typing_extensions import get_type_hints from typing_extensions import override @@ -26,6 +29,10 @@ from .utils import unify_body +class JSONResponse(Protocol): + def json(self) -> Any: ... # noqa: E704 + + class BaseRequestModel(BaseModel, Generic[ResponseType]): """Declarative way to define a model""" @@ -86,6 +93,15 @@ def request_args_for_values(self) -> RequestArgs: return request_args + def adapt_type(self, response: JSONResponse) -> ResponseType: + if isinstance(self.response_model, TypeAdapter): + return self.response_model.validate_python(response.json()) + + if isinstance(self.response_model, (BaseModel, ModelMetaclass)): + return self.response_model.model_validate(response.json()) + + raise ValueError("response_model must be a TypeAdapter or a BaseModel") + class RequestModel(BaseRequestModel[ResponseType]): raw_response: Optional[Response] = None @@ -98,18 +114,15 @@ def send(self, client: Client) -> ResponseType: r = self.as_request(client) self.raw_response = client.send(r) self.handle_error(self.raw_response) - if isinstance(self.response_model, TypeAdapter): - return self.response_model.validate_python(self.raw_response.json()) - return self.response_model.model_validate(self.raw_response.json()) + + return self.adapt_type(self.raw_response) async def asend(self, client: AsyncClient) -> ResponseType: """Send the request asynchronously""" r = self.as_request(client) self.raw_response = await client.send(r) self.handle_error(self.raw_response) - if isinstance(self.response_model, TypeAdapter): - return self.response_model.validate_python(self.raw_response.json()) - return self.response_model.model_validate(self.raw_response.json()) + return self.adapt_type(self.raw_response) def as_request(self, client: BaseClient) -> Request: """Transform the properties of the object into a request""" diff --git a/src/requestmodel/typing.py b/src/requestmodel/typing.py index 561526b..76453a2 100644 --- a/src/requestmodel/typing.py +++ b/src/requestmodel/typing.py @@ -1,11 +1,17 @@ from typing import Any from typing import Dict +from typing import List from typing import Type from typing import TypeVar +from typing import Union from pydantic import BaseModel +from pydantic import TypeAdapter from pydantic.fields import FieldInfo -ResponseType = TypeVar("ResponseType", bound=BaseModel) +ResponseType = TypeVar( + "ResponseType", + bound=Union[BaseModel, TypeAdapter[List[BaseModel]], List[BaseModel]], +) RequestArgs = Dict[Type[FieldInfo], Dict[str, Any]] diff --git a/tests/test_request_model.py b/tests/test_request_model.py index c78addf..80087b2 100644 --- a/tests/test_request_model.py +++ b/tests/test_request_model.py @@ -220,8 +220,8 @@ def test_type_adapter() -> None: async def test_type_adapter_async() -> None: request = TypeAdapterRequest() - async with AsyncClient(app=app, base_url="http://test") as client: + async with AsyncClient(app=app, base_url="http://test", timeout=30) as aclient: - response = await request.asend(client) + response = await request.asend(aclient) assert response == [NameModel(name="test")] From cabb2aa949dcdc9d589ec2a007cd4630d88e3fd3 Mon Sep 17 00:00:00 2001 From: jelmert Date: Mon, 18 Nov 2024 13:09:47 +0100 Subject: [PATCH 3/3] Add coverage for adapt_type --- src/requestmodel/model.py | 2 +- tests/test_request_model.py | 17 ++++++----------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/requestmodel/model.py b/src/requestmodel/model.py index 2442c33..0ba86dc 100644 --- a/src/requestmodel/model.py +++ b/src/requestmodel/model.py @@ -29,7 +29,7 @@ from .utils import unify_body -class JSONResponse(Protocol): +class JSONResponse(Protocol): # pragma: no cover def json(self) -> Any: ... # noqa: E704 diff --git a/tests/test_request_model.py b/tests/test_request_model.py index 80087b2..fe67532 100644 --- a/tests/test_request_model.py +++ b/tests/test_request_model.py @@ -8,7 +8,6 @@ import pytest from fastapi._compat import field_annotation_is_scalar from fastapi._compat import field_annotation_is_sequence -from httpx import AsyncClient from pydantic import BaseModel from pydantic import ValidationError from typeguard import suppress_type_checks @@ -20,7 +19,6 @@ from requestmodel import RequestModel from requestmodel import params from requestmodel.utils import get_annotated_type -from tests.fastapi_server import app from tests.fastapi_server import client from tests.fastapi_server.schema import NameModel from tests.fastapi_server.schema import NameModelList @@ -215,13 +213,10 @@ def test_type_adapter() -> None: assert response == [NameModel(name="test")] -@suppress_type_checks -@pytest.mark.asyncio -async def test_type_adapter_async() -> None: +def test_type_adapter_exception() -> None: request = TypeAdapterRequest() - - async with AsyncClient(app=app, base_url="http://test", timeout=30) as aclient: - - response = await request.asend(aclient) - - assert response == [NameModel(name="test")] + TypeAdapterRequest.response_model = int # type: ignore + with pytest.raises( + ValueError, match="response_model must be a TypeAdapter or a BaseModel" + ): + request.adapt_type(SimpleResponse(data="test"))