From d11161dc87b7468f3d059eefaecc47632b2a0883 Mon Sep 17 00:00:00 2001 From: Aschenbath Date: Wed, 4 Nov 2026 08:52:26 +0000 Subject: [PATCH] Pass target type to format_exception for noteless failures --- HISTORY.md | 3 + src/cattrs/__init__.py | 2 + src/cattrs/converters.py | 9 ++- src/cattrs/errors.py | 14 +++++ src/cattrs/v.py | 19 +++++- tests/test_v.py | 124 +++++++++++++++++++++++++++++++++++++-- 6 files changed, 161 insertions(+), 10 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 942616e3..4cd3617a 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -13,6 +13,9 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ## NEXT +- Fix {func}`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)) diff --git a/src/cattrs/__init__.py b/src/cattrs/__init__.py index 2252272c..f1e9a335 100644 --- a/src/cattrs/__init__.py +++ b/src/cattrs/__init__.py @@ -9,6 +9,7 @@ IterableValidationError, IterableValidationNote, StructureHandlerNotFoundError, + TypeValidationNote, ) from .gen import override from .types import SimpleStructureHook @@ -26,6 +27,7 @@ "IterableValidationNote", "SimpleStructureHook", "StructureHandlerNotFoundError", + "TypeValidationNote", "UnstructureStrategy", "get_structure_hook", "get_unstructure_hook", diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 70c19b44..09da3ea2 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -90,6 +90,7 @@ IterableValidationError, IterableValidationNote, StructureHandlerNotFoundError, + TypeValidationNote, ) from .fns import Predicate, identity, raise_error from .gen import ( @@ -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 diff --git a/src/cattrs/errors.py b/src/cattrs/errors.py index 13df1009..2bc3547c 100644 --- a/src/cattrs/errors.py +++ b/src/cattrs/errors.py @@ -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.""" diff --git a/src/cattrs/v.py b/src/cattrs/v.py index 78361aab..5180dda8 100644 --- a/src/cattrs/v.py +++ b/src/cattrs/v.py @@ -6,6 +6,7 @@ ClassValidationError, ForbiddenExtraKeysError, IterableValidationError, + TypeValidationNote, ) __all__ = ["format_exception", "transform_error"] @@ -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 = "$", @@ -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: @@ -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 diff --git a/tests/test_v.py b/tests/test_v.py index 4456b60e..445eba4d 100644 --- a/tests/test_v.py +++ b/tests/test_v.py @@ -1,5 +1,6 @@ """Tests for the cattrs.v framework.""" +import pickle from typing import ( Dict, List, @@ -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 @@ -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: @@ -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, ...]) @@ -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 @ $" + ]