diff --git a/fastapi/_compat/__init__.py b/fastapi/_compat/__init__.py index 0aadd68de..1e0c3a249 100644 --- a/fastapi/_compat/__init__.py +++ b/fastapi/_compat/__init__.py @@ -1,3 +1,5 @@ +from typing import TYPE_CHECKING + from .main import BaseConfig as BaseConfig from .main import PydanticSchemaGenerationError as PydanticSchemaGenerationError from .main import RequiredParam as RequiredParam @@ -34,6 +36,7 @@ from .may_v1 import CoreSchema as CoreSchema from .may_v1 import GetJsonSchemaHandler as GetJsonSchemaHandler from .may_v1 import JsonSchemaValue as JsonSchemaValue from .may_v1 import _normalize_errors as _normalize_errors +from .may_v1 import display_errors as display_errors from .model_field import ModelField as ModelField from .shared import PYDANTIC_V2 as PYDANTIC_V2 from .shared import PYDANTIC_VERSION_MINOR_TUPLE as PYDANTIC_VERSION_MINOR_TUPLE @@ -48,3 +51,6 @@ from .shared import ( from .shared import lenient_issubclass as lenient_issubclass from .shared import sequence_types as sequence_types from .shared import value_is_sequence as value_is_sequence + +if TYPE_CHECKING: # pragma: nocover + from .may_v1 import ErrorDict as ErrorDict diff --git a/fastapi/_compat/may_v1.py b/fastapi/_compat/may_v1.py index beea4d167..e3273406b 100644 --- a/fastapi/_compat/may_v1.py +++ b/fastapi/_compat/may_v1.py @@ -1,5 +1,16 @@ import sys -from typing import Any, Dict, List, Literal, Sequence, Tuple, Type, Union +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + Literal, + Sequence, + Tuple, + Type, + TypedDict, + Union, +) from fastapi.types import ModelNameMap @@ -58,6 +69,23 @@ if sys.version_info >= (3, 14): from .v2 import ValidationError, create_model + def _display_error_loc(error: "ErrorDict") -> str: + return " -> ".join(str(e) for e in error["loc"]) + + def _display_error_type_and_ctx(error: "ErrorDict") -> str: + t = "type=" + error["type"] + ctx = error.get("ctx") + if ctx: # pragma: no cover + return t + "".join(f"; {k}={v}" for k, v in ctx.items()) + else: + return t + + def display_errors(errors: List["ErrorDict"]) -> str: + return "\n".join( + f"{_display_error_loc(e)}\n {e['msg']} ({_display_error_type_and_ctx(e)})" + for e in errors + ) + def get_definitions( *, fields: List[ModelField], @@ -71,6 +99,17 @@ if sys.version_info >= (3, 14): ]: return {}, {} # pragma: no cover + if TYPE_CHECKING: # pragma: no cover + Loc = Tuple[Union[int, str], ...] + + class _ErrorDictRequired(TypedDict): + loc: Loc + msg: str + type: str + + class ErrorDict(_ErrorDictRequired, total=False): + ctx: Dict[str, Any] + else: from .v1 import AnyUrl as AnyUrl @@ -91,8 +130,12 @@ else: from .v1 import UndefinedType as UndefinedType from .v1 import Url as Url from .v1 import ValidationError, create_model + from .v1 import display_errors as display_errors from .v1 import get_definitions as get_definitions + if TYPE_CHECKING: # pragma: no cover + from .v1 import ErrorDict as ErrorDict + RequestErrorModel: Type[BaseModel] = create_model("Request") diff --git a/fastapi/_compat/v1.py b/fastapi/_compat/v1.py index e17ce8bea..b29b48ab3 100644 --- a/fastapi/_compat/v1.py +++ b/fastapi/_compat/v1.py @@ -2,6 +2,7 @@ from copy import copy from dataclasses import dataclass, is_dataclass from enum import Enum from typing import ( + TYPE_CHECKING, Any, Callable, Dict, @@ -33,6 +34,7 @@ if not PYDANTIC_V2: from pydantic.class_validators import Validator as Validator from pydantic.color import Color as Color from pydantic.error_wrappers import ErrorWrapper as ErrorWrapper + from pydantic.error_wrappers import display_errors as display_errors from pydantic.errors import MissingError from pydantic.fields import ( # type: ignore[attr-defined] SHAPE_FROZENSET, @@ -69,6 +71,8 @@ if not PYDANTIC_V2: from pydantic.typing import evaluate_forwardref as evaluate_forwardref from pydantic.utils import lenient_issubclass as lenient_issubclass + if TYPE_CHECKING: # pragma: nocover + from pydantic.error_wrappers import ErrorDict as ErrorDict else: from pydantic.v1 import BaseConfig as BaseConfig # type: ignore[assignment] @@ -80,6 +84,7 @@ else: from pydantic.v1.class_validators import Validator as Validator from pydantic.v1.color import Color as Color # type: ignore[assignment] from pydantic.v1.error_wrappers import ErrorWrapper as ErrorWrapper + from pydantic.v1.error_wrappers import display_errors as display_errors from pydantic.v1.errors import MissingError from pydantic.v1.fields import ( SHAPE_FROZENSET, @@ -122,6 +127,9 @@ else: from pydantic.v1.typing import evaluate_forwardref as evaluate_forwardref from pydantic.v1.utils import lenient_issubclass as lenient_issubclass + if TYPE_CHECKING: # pragma: nocover + from pydantic.v1.error_wrappers import ErrorDict as ErrorDict + GetJsonSchemaHandler = Any JsonSchemaValue = Dict[str, Any] diff --git a/fastapi/exceptions.py b/fastapi/exceptions.py index a46e82350..5f5c672dc 100644 --- a/fastapi/exceptions.py +++ b/fastapi/exceptions.py @@ -1,11 +1,24 @@ -from typing import Any, Dict, Optional, Sequence, Type, TypedDict, Union +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Optional, + Sequence, + Type, + TypedDict, + Union, +) from annotated_doc import Doc +from fastapi._compat import display_errors from pydantic import BaseModel, create_model from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import WebSocketException as StarletteWebSocketException from typing_extensions import Annotated +if TYPE_CHECKING: # pragma: nocover + from fastapi._compat import ErrorDict + class EndpointContext(TypedDict, total=False): function: str @@ -164,7 +177,7 @@ class DependencyScopeError(FastAPIError): class ValidationException(Exception): def __init__( self, - errors: Sequence[Any], + errors: Sequence["ErrorDict"], *, endpoint_ctx: Optional[EndpointContext] = None, ) -> None: @@ -177,7 +190,7 @@ class ValidationException(Exception): self.endpoint_file = ctx.get("file") self.endpoint_line = ctx.get("line") - def errors(self) -> Sequence[Any]: + def errors(self) -> Sequence["ErrorDict"]: return self._errors def _format_endpoint_context(self) -> str: @@ -193,8 +206,7 @@ class ValidationException(Exception): def __str__(self) -> str: message = f"{len(self._errors)} validation error{'s' if len(self._errors) != 1 else ''}:\n" - for err in self._errors: - message += f" {err}\n" + message += display_errors(self._errors) message += self._format_endpoint_context() return message.rstrip() @@ -202,7 +214,7 @@ class ValidationException(Exception): class RequestValidationError(ValidationException): def __init__( self, - errors: Sequence[Any], + errors: Sequence["ErrorDict"], *, body: Any = None, endpoint_ctx: Optional[EndpointContext] = None, @@ -224,7 +236,7 @@ class WebSocketRequestValidationError(ValidationException): class ResponseValidationError(ValidationException): def __init__( self, - errors: Sequence[Any], + errors: Sequence["ErrorDict"], *, body: Any = None, endpoint_ctx: Optional[EndpointContext] = None, diff --git a/tests/test_tutorial/test_handling_errors/test_tutorial004.py b/tests/test_tutorial/test_handling_errors/test_tutorial004.py index 217159a59..99914df8f 100644 --- a/tests/test_tutorial/test_handling_errors/test_tutorial004.py +++ b/tests/test_tutorial/test_handling_errors/test_tutorial004.py @@ -1,24 +1,46 @@ -from fastapi.testclient import TestClient +import inspect -from docs_src.handling_errors.tutorial004 import app +from fastapi.testclient import TestClient +from inline_snapshot import snapshot + +import docs_src.handling_errors.tutorial004 as tutorial004 +from tests.utils import pydantic_snapshot + +app = tutorial004.app client = TestClient(app) def test_get_validation_error(): + source_file = inspect.getsourcefile(tutorial004.read_item) + line_number = inspect.getsourcelines(tutorial004.read_item)[1] + func_name = tutorial004.read_item.__name__ + response = client.get("/items/foo") assert response.status_code == 400, response.text - # TODO: remove when deprecating Pydantic v1 - assert ( - # TODO: remove when deprecating Pydantic v1 - "path -> item_id" in response.text - or "'loc': ('path', 'item_id')" in response.text - ) - assert ( - # TODO: remove when deprecating Pydantic v1 - "value is not a valid integer" in response.text - or "Input should be a valid integer, unable to parse string as an integer" - in response.text + assert response.text == pydantic_snapshot( + v2=snapshot( + "\n".join( + [ + "1 validation error:", + "path -> item_id", + " Input should be a valid integer, unable to parse string as an integer (type=int_parsing)", + f' File "{source_file}", line {line_number}, in {func_name}', + " GET /items/{item_id}", + ] + ) + ), + v1=snapshot( + "\n".join( + [ + "1 validation error:", + "path -> item_id", + " value is not a valid integer (type=type_error.integer)", + f' File "{source_file}", line {line_number}, in {func_name}', + " GET /items/{item_id}", + ] + ) + ), ) diff --git a/tests/test_tutorial/test_header_param_models/test_tutorial001.py b/tests/test_tutorial/test_header_param_models/test_tutorial001.py index bc876897b..392f21de9 100644 --- a/tests/test_tutorial/test_header_param_models/test_tutorial001.py +++ b/tests/test_tutorial/test_header_param_models/test_tutorial001.py @@ -5,7 +5,7 @@ from dirty_equals import IsDict from fastapi.testclient import TestClient from inline_snapshot import snapshot -from tests.utils import needs_py39, needs_py310 +from tests.utils import needs_py39, needs_py310, pydantic_snapshot @pytest.fixture( @@ -62,10 +62,10 @@ def test_header_param_model_defaults(client: TestClient): def test_header_param_model_invalid(client: TestClient): response = client.get("/items/") assert response.status_code == 422 - assert response.json() == snapshot( - { - "detail": [ - IsDict( + assert response.json() == pydantic_snapshot( + v2=snapshot( + { + "detail": [ { "type": "missing", "loc": ["header", "save_data"], @@ -78,18 +78,21 @@ def test_header_param_model_invalid(client: TestClient): "connection": "keep-alive", "user-agent": "testclient", }, - } - ) - | IsDict( - # TODO: remove when deprecating Pydantic v1 + }, + ] + } + ), + v1=snapshot( + { + "detail": [ { "type": "value_error.missing", "loc": ["header", "save_data"], "msg": "field required", - } - ) - ] - } + }, + ], + }, + ), ) diff --git a/tests/test_tutorial/test_header_param_models/test_tutorial003.py b/tests/test_tutorial/test_header_param_models/test_tutorial003.py index 554a48d2e..e19bb3de3 100644 --- a/tests/test_tutorial/test_header_param_models/test_tutorial003.py +++ b/tests/test_tutorial/test_header_param_models/test_tutorial003.py @@ -5,7 +5,7 @@ from dirty_equals import IsDict from fastapi.testclient import TestClient from inline_snapshot import snapshot -from tests.utils import needs_py39, needs_py310 +from tests.utils import needs_py39, needs_py310, pydantic_snapshot @pytest.fixture( @@ -59,10 +59,10 @@ def test_header_param_model_no_underscore(client: TestClient): ], ) assert response.status_code == 422 - assert response.json() == snapshot( - { - "detail": [ - IsDict( + assert response.json() == pydantic_snapshot( + v2=snapshot( + { + "detail": [ { "type": "missing", "loc": ["header", "save_data"], @@ -80,17 +80,20 @@ def test_header_param_model_no_underscore(client: TestClient): "x-tag": ["one", "two"], }, } - ) - | IsDict( - # TODO: remove when deprecating Pydantic v1 + ], + }, + ), + v1=snapshot( + { + "detail": [ { "type": "value_error.missing", "loc": ["header", "save_data"], "msg": "field required", - } - ) - ] - } + }, + ], + }, + ), ) @@ -109,10 +112,10 @@ def test_header_param_model_defaults(client: TestClient): def test_header_param_model_invalid(client: TestClient): response = client.get("/items/") assert response.status_code == 422 - assert response.json() == snapshot( - { - "detail": [ - IsDict( + assert response.json() == pydantic_snapshot( + v2=snapshot( + { + "detail": [ { "type": "missing", "loc": ["header", "save_data"], @@ -126,17 +129,20 @@ def test_header_param_model_invalid(client: TestClient): "user-agent": "testclient", }, } - ) - | IsDict( - # TODO: remove when deprecating Pydantic v1 + ], + }, + ), + v1=snapshot( + { + "detail": [ { "type": "value_error.missing", "loc": ["header", "save_data"], "msg": "field required", - } - ) - ] - } + }, + ], + }, + ), )