Skip to content

Commit f5d0594

Browse files
authored
(errors) Correctly set BaseException.args and make them pickable (#666)
* (errors) Correctly set `BaseException.args` * (errors) Correctly set `BaseException.args`
1 parent bc325a8 commit f5d0594

File tree

4 files changed

+95
-18
lines changed

4 files changed

+95
-18
lines changed

HISTORY.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
2424
If you're using these functions directly, the old behavior can be restored by passing in the desired value directly.
2525
([#596](https://github.com/python-attrs/cattrs/issues/596) [#660](https://github.com/python-attrs/cattrs/pull/660))
2626
- Fix unstructuring of generic classes with stringified annotations.
27-
([#661](https://github.com/python-attrs/cattrs/issues/661) [#662](https://github.com/python-attrs/cattrs/issues/662))
27+
([#661](https://github.com/python-attrs/cattrs/issues/661) [#662](https://github.com/python-attrs/cattrs/issues/662)
28+
- For {class}`cattrs.errors.StructureHandlerNotFoundError` and {class}`cattrs.errors.ForbiddenExtraKeysError`
29+
correctly set {attr}`BaseException.args` in `super()` and hence make them pickable.
30+
([#666](https://github.com/python-attrs/cattrs/pull/666))
2831

2932
## 25.1.1 (2025-06-04)
3033

src/cattrs/errors.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,18 @@ class StructureHandlerNotFoundError(Exception):
1313
"""
1414

1515
def __init__(self, message: str, type_: type) -> None:
16-
super().__init__(message)
16+
super().__init__(message, type_)
17+
self.message = message
1718
self.type_ = type_
1819

20+
def __str__(self) -> str:
21+
return self.message
22+
1923

2024
class BaseValidationError(ExceptionGroup):
2125
cl: type
2226

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

38-
def __new__(
39-
cls, string: str, index: Union[int, str], type: Any
40-
) -> "IterableValidationNote":
42+
def __new__(cls, string: str, index: Union[int, str], type: Any) -> Self:
4143
instance = str.__new__(cls, string)
4244
instance.index = index
4345
instance.type = type
@@ -76,7 +78,7 @@ class AttributeValidationNote(str):
7678
name: str
7779
type: Any
7880

79-
def __new__(cls, string: str, name: str, type: Any) -> "AttributeValidationNote":
81+
def __new__(cls, string: str, name: str, type: Any) -> Self:
8082
instance = str.__new__(cls, string)
8183
instance.name = name
8284
instance.type = type
@@ -122,11 +124,15 @@ class ForbiddenExtraKeysError(Exception):
122124
def __init__(
123125
self, message: Optional[str], cl: type, extra_fields: set[str]
124126
) -> None:
127+
self.message = message
125128
self.cl = cl
126129
self.extra_fields = extra_fields
127-
cln = cl.__name__
128130

129-
super().__init__(
130-
message
131-
or f"Extra fields in constructor for {cln}: {', '.join(extra_fields)}"
131+
super().__init__(message, cl, extra_fields)
132+
133+
def __str__(self) -> str:
134+
return (
135+
self.message
136+
or f"Extra fields in constructor for {self.cl.__name__}: "
137+
f"{', '.join(self.extra_fields)}"
132138
)

tests/test_errors.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import pickle
2+
from pathlib import Path
3+
from typing import Any
4+
5+
import pytest
6+
7+
from cattrs._compat import ExceptionGroup
8+
from cattrs.errors import (
9+
BaseValidationError,
10+
ClassValidationError,
11+
ForbiddenExtraKeysError,
12+
IterableValidationError,
13+
StructureHandlerNotFoundError,
14+
)
15+
16+
17+
@pytest.mark.parametrize(
18+
"err_cls, err_args",
19+
[
20+
(StructureHandlerNotFoundError, ("Structure Message", int)),
21+
(ForbiddenExtraKeysError, ("Forbidden Message", int, {"foo", "bar"})),
22+
(ForbiddenExtraKeysError, ("", str, {"foo", "bar"})),
23+
(ForbiddenExtraKeysError, (None, list, {"foo", "bar"})),
24+
(
25+
BaseValidationError,
26+
("BaseValidation Message", [ValueError("Test BaseValidation")], int),
27+
),
28+
(
29+
IterableValidationError,
30+
("IterableValidation Msg", [ValueError("Test IterableValidation")], int),
31+
),
32+
(
33+
ClassValidationError,
34+
("ClassValidation Message", [ValueError("Test ClassValidation")], int),
35+
),
36+
],
37+
)
38+
def test_errors_pickling(
39+
err_cls: type[Exception], err_args: tuple[Any, ...], tmp_path: Path
40+
) -> None:
41+
"""Test if a round of pickling and unpickling works for errors."""
42+
before = err_cls(*err_args)
43+
44+
assert before.args == err_args
45+
46+
with (tmp_path / (err_cls.__name__.lower() + ".pypickle")).open("wb") as f:
47+
pickle.dump(before, f)
48+
49+
with (tmp_path / (err_cls.__name__.lower() + ".pypickle")).open("rb") as f:
50+
after = pickle.load(f) # noqa: S301
51+
52+
assert isinstance(after, err_cls)
53+
assert str(after) == str(before)
54+
if issubclass(err_cls, ExceptionGroup):
55+
assert after.message == before.message
56+
assert after.args[0] == before.args[0]
57+
58+
# We need to do the exceptions within the group (i.e. args[1])
59+
# separately, as on unpickling new objects are created and hence
60+
# they will never be equal to the original ones.
61+
for after_exc, before_exc in zip(after.exceptions, before.exceptions):
62+
assert str(after_exc) == str(before_exc)
63+
64+
# The problem with args[1] might be also for other parameters, but
65+
# we ignore this here and if needed then we need a separate test
66+
assert after.args[2:] == before.args[2:]
67+
68+
else:
69+
assert after.args == err_args
70+
assert after.args == before.args
71+
72+
assert after.__cause__ == before.__cause__
73+
assert after.__context__ == before.__context__
74+
assert after.__traceback__ == before.__traceback__

tests/test_typeddicts.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -382,13 +382,7 @@ def test_forbid_extra_keys(
382382
assert repr(ctx.value) == repr(
383383
ClassValidationError(
384384
f"While structuring {cls.__name__}",
385-
[
386-
ForbiddenExtraKeysError(
387-
f"Extra fields in constructor for {cls.__name__}: test",
388-
cls,
389-
{"test"},
390-
)
391-
],
385+
[ForbiddenExtraKeysError("", cls, {"test"})],
392386
cls,
393387
)
394388
)

0 commit comments

Comments
 (0)