Add missing bits of ValidationException.__str__

Fixes #12125.
This commit is contained in:
Tamir Duberstein 2024-11-22 10:05:53 -05:00
parent a2cef707e3
commit 8f4d2665a9
No known key found for this signature in database
7 changed files with 157 additions and 57 deletions

View File

@ -1,3 +1,5 @@
from typing import TYPE_CHECKING
from .main import BaseConfig as BaseConfig from .main import BaseConfig as BaseConfig
from .main import PydanticSchemaGenerationError as PydanticSchemaGenerationError from .main import PydanticSchemaGenerationError as PydanticSchemaGenerationError
from .main import RequiredParam as RequiredParam 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 GetJsonSchemaHandler as GetJsonSchemaHandler
from .may_v1 import JsonSchemaValue as JsonSchemaValue from .may_v1 import JsonSchemaValue as JsonSchemaValue
from .may_v1 import _normalize_errors as _normalize_errors 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 .model_field import ModelField as ModelField
from .shared import PYDANTIC_V2 as PYDANTIC_V2 from .shared import PYDANTIC_V2 as PYDANTIC_V2
from .shared import PYDANTIC_VERSION_MINOR_TUPLE as PYDANTIC_VERSION_MINOR_TUPLE 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 lenient_issubclass as lenient_issubclass
from .shared import sequence_types as sequence_types from .shared import sequence_types as sequence_types
from .shared import value_is_sequence as value_is_sequence from .shared import value_is_sequence as value_is_sequence
if TYPE_CHECKING: # pragma: nocover
from .may_v1 import ErrorDict as ErrorDict

View File

@ -1,5 +1,16 @@
import sys 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 from fastapi.types import ModelNameMap
@ -58,6 +69,23 @@ if sys.version_info >= (3, 14):
from .v2 import ValidationError, create_model 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( def get_definitions(
*, *,
fields: List[ModelField], fields: List[ModelField],
@ -71,6 +99,17 @@ if sys.version_info >= (3, 14):
]: ]:
return {}, {} # pragma: no cover 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: else:
from .v1 import AnyUrl as AnyUrl from .v1 import AnyUrl as AnyUrl
@ -91,8 +130,12 @@ else:
from .v1 import UndefinedType as UndefinedType from .v1 import UndefinedType as UndefinedType
from .v1 import Url as Url from .v1 import Url as Url
from .v1 import ValidationError, create_model from .v1 import ValidationError, create_model
from .v1 import display_errors as display_errors
from .v1 import get_definitions as get_definitions 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") RequestErrorModel: Type[BaseModel] = create_model("Request")

View File

