From d4b888b0404912bef9f53221d96397bce5be77ec Mon Sep 17 00:00:00 2001 From: Contributor Date: Mon, 23 Mar 2026 03:13:55 +0800 Subject: [PATCH 1/2] Fix pickling of exception classes with kw_only attributes BaseException.__reduce__ returns (cls, self.args, state), passing self.args as positional arguments to cls() on unpickle. When kw_only=True, __init__ only accepts keyword-only arguments, causing unpickling to fail with: TypeError: __init__() takes 1 positional argument but 2 were given This fix adds a custom __reduce__ for exception classes that have kw_only attrs. It uses a _rebuild_exc helper that creates the instance via __new__ (bypassing __init__) and restores attributes directly, then calls BaseException.__init__ to properly set self.args. Fixes GH#734 --- src/attr/_make.py | 45 ++++++++++++++++++++++++++++++++++++++++ tests/test_functional.py | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/src/attr/_make.py b/src/attr/_make.py index 4b32d6a71..738f284bb 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -103,6 +103,31 @@ def __reduce__(self, _none_constructor=type(None), _args=()): # noqa: B008 return _none_constructor, _args +def _rebuild_exc(cls, state): + """ + Rebuild an exception instance without calling ``__init__``. + + Used by ``__reduce__`` for exception classes with ``kw_only`` attributes, + where ``BaseException.__reduce__`` would pass positional args that + ``kw_only`` rejects. + """ + obj = cls.__new__(cls) + for name, value in state.items(): + try: + object.__setattr__(obj, name, value) + except AttributeError: + pass + # Restore BaseException.args which is set by __init__ via + # BaseException.__init__(self, val1, val2, ...). + init_values = tuple( + state[a.name] + for a in cls.__attrs_attrs__ + if a.init and a.name in state + ) + BaseException.__init__(obj, *init_values) + return obj + + def attrib( default=NOTHING, validator=None, @@ -757,6 +782,26 @@ def __init__( self._cls_dict["__setstate__"], ) = self._make_getstate_setstate() + # Fix pickling for exception classes with kw_only attributes. + # BaseException.__reduce__ returns (cls, self.args, state) which calls + # cls(*args) on unpickle, but kw_only attrs reject positional args. + # Override __reduce__ to use __new__ + state instead of __init__. + if props.is_exception and any( + a.kw_only for a in attrs if a.init + ): + _attr_names = self._attr_names + + def __reduce__(self, *, _attr_names=_attr_names): + state = { + name: getattr(self, name) + for name in _attr_names + if name != "__weakref__" + and hasattr(self, name) + } + return (_rebuild_exc, (self.__class__, state)) + + self._cls_dict["__reduce__"] = __reduce__ + # 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 b8dfa4593..4279965c2 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -624,6 +624,45 @@ class FooError(Exception): FooError(1) + @pytest.mark.parametrize("protocol", range(pickle.HIGHEST_PROTOCOL + 1)) + def test_auto_exc_kw_only_pickle(self, slots, frozen, protocol): + """ + Exception classes with kw_only attributes can be pickled. + + BaseException.__reduce__ passes positional args, which kw_only + rejects. Our custom __reduce__ handles this correctly. + Regression test for GH#734. + """ + + @attr.s(auto_exc=True, slots=slots, frozen=frozen, kw_only=True) + class KwOnlyError(Exception): + msg = attr.ib() + x = attr.ib() + + e = KwOnlyError(msg="hello", x=42) + e2 = pickle.loads(pickle.dumps(e, protocol)) + + assert e2.msg == "hello" + assert e2.x == 42 + assert e2.args == e.args + + def test_auto_exc_mixed_kw_only_pickle(self, slots): + """ + Exception classes with mixed positional and kw_only attributes + can be pickled. + """ + + @attr.s(auto_exc=True, slots=slots, kw_only=False) + class MixedError(Exception): + pos = attr.ib() + kw = attr.ib(kw_only=True) + + e = MixedError(10, kw=20) + e2 = pickle.loads(pickle.dumps(e)) + + assert e2.pos == 10 + assert e2.kw == 20 + def test_eq_only(self, slots, frozen): """ Classes with order=False cannot be ordered. From c10e1115cfda15873cd32a74bc3af4170e03fa91 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:16:21 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/attr/_make.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 738f284bb..200145954 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -786,17 +786,14 @@ def __init__( # BaseException.__reduce__ returns (cls, self.args, state) which calls # cls(*args) on unpickle, but kw_only attrs reject positional args. # Override __reduce__ to use __new__ + state instead of __init__. - if props.is_exception and any( - a.kw_only for a in attrs if a.init - ): + if props.is_exception and any(a.kw_only for a in attrs if a.init): _attr_names = self._attr_names def __reduce__(self, *, _attr_names=_attr_names): state = { name: getattr(self, name) for name in _attr_names - if name != "__weakref__" - and hasattr(self, name) + if name != "__weakref__" and hasattr(self, name) } return (_rebuild_exc, (self.__class__, state))