Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]]
Expand Down
39 changes: 39 additions & 0 deletions tests/test_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading