diff --git a/src/attr/_make.py b/src/attr/_make.py index 4b32d6a71..200145954 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,23 @@ 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.