Skip to content
Merged
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
5 changes: 4 additions & 1 deletion HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
If you're using these functions directly, the old behavior can be restored by passing in the desired value directly.
([#596](https://github.com/python-attrs/cattrs/issues/596) [#660](https://github.com/python-attrs/cattrs/pull/660))
- Fix unstructuring of generic classes with stringified annotations.
([#661](https://github.com/python-attrs/cattrs/issues/661) [#662](https://github.com/python-attrs/cattrs/issues/662))
([#661](https://github.com/python-attrs/cattrs/issues/661) [#662](https://github.com/python-attrs/cattrs/issues/662)
- For {class}`cattrs.errors.StructureHandlerNotFoundError` and {class}`cattrs.errors.ForbiddenExtraKeysError`
correctly set {attr}`BaseException.args` in `super()` and hence make them pickable.
([#666](https://github.com/python-attrs/cattrs/pull/666))

## 25.1.1 (2025-06-04)

Expand Down
26 changes: 16 additions & 10 deletions src/cattrs/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,18 @@ class StructureHandlerNotFoundError(Exception):
"""

def __init__(self, message: str, type_: type) -> None:
super().__init__(message)
super().__init__(message, type_)
self.message = message
self.type_ = type_

def __str__(self) -> str:
return self.message


class BaseValidationError(ExceptionGroup):
cl: type

def __new__(cls, message: str, excs: Sequence[Exception], cl: type):
def __new__(cls, message: str, excs: Sequence[Exception], cl: type) -> Self:
obj = super().__new__(cls, message, excs)
obj.cl = cl
return obj
Expand All @@ -35,9 +39,7 @@ class IterableValidationNote(str):
index: Union[int, str] # Ints for list indices, strs for dict keys
type: Any

def __new__(
cls, string: str, index: Union[int, str], type: Any
) -> "IterableValidationNote":
def __new__(cls, string: str, index: Union[int, str], type: Any) -> Self:
instance = str.__new__(cls, string)
instance.index = index
instance.type = type
Expand Down Expand Up @@ -76,7 +78,7 @@ class AttributeValidationNote(str):
name: str
type: Any

def __new__(cls, string: str, name: str, type: Any) -> "AttributeValidationNote":
def __new__(cls, string: str, name: str, type: Any) -> Self:
instance = str.__new__(cls, string)
instance.name = name
instance.type = type
Expand Down Expand Up @@ -122,11 +124,15 @@ class ForbiddenExtraKeysError(Exception):
def __init__(
self, message: Optional[str], cl: type, extra_fields: set[str]
) -> None:
self.message = message
self.cl = cl
self.extra_fields = extra_fields
cln = cl.__name__

super().__init__(
message
or f"Extra fields in constructor for {cln}: {', '.join(extra_fields)}"
super().__init__(message, cl, extra_fields)

def __str__(self) -> str:
return (
self.message
or f"Extra fields in constructor for {self.cl.__name__}: "
f"{', '.join(self.extra_fields)}"
)
74 changes: 74 additions & 0 deletions tests/test_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import pickle
from pathlib import Path
from typing import Any

import pytest

from cattrs._compat import ExceptionGroup
from cattrs.errors import (
BaseValidationError,
ClassValidationError,
ForbiddenExtraKeysError,
IterableValidationError,
StructureHandlerNotFoundError,
)


@pytest.mark.parametrize(
"err_cls, err_args",
[
(StructureHandlerNotFoundError, ("Structure Message", int)),
(ForbiddenExtraKeysError, ("Forbidden Message", int, {"foo", "bar"})),
(ForbiddenExtraKeysError, ("", str, {"foo", "bar"})),
(ForbiddenExtraKeysError, (None, list, {"foo", "bar"})),
(
BaseValidationError,
("BaseValidation Message", [ValueError("Test BaseValidation")], int),
),
(
IterableValidationError,
("IterableValidation Msg", [ValueError("Test IterableValidation")], int),
),
(
ClassValidationError,
("ClassValidation Message", [ValueError("Test ClassValidation")], int),
),
],
)
def test_errors_pickling(
err_cls: type[Exception], err_args: tuple[Any, ...], tmp_path: Path
) -> None:
"""Test if a round of pickling and unpickling works for errors."""
before = err_cls(*err_args)

assert before.args == err_args

with (tmp_path / (err_cls.__name__.lower() + ".pypickle")).open("wb") as f:
pickle.dump(before, f)

with (tmp_path / (err_cls.__name__.lower() + ".pypickle")).open("rb") as f:
after = pickle.load(f) # noqa: S301

assert isinstance(after, err_cls)
assert str(after) == str(before)
if issubclass(err_cls, ExceptionGroup):
assert after.message == before.message
assert after.args[0] == before.args[0]

# We need to do the exceptions within the group (i.e. args[1])
# separately, as on unpickling new objects are created and hence
# they will never be equal to the original ones.
for after_exc, before_exc in zip(after.exceptions, before.exceptions):
assert str(after_exc) == str(before_exc)

# The problem with args[1] might be also for other parameters, but
# we ignore this here and if needed then we need a separate test
assert after.args[2:] == before.args[2:]

else:
assert after.args == err_args
assert after.args == before.args

assert after.__cause__ == before.__cause__
assert after.__context__ == before.__context__
assert after.__traceback__ == before.__traceback__
8 changes: 1 addition & 7 deletions tests/test_typeddicts.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,13 +382,7 @@ def test_forbid_extra_keys(
assert repr(ctx.value) == repr(
ClassValidationError(
f"While structuring {cls.__name__}",
[
ForbiddenExtraKeysError(
f"Extra fields in constructor for {cls.__name__}: test",
cls,
{"test"},
)
],
[ForbiddenExtraKeysError("", cls, {"test"})],
cls,
)
)
Expand Down