diff --git a/HISTORY.md b/HISTORY.md index 942616e3..385c241f 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -13,6 +13,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ## NEXT +- With detailed validation, failing to structure a standalone value (for example a primitive type like `int`) now attaches a `ValueValidationNote` carrying the target type, so {func}`transform_error ` passes the type to the exception formatter instead of `None`. + ([#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..8297e185 100644 --- a/src/cattrs/__init__.py +++ b/src/cattrs/__init__.py @@ -9,6 +9,7 @@ IterableValidationError, IterableValidationNote, StructureHandlerNotFoundError, + ValueValidationNote, ) from .gen import override from .types import SimpleStructureHook @@ -27,6 +28,7 @@ "SimpleStructureHook", "StructureHandlerNotFoundError", "UnstructureStrategy", + "ValueValidationNote", "get_structure_hook", "get_unstructure_hook", "global_converter", diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 70c19b44..da59a0d9 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -86,10 +86,13 @@ ) from .enums import enum_structure_factory, enum_unstructure_factory from .errors import ( + AttributeValidationNote, + BaseValidationError, CattrsError, IterableValidationError, IterableValidationNote, StructureHandlerNotFoundError, + ValueValidationNote, ) from .fns import Predicate, identity, raise_error from .gen import ( @@ -634,8 +637,33 @@ def structure(self, obj: UnstructuredValue, cl: TypeForm[T]) -> T: .. versionchanged:: NEXT The target type is now annotated with ``TypeForm`` to support stronger type hints. + + .. versionchanged:: NEXT + With detailed validation, a failure to structure a standalone + value (for example a primitive type) now carries a + :class:`ValueValidationNote ` with the + target type, so `transform_error` can pass it to the exception + formatter. """ - return self._structure_func.dispatch(cl)(obj, cl) + if not self.detailed_validation: + return self._structure_func.dispatch(cl)(obj, cl) + try: + return self._structure_func.dispatch(cl)(obj, cl) + except Exception as exc: + # Failures inside classes and iterables are grouped and carry + # their own notes; a bare failure (for example a primitive type, + # or a custom hook raising directly) gets the target type + # attached so `transform_error` can use it, unless a more + # specific note is already there. + if not isinstance(exc, BaseValidationError) and not any( + isinstance(note, (AttributeValidationNote, IterableValidationNote, ValueValidationNote)) + for note in getattr(exc, "__notes__", ()) + ): + exc.__notes__ = [ + *getattr(exc, "__notes__", []), + ValueValidationNote(f"Structuring {cl}", cl), + ] + 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..771b1659 100644 --- a/src/cattrs/errors.py +++ b/src/cattrs/errors.py @@ -53,6 +53,25 @@ def __getnewargs__(self) -> tuple[str, Union[int, str], Any]: return (str(self), self.index, self.type) +class ValueValidationNote(str): + """Attached as a note to an exception when structuring a standalone value fails. + + Used for failures outside of a class or iterable context, for example when + structuring a primitive type at the top level, where no attribute or + iterable note applies. + """ + + 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 IterableValidationError(BaseValidationError): """Raised when structuring an iterable.""" diff --git a/src/cattrs/v.py b/src/cattrs/v.py index 78361aab..6acd5458 100644 --- a/src/cattrs/v.py +++ b/src/cattrs/v.py @@ -3,9 +3,12 @@ from typing import Callable, Union from .errors import ( + AttributeValidationNote, ClassValidationError, ForbiddenExtraKeysError, IterableValidationError, + IterableValidationNote, + ValueValidationNote, ) __all__ = ["format_exception", "transform_error"] @@ -32,11 +35,10 @@ def format_exception(exc: BaseException, type: Union[type, None]) -> str: else: res = "invalid value" elif isinstance(exc, TypeError): - if type is None: - if exc.args[0].endswith("object is not iterable"): - res = "invalid value for type, expected an iterable" - else: - res = f"invalid type ({exc})" + if exc.args and exc.args[0].endswith("object is not iterable"): + res = "invalid value for type, expected an iterable" + elif type is None: + res = f"invalid type ({exc})" else: tn = type.__name__ if hasattr(type, "__name__") else repr(type) res = f"invalid value for type, expected {tn}" @@ -79,6 +81,11 @@ def transform_error( string descriptions of errors. .. versionadded:: 23.1.0 + + .. versionchanged:: NEXT + The target type of a standalone value (for example a primitive type) + is now passed to ``format_exception``, using the validation note + attached by converters with detailed validation. """ errors = [] if isinstance(exc, IterableValidationError): @@ -102,5 +109,12 @@ def transform_error( for exc in without: errors.append(f"{format_exception(exc, None)} @ {path}") else: - errors.append(f"{format_exception(exc, None)} @ {path}") + # A standalone failure (for example a primitive type at the top + # level) carries its target type in a validation note, if any. + type = None + for note in getattr(exc, "__notes__", ()): + if isinstance(note, (AttributeValidationNote, IterableValidationNote, ValueValidationNote)): + type = note.type + break + errors.append(f"{format_exception(exc, type)} @ {path}") return errors diff --git a/tests/test_v.py b/tests/test_v.py index 4456b60e..cd66bdf8 100644 --- a/tests/test_v.py +++ b/tests/test_v.py @@ -376,3 +376,34 @@ class E(TypedDict): def test_other_errors(): """Errors without explicit support transform predictably.""" assert format_exception(IndexError("Test"), List[int]) == "unknown error (Test)" + + +def test_primitive_errors(c: Converter) -> None: + """Standalone primitive failures know their target type.""" + with raises(ValueError) as exc_info: + c.structure("not-a-number", int) + assert transform_error(exc_info.value) == ["invalid value for type, expected int @ $"] + + with raises(ValueError) as exc_info: + c.structure("not-a-number", float) + assert transform_error(exc_info.value) == ["invalid value for type, expected float @ $"] + + +def test_primitive_errors_custom_formatter(c: Converter) -> None: + """A custom formatter receives the target type for primitive failures.""" + + def fmt(exc, type): + assert type is int + return "custom" + + with raises(ValueError) as exc_info: + c.structure("not-a-number", int) + assert transform_error(exc_info.value, format_exception=fmt) == ["custom @ $"] + + +def test_primitive_errors_no_detailed_validation() -> None: + """Without detailed validation, no target type is attached.""" + converter = Converter(detailed_validation=False) + with raises(ValueError) as exc_info: + converter.structure("not-a-number", int) + assert transform_error(exc_info.value) == ["invalid value @ $"]