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
2 changes: 2 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cattrs.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))

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,
ValueValidationNote,
)
from .gen import override
from .types import SimpleStructureHook
Expand All @@ -27,6 +28,7 @@
"SimpleStructureHook",
"StructureHandlerNotFoundError",
"UnstructureStrategy",
"ValueValidationNote",
"get_structure_hook",
"get_unstructure_hook",
"global_converter",
Expand Down
30 changes: 29 additions & 1 deletion src/cattrs/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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 <cattrs.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
Expand Down
19 changes: 19 additions & 0 deletions src/cattrs/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
26 changes: 20 additions & 6 deletions src/cattrs/v.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
from typing import Callable, Union

from .errors import (
AttributeValidationNote,
ClassValidationError,
ForbiddenExtraKeysError,
IterableValidationError,
IterableValidationNote,
ValueValidationNote,
)

__all__ = ["format_exception", "transform_error"]
Expand All @@ -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}"
Expand Down Expand Up @@ -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):
Expand All @@ -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
31 changes: 31 additions & 0 deletions tests/test_v.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 @ $"]