From c950e248ddc28c23e0cfd8da77cb1b6f8f73c014 Mon Sep 17 00:00:00 2001 From: Friday Date: Tue, 17 Feb 2026 10:04:04 +0000 Subject: [PATCH] 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 --- src/attr/_make.py | 34 ++++++++++++++++++++++++ tests/test_functional.py | 56 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/attr/_make.py b/src/attr/_make.py index 32e42976e..3f5bdc689 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -634,6 +634,37 @@ def evolve(*args, **changes): return cls(**changes) +def _reconstruct_exc(cls, kwargs): + """ + Reconstruct an attrs exception from keyword arguments. + + Used by pickle to properly handle keyword-only arguments. + """ + return cls(**kwargs) + + +def _make_exc_reduce(attrs): + """ + Create a ``__reduce__`` for exception classes that properly handles + keyword-only arguments during pickling. + + BaseException's default ``__reduce__`` passes all values as positional + args, which fails when some attrs are keyword-only. + """ + init_attrs = tuple(a for a in attrs if a.init) + + def __reduce__(self): + return ( + _reconstruct_exc, + ( + self.__class__, + {a.name: getattr(self, a.name) for a in init_attrs}, + ), + ) + + return __reduce__ + + class _ClassBuilder: """ Iteratively build *one* class. @@ -749,6 +780,9 @@ def __init__( self._cls_dict["__setstate__"], ) = self._make_getstate_setstate() + if props.is_exception: + self._cls_dict["__reduce__"] = _make_exc_reduce(attrs) + # tuples of script, globs, hook self._script_snippets: list[ tuple[str, dict, Callable[[dict, dict], Any]] diff --git a/tests/test_functional.py b/tests/test_functional.py index 7b0317d19..eec0fa2be 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -107,6 +107,28 @@ class WithMetaSlots(metaclass=Meta): FromMakeClass = attr.make_class("FromMakeClass", ["x"]) +@attr.s(auto_exc=True, kw_only=True) +class KwOnlyExc(Exception): + x = attr.ib() + + +@attr.s(auto_exc=True, slots=True, kw_only=True) +class KwOnlyExcSlots(Exception): + x = attr.ib() + + +@attr.s(auto_exc=True) +class MixedExc(Exception): + x = attr.ib() + y = attr.ib(kw_only=True) + + +@attr.s(auto_exc=True, slots=True) +class MixedExcSlots(Exception): + x = attr.ib() + y = attr.ib(kw_only=True) + + class TestFunctional: """ Functional tests. @@ -613,6 +635,40 @@ class FooError(Exception): FooError(1) + @pytest.mark.parametrize( + "cls", + [KwOnlyExc, KwOnlyExcSlots], + ) + def test_auto_exc_kw_only_pickle(self, cls): + """ + Exceptions with kw_only=True can be pickled and unpickled. + + Regression test for #734. + """ + exc = cls(x=42) + exc2 = pickle.loads(pickle.dumps(exc)) + + assert exc2.x == 42 + assert isinstance(exc2, cls) + + @pytest.mark.parametrize( + "cls", + [MixedExc, MixedExcSlots], + ) + def test_auto_exc_mixed_kw_only_pickle(self, cls): + """ + Exceptions with a mix of positional and kw_only attrs can be + pickled and unpickled. + + Regression test for #734. + """ + exc = cls(1, y=2) + exc2 = pickle.loads(pickle.dumps(exc)) + + assert exc2.x == 1 + assert exc2.y == 2 + assert isinstance(exc2, cls) + def test_eq_only(self, slots, frozen): """ Classes with order=False cannot be ordered.