@ -2,6 +2,7 @@ from copy import copy
from dataclasses import dataclass, is_dataclass from dataclasses import dataclass, is_dataclass
from enum import Enum from enum import Enum
from typing import ( from typing import (
TYPE_CHECKING,
Any, Any,
Callable, Callable,
Dict, Dict,
@ -33,6 +34,7 @@ if not PYDANTIC_V2:
from pydantic.class_validators import Validator as Validator from pydantic.class_validators import Validator as Validator
from pydantic.color import Color as Color from pydantic.color import Color as Color
from pydantic.error_wrappers import ErrorWrapper as ErrorWrapper 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.errors import MissingError
from pydantic.fields import ( # type: ignore[attr-defined] from pydantic.fields import ( # type: ignore[attr-defined]
SHAPE_FROZENSET, SHAPE_FROZENSET,
@ -69,6 +71,8 @@ if not PYDANTIC_V2:
from pydantic.typing import evaluate_forwardref as evaluate_forwardref from pydantic.typing import evaluate_forwardref as evaluate_forwardref
from pydantic.utils import lenient_issubclass as lenient_issubclass from pydantic.utils import lenient_issubclass as lenient_issubclass
if TYPE_CHECKING: # pragma: nocover
from pydantic.error_wrappers import ErrorDict as ErrorDict
else: else:
from pydantic.v1 import BaseConfig as BaseConfig # type: ignore[assignment] 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.class_validators import Validator as Validator
from pydantic.v1.color import Color as Color # type: ignore[assignment] 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 ErrorWrapper as ErrorWrapper
from pydantic.v1.error_wrappers import display_errors as display_errors
from pydantic.v1.errors import MissingError from pydantic.v1.errors import MissingError
from pydantic.v1.fields import ( from pydantic.v1.fields import (
SHAPE_FROZENSET, SHAPE_FROZENSET,
@ -122,6 +127,9 @@ else:
from pydantic.v1.typing import evaluate_forwardref as evaluate_forwardref from pydantic.v1.typing import evaluate_forwardref as evaluate_forwardref
from pydantic.v1.utils import lenient_issubclass as lenient_issubclass 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 GetJsonSchemaHandler = Any
JsonSchemaValue = Dict[str, Any] JsonSchemaValue = Dict[str, Any]

View File

@ -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 annotated_doc import Doc
from fastapi._compat import display_errors
from pydantic import BaseModel, create_model from pydantic import BaseModel, create_model
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.exceptions import WebSocketException as StarletteWebSocketException from starlette.exceptions import WebSocketException as StarletteWebSocketException
from typing_extensions import Annotated from typing_extensions import Annotated
if TYPE_CHECKING: # pragma: nocover
from fastapi._compat import ErrorDict
class EndpointContext(TypedDict, total=False): class EndpointContext(TypedDict, total=False):
function: str function: str
@ -164,7 +177,7 @@ class DependencyScopeError(FastAPIError):
class ValidationException(Exception): class ValidationException(Exception):
def __init__( def __init__(
self, self,
errors: Sequence[Any], errors: Sequence["ErrorDict"],
*, *,
endpoint_ctx: Optional[EndpointContext] = None, endpoint_ctx: Optional[EndpointContext] = None,
) -> None: ) -> None:
@ -177,7 +190,7 @@ class ValidationException(Exception):
self.endpoint_file = ctx.get("file") self.endpoint_file = ctx.get("file")
self.endpoint_line = ctx.get("line") self.endpoint_line = ctx.get("line")
def errors(self) -> Sequence[Any]: def errors(self) -> Sequence["ErrorDict"]:
return self._errors return self._errors
def _format_endpoint_context(self) -> str: def _format_endpoint_context(self) -> str:
@ -193,8 +206,7 @@ class ValidationException(Exception):
def __str__(self) -> str: def __str__(self) -> str:
message = f"{len(self._errors)} validation error{'s' if len(self._errors) != 1 else ''}:\n" message = f"{len(self._errors)} validation error{'s' if len(self._errors) != 1 else ''}:\n"
for err in self._errors: message += display_errors(self._errors)
message += f" {err}\n"
message += self._format_endpoint_context() message += self._format_endpoint_context()
return message.rstrip() return message.rstrip()
@ -202,7 +214,7 @@ class ValidationException(Exception):
class RequestValidationError(ValidationException): class RequestValidationError(ValidationException):
def __init__( def __init__(
self, self,
errors: Sequence[Any], errors: Sequence["ErrorDict"],
*, *,
body: Any = None, body: Any = None,
endpoint_ctx: Optional[EndpointContext] = None, endpoint_ctx: Optional[EndpointContext] = None,
@ -224,7 +236,7 @@ class WebSocketRequestValidationError(ValidationException):
class ResponseValidationError(ValidationException): class ResponseValidationError(ValidationException):
def __init__( def __init__(
self, self,
errors: Sequence[Any], errors: Sequence["ErrorDict"],
*, *,
body: Any = None, body: Any = None,
endpoint_ctx: Optional[EndpointContext] = None, endpoint_ctx: Optional[EndpointContext] = None,

View File

@ -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) client = TestClient(app)
def test_get_validation_error(): 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") response = client.get("/items/foo")
assert response.status_code == 400, response.text assert response.status_code == 400, response.text
# TODO: remove when deprecating Pydantic v1 assert response.text == pydantic_snapshot(
assert ( v2=snapshot(
# TODO: remove when deprecating Pydantic v1 "\n".join(
"path -> item_id" in response.text [
or "'loc': ('path', 'item_id')" in response.text "1 validation error:",
) "path -> item_id",
assert ( " Input should be a valid integer, unable to parse string as an integer (type=int_parsing)",
# TODO: remove when deprecating Pydantic v1 f' File "{source_file}", line {line_number}, in {func_name}',
"value is not a valid integer" in response.text " GET /items/{item_id}",
or "Input should be a valid integer, unable to parse string as an integer" ]
in response.text )
),
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}",
]
)
),
) )

View File

@ -5,7 +5,7 @@ from dirty_equals import IsDict
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from inline_snapshot import snapshot 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( @pytest.fixture(
@ -62,10 +62,10 @@ def test_header_param_model_defaults(client: TestClient):
def test_header_param_model_invalid(client: TestClient): def test_header_param_model_invalid(client: TestClient):
response = client.get("/items/") response = client.get("/items/")
assert response.status_code == 422 assert response.status_code == 422
assert response.json() == snapshot( assert response.json() == pydantic_snapshot(
{ v2=snapshot(
"detail": [ {
IsDict( "detail": [
{ {
"type": "missing", "type": "missing",
"loc": ["header", "save_data"], "loc": ["header", "save_data"],
@ -78,18 +78,21 @@ def test_header_param_model_invalid(client: TestClient):
"connection": "keep-alive", "connection": "keep-alive",
"user-agent": "testclient", "user-agent": "testclient",
}, },
} },
) ]
| IsDict( }
# TODO: remove when deprecating Pydantic v1 ),
v1=snapshot(
{
"detail": [
{ {
"type": "value_error.missing", "type": "value_error.missing",
"loc": ["header", "save_data"], "loc": ["header", "save_data"],
"msg": "field required", "msg": "field required",
} },
) ],
] },
} ),
) )

View File

@ -5,7 +5,7 @@ from dirty_equals import IsDict
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from inline_snapshot import snapshot 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( @pytest.fixture(
@ -59,10 +59,10 @@ def test_header_param_model_no_underscore(client: TestClient):
], ],
) )
assert response.status_code == 422 assert response.status_code == 422
assert response.json() == snapshot( assert response.json() == pydantic_snapshot(
{ v2=snapshot(
"detail": [ {
IsDict( "detail": [
{ {
"type": "missing", "type": "missing",
"loc": ["header", "save_data"], "loc": ["header", "save_data"],
@ -80,17 +80,20 @@ def test_header_param_model_no_underscore(client: TestClient):
"x-tag": ["one", "two"], "x-tag": ["one", "two"],
}, },
} }
) ],
| IsDict( },
# TODO: remove when deprecating Pydantic v1 ),
v1=snapshot(
{
"detail": [
{ {
"type": "value_error.missing", "type": "value_error.missing",
"loc": ["header", "save_data"], "loc": ["header", "save_data"],
"msg": "field required", "msg": "field required",
} },
) ],
] },
} ),
) )
@ -109,10 +112,10 @@ def test_header_param_model_defaults(client: TestClient):
def test_header_param_model_invalid(client: TestClient): def test_header_param_model_invalid(client: TestClient):
response = client.get("/items/") response = client.get("/items/")
assert response.status_code == 422 assert response.status_code == 422
assert response.json() == snapshot( assert response.json() == pydantic_snapshot(
{ v2=snapshot(
"detail": [ {
IsDict( "detail": [
{ {
"type": "missing", "type": "missing",
"loc": ["header", "save_data"], "loc": ["header", "save_data"],
@ -126,17 +129,20 @@ def test_header_param_model_invalid(client: TestClient):
"user-agent": "testclient", "user-agent": "testclient",
}, },
} }
) ],
| IsDict( },
# TODO: remove when deprecating Pydantic v1 ),
v1=snapshot(
{
"detail": [
{ {
"type": "value_error.missing", "type": "value_error.missing",
"loc": ["header", "save_data"], "loc": ["header", "save_data"],
"msg": "field required", "msg": "field required",
} },
) ],
] },
} ),
) )