Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ Our backwards-compatibility policy can be found [here](https://github.com/python

## NEXT

- Fix {func}`transform_error <cattrs.transform_error>` omitting the target type for top-level structuring failures when detailed validation is enabled.
([#401](https://github.com/python-attrs/cattrs/issues/401))

- Fix the msgspec converter passing typed deques directly to the encoder, which cannot serialize them. Deques are now unstructured into lists, including when nested in attrs classes or dataclasses.
([#818](https://github.com/python-attrs/cattrs/issues/818) [#819](https://github.com/python-attrs/cattrs/pull/819))

Expand Down
2 changes: 2 additions & 0 deletions src/cattrs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
IterableValidationError,
IterableValidationNote,
StructureHandlerNotFoundError,
TypeValidationNote,
)
from .gen import override
from .types import SimpleStructureHook
Expand All @@ -26,6 +27,7 @@
"IterableValidationNote",
"SimpleStructureHook",
"StructureHandlerNotFoundError",
"TypeValidationNote",
"UnstructureStrategy",
"get_structure_hook",
"get_unstructure_hook",
Expand Down
9 changes: 8 additions & 1 deletion src/cattrs/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
IterableValidationError,
IterableValidationNote,
StructureHandlerNotFoundError,
TypeValidationNote,
)
from .fns import Predicate, identity, raise_error
from .gen import (
Expand Down Expand Up @@ -635,7 +636,13 @@ def structure(self, obj: UnstructuredValue, cl: TypeForm[T]) -> T:
The target type is now annotated with ``TypeForm`` to support
stronger type hints.
"""
return self._structure_func.dispatch(cl)(obj, cl)
try:
return self._structure_func.dispatch(cl)(obj, cl)
except Exception as exc:
if self.detailed_validation and not getattr(exc, "__notes__", []):
note = TypeValidationNote(f"Structuring {cl}", cl)
exc.__notes__ = [*getattr(exc, "__notes__", []), note]
raise

def get_structure_hook(
self, type: TypeForm[T], cache_result: bool = True
Expand Down
14 changes: 14 additions & 0 deletions src/cattrs/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ def derive(self, excs: Sequence[Exception]) -> Self:
return self.__class__(self.message, excs, self.cl)


class TypeValidationNote(str):
"""Attached as a note to an exception when structuring a value fails, carrying the target type."""

type: Any

def __new__(cls, string: str, type: Any) -> Self:
instance = str.__new__(cls, string)
instance.type = type
return instance

def __getnewargs__(self) -> tuple[str, Any]:
return (str(self), self.type)


class IterableValidationNote(str):
"""Attached as a note to an exception when an iterable element fails structuring."""

Expand Down
19 changes: 16 additions & 3 deletions src/cattrs/v.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
ClassValidationError,
ForbiddenExtraKeysError,
IterableValidationError,
TypeValidationNote,
)

__all__ = ["format_exception", "transform_error"]
Expand Down Expand Up @@ -53,6 +54,14 @@ def format_exception(exc: BaseException, type: Union[type, None]) -> str:
return res


def _get_type_from_notes(exc: BaseException) -> Union[type, None]:
"""Get the target type from an exception's type validation note, if any."""
for note in getattr(exc, "__notes__", []):
if note.__class__ is TypeValidationNote:
return note.type
return None


def transform_error(
exc: Union[ClassValidationError, IterableValidationError, BaseException],
path: str = "$",
Expand Down Expand Up @@ -90,7 +99,9 @@ def transform_error(
else:
errors.append(f"{format_exception(exc, note.type)} @ {p}")
for exc in without:
errors.append(f"{format_exception(exc, None)} @ {path}")
errors.append(
f"{format_exception(exc, _get_type_from_notes(exc))} @ {path}"
)
elif isinstance(exc, ClassValidationError):
with_notes, without = exc.group_exceptions()
for exc, note in with_notes:
Expand All @@ -100,7 +111,9 @@ def transform_error(
else:
errors.append(f"{format_exception(exc, note.type)} @ {p}")
for exc in without:
errors.append(f"{format_exception(exc, None)} @ {path}")
errors.append(
f"{format_exception(exc, _get_type_from_notes(exc))} @ {path}"
)
else:
errors.append(f"{format_exception(exc, None)} @ {path}")
errors.append(f"{format_exception(exc, _get_type_from_notes(exc))} @ {path}")
return errors
124 changes: 118 additions & 6 deletions tests/test_v.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for the cattrs.v framework."""

import pickle
from typing import (
Dict,
List,
Expand All @@ -12,11 +13,11 @@
)

from attrs import Factory, define, field
from pytest import fixture, raises
from pytest import fixture, mark, raises

from cattrs import Converter, transform_error
from cattrs import Converter, TypeValidationNote, transform_error
from cattrs._compat import Mapping
from cattrs.errors import IterableValidationError
from cattrs.errors import ClassValidationError, IterableValidationError
from cattrs.gen import make_dict_structure_fn
from cattrs.v import format_exception

Expand All @@ -27,6 +28,116 @@ def c() -> Converter:
return Converter(detailed_validation=True)


@mark.parametrize("cl", [int, float])
def test_primitive_errors(c: Converter, cl: type) -> None:
"""Top-level primitive errors include the target type."""
with raises(ValueError) as exc:
c.structure("x", cl)

assert transform_error(exc.value) == [
f"invalid value for type, expected {cl.__name__} @ $"
]


def test_custom_error_fn_primitive(c: Converter) -> None:
"""Custom formatters receive the target type for primitive errors."""
with raises(ValueError) as exc:
c.structure("x", int)

def my_format(error, type):
assert error is exc.value
assert type is int
return "an integer is required"

assert transform_error(exc.value, format_exception=my_format) == [
"an integer is required @ $"
]


def test_primitive_error_pickling(c: Converter) -> None:
"""Primitive errors retain their target type after pickling."""
with raises(ValueError) as exc:
c.structure("x", int)

error = pickle.loads(pickle.dumps(exc.value)) # noqa: S301
assert transform_error(error) == ["invalid value for type, expected int @ $"]


@mark.parametrize("cl", [int, float])
def test_primitive_errors_without_detailed_validation(converter_cls, cl: type) -> None:
"""Disabling detailed validation leaves primitive errors without notes."""
c = converter_cls(detailed_validation=False)
with raises(ValueError) as exc:
c.structure("x", cl)

assert not hasattr(exc.value, "__notes__")
assert transform_error(exc.value) == ["invalid value @ $"]


@mark.parametrize("group_cls", [ClassValidationError, IterableValidationError])
@mark.parametrize("with_type_note", [True, False])
def test_group_errors_without_context(group_cls, with_type_note: bool) -> None:
"""Errors without attribute or index notes use type notes when present."""
exc = ValueError("Test")
exc.__notes__ = ["A string note"]
if with_type_note:
exc.__notes__.append(TypeValidationNote("Structuring int", int))
group = group_cls("Test", [exc], int)

expected = (
"invalid value for type, expected int" if with_type_note else "invalid value"
)
assert transform_error(group) == [f"{expected} @ $"]


def test_recursive_structure_type_note(c: Converter) -> None:
"""Recursive structuring keeps only the innermost target type note."""
c.register_structure_hook(float, lambda v, _: c.structure(v, int))

with raises(ValueError) as exc:
c.structure("x", float)

assert len(exc.value.__notes__) == 1
note = exc.value.__notes__[0]
assert isinstance(note, TypeValidationNote)
assert note == f"Structuring {int}"
assert note.type is int
assert transform_error(exc.value) == ["invalid value for type, expected int @ $"]


def test_structure_preserves_existing_notes(c: Converter) -> None:
"""An exception with existing notes is re-raised unchanged."""
error = ValueError("Test")
error.__notes__ = notes = ["A string note"]

def hook(v, _):
raise error

c.register_structure_hook(int, hook)
with raises(ValueError) as exc:
c.structure("x", int)

assert exc.value is error
assert error.__notes__ is notes
assert transform_error(error) == ["invalid value @ $"]


@mark.parametrize("exc_cls", [KeyboardInterrupt, SystemExit])
def test_structure_base_exception_without_notes(c: Converter, exc_cls) -> None:
"""Exceptions outside Exception are re-raised without notes."""
error = exc_cls()

def hook(v, _):
raise error

c.register_structure_hook(int, hook)
with raises(exc_cls) as exc:
c.structure("x", int)

assert exc.value is error
assert not hasattr(error, "__notes__")


def test_attribute_errors(c: Converter) -> None:
@define
class C:
Expand Down Expand Up @@ -164,9 +275,7 @@ def test_sequence_errors(c: Converter) -> None:
try:
c.structure(1, List[int])
except Exception as exc:
assert transform_error(exc) == [
"invalid value for type, expected an iterable @ $"
]
assert transform_error(exc) == ["invalid value for type, expected List @ $"]

try:
c.structure(["str", 1, "str"], Tuple[int, ...])
Expand Down Expand Up @@ -376,3 +485,6 @@ class E(TypedDict):
def test_other_errors():
"""Errors without explicit support transform predictably."""
assert format_exception(IndexError("Test"), List[int]) == "unknown error (Test)"
assert transform_error(TypeError("'int' object is not iterable")) == [
"invalid value for type, expected an iterable @ $"
]