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 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

View File

@ -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")

View File

@ -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]

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 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,

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)
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 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}",
]
)
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
),
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 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(
assert response.json() == pydantic_snapshot(
v2=snapshot(
{
"detail": [
IsDict(
{
"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",
}
)
]
}
},
],
},
),
)

View File

@ -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(
assert response.json() == pydantic_snapshot(
v2=snapshot(
{
"detail": [
IsDict(
{
"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(
assert response.json() == pydantic_snapshot(
v2=snapshot(
{
"detail": [
IsDict(
{
"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",
}
)
]
}
},
],
},
),
)