Skip to content

Commit c950e24

Browse files
Fridayai700claude
andcommitted
Fix pickling of exceptions with kw_only attributes
BaseException.__reduce__ passes all attribute values as positional args, which fails when some attrs are keyword-only. Add a custom __reduce__ to exception classes that passes all init arguments as keyword arguments via a helper function. Fixes #734 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fb9ea67 commit c950e24

File tree

2 files changed

+90
-0
lines changed

2 files changed

+90
-0
lines changed

src/attr/_make.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,37 @@ def evolve(*args, **changes):
634634
return cls(**changes)
635635

636636

637+
def _reconstruct_exc(cls, kwargs):
638+
"""
639+
Reconstruct an attrs exception from keyword arguments.
640+
641+
Used by pickle to properly handle keyword-only arguments.
642+
"""
643+
return cls(**kwargs)
644+
645+
646+
def _make_exc_reduce(attrs):
647+
"""
648+
Create a ``__reduce__`` for exception classes that properly handles
649+
keyword-only arguments during pickling.
650+
651+
BaseException's default ``__reduce__`` passes all values as positional
652+
args, which fails when some attrs are keyword-only.
653+
"""
654+
init_attrs = tuple(a for a in attrs if a.init)
655+
656+
def __reduce__(self):
657+
return (
658+
_reconstruct_exc,
659+
(
660+
self.__class__,
661+
{a.name: getattr(self, a.name) for a in init_attrs},
662+
),
663+
)
664+
665+
return __reduce__
666+
667+
637668
class _ClassBuilder:
638669
"""
639670
Iteratively build *one* class.
@@ -749,6 +780,9 @@ def __init__(
749780
self._cls_dict["__setstate__"],
750781
) = self._make_getstate_setstate()
751782

783+
if props.is_exception:
784+
self._cls_dict["__reduce__"] = _make_exc_reduce(attrs)
785+
752786
# tuples of script, globs, hook
753787
self._script_snippets: list[
754788
tuple[str, dict, Callable[[dict, dict], Any]]

tests/test_functional.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,28 @@ class WithMetaSlots(metaclass=Meta):
107107
FromMakeClass = attr.make_class("FromMakeClass", ["x"])
108108

109109

110+
@attr.s(auto_exc=True, kw_only=True)
111+
class KwOnlyExc(Exception):
112+
x = attr.ib()
113+
114+
115+
@attr.s(auto_exc=True, slots=True, kw_only=True)
116+
class KwOnlyExcSlots(Exception):
117+
x = attr.ib()
118+
119+
120+
@attr.s(auto_exc=True)
121+
class MixedExc(Exception):
122+
x = attr.ib()
123+
y = attr.ib(kw_only=True)
124+
125+
126+
@attr.s(auto_exc=True, slots=True)
127+
class MixedExcSlots(Exception):
128+
x = attr.ib()
129+
y = attr.ib(kw_only=True)
130+
131+
110132
class TestFunctional:
111133
"""
112134
Functional tests.
@@ -613,6 +635,40 @@ class FooError(Exception):
613635

614636
FooError(1)
615637

638+
@pytest.mark.parametrize(
639+
"cls",
640+
[KwOnlyExc, KwOnlyExcSlots],
641+
)
642+
def test_auto_exc_kw_only_pickle(self, cls):
643+
"""
644+
Exceptions with kw_only=True can be pickled and unpickled.
645+
646+
Regression test for #734.
647+
"""
648+
exc = cls(x=42)
649+
exc2 = pickle.loads(pickle.dumps(exc))
650+
651+
assert exc2.x == 42
652+
assert isinstance(exc2, cls)
653+
654+
@pytest.mark.parametrize(
655+
"cls",
656+
[MixedExc, MixedExcSlots],
657+
)
658+
def test_auto_exc_mixed_kw_only_pickle(self, cls):
659+
"""
660+
Exceptions with a mix of positional and kw_only attrs can be
661+
pickled and unpickled.
662+
663+
Regression test for #734.
664+
"""
665+
exc = cls(1, y=2)
666+
exc2 = pickle.loads(pickle.dumps(exc))
667+
668+
assert exc2.x == 1
669+
assert exc2.y == 2
670+
assert isinstance(exc2, cls)
671+
616672
def test_eq_only(self, slots, frozen):
617673
"""
618674
Classes with order=False cannot be ordered.

0 commit comments

Comments
 (0)