Skip to content
Open
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
1 change: 1 addition & 0 deletions changelog.d/1595.change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`attrs.s(auto_exc=True)` exceptions now get a generated `__reduce__`, so exceptions whose `__init__` cannot accept `self.args` positionally -- for example with *kw_only* fields -- can be pickled and deep-copied.
58 changes: 58 additions & 0 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,22 @@ def _has_own_attribute(cls, attrib_name):
return attrib_name in cls.__dict__


def _reconstruct_exception(cls, args, state=None):
"""
Create *cls* with ``BaseException.__new__`` so its ``args`` are set
without calling the attrs-generated ``__init__`` (whose signature may not
accept ``args`` positionally, for example with ``kw_only`` fields).

If *state* is passed, it is applied to ``__dict__`` directly, which
both matches classic exception pickling semantics and works for frozen
classes.
"""
self = cls.__new__(cls, *args)
if state:
self.__dict__.update(state)
return self


def _collect_base_attrs(
cls, taken_attr_names
) -> tuple[list[Attribute], dict[str, type]]:
Expand Down Expand Up @@ -1018,6 +1034,40 @@ def __str__(self):
self._cls_dict["__str__"] = self._add_method_dunders(__str__)
return self

def add_reduce(self):
if self._slots:
state_attr_names = tuple(
an for an in self._attr_names if an != "__weakref__"
)

def __reduce__(self):
state = {}
for name in state_attr_names:
# Attributes with init=False and no default may be unset.
try:
state[name] = getattr(self, name)
except AttributeError:
pass
return (
_reconstruct_exception,
(self.__class__, self.args),
state,
)

else:

def __reduce__(self):
# State is applied by _reconstruct_exception instead of the
# usual third element: default state application can go
# through setattr, which frozen classes reject.
return (
_reconstruct_exception,
(self.__class__, self.args, self.__dict__.copy()),
)

self._cls_dict["__reduce__"] = self._add_method_dunders(__reduce__)
return self

def _make_getstate_setstate(self):
"""
Create custom __setstate__ and __getstate__ methods.
Expand Down Expand Up @@ -1556,6 +1606,14 @@ def wrap(cls):
if props.added_ordering:
builder.add_order()

if is_exc and not _has_own_attribute(cls, "__reduce__"):
# BaseException.__reduce__ re-creates an exception by calling
# cls(*self.args), which fails when __init__'s signature cannot
# accept the args positionally (e.g. kw_only fields) and loses
# any state __init__ doesn't reproduce. Restore from state
# instead.
builder.add_reduce()

if not frozen:
builder.add_setattr()

Expand Down
111 changes: 111 additions & 0 deletions tests/test_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,48 @@ class WithMetaSlots(metaclass=Meta):
FromMakeClass = attr.make_class("FromMakeClass", ["x"])


@attr.s(auto_exc=True, kw_only=True)
class KwOnlyError(Exception):
x = attr.ib(default=42)


@attr.s(auto_exc=True, kw_only=True, slots=True)
class KwOnlyErrorSlots(Exception):
x = attr.ib(default=42)


@attr.s(auto_exc=True, kw_only=True, frozen=True)
class KwOnlyErrorFrozen(Exception):
x = attr.ib(default=42)


@attr.s(auto_exc=True, kw_only=True, frozen=True, slots=True)
class KwOnlyErrorFrozenSlots(Exception):
x = attr.ib(default=42)


@attr.s(auto_exc=True)
class ChildError(KwOnlyError):
y = attr.ib()


@attr.s(auto_exc=True, slots=True)
class ChildErrorSlots(KwOnlyErrorSlots):
y = attr.ib()


@attr.s(auto_exc=True)
class UnsetAttributeError_(Exception):
x = attr.ib()
z = attr.ib(init=False)


@attr.s(auto_exc=True, slots=True)
class UnsetAttributeErrorSlots_(Exception):
x = attr.ib()
z = attr.ib(init=False)


class TestFunctional:
"""
Functional tests.
Expand Down Expand Up @@ -624,6 +666,75 @@ class FooError(Exception):

FooError(1)

@pytest.mark.parametrize(
"cls",
[
KwOnlyError,
KwOnlyErrorSlots,
KwOnlyErrorFrozen,
KwOnlyErrorFrozenSlots,
],
)
@pytest.mark.parametrize("protocol", range(2, pickle.HIGHEST_PROTOCOL + 1))
def test_auto_exc_pickle_kw_only(self, cls, protocol):
"""
Exceptions whose __init__ cannot accept self.args positionally --
e.g. with kw_only fields -- survive a pickle round-trip with their
args and state intact.

Regression test for #734.
"""
e = cls(x=1337)
rv = pickle.loads(pickle.dumps(e, protocol))

assert rv.__class__ is cls
assert e.x == rv.x
assert e.args == rv.args

@pytest.mark.parametrize("cls", [ChildError, ChildErrorSlots])
@pytest.mark.parametrize("protocol", range(2, pickle.HIGHEST_PROTOCOL + 1))
def test_auto_exc_pickle_subclass(self, cls, protocol):
"""
Subclasses that add a required positional field to a kw_only
exception also round-trip.
"""
e = cls("foo")
rv = pickle.loads(pickle.dumps(e, protocol))

assert e.x == rv.x
assert e.y == rv.y
assert e.args == rv.args

@pytest.mark.parametrize(
"cls", [UnsetAttributeError_, UnsetAttributeErrorSlots_]
)
def test_auto_exc_pickle_unset_attribute(self, cls):
"""
Exceptions with an attribute that is never set (init=False, no
default) can still be pickled and deep-copied.
"""
e = cls(1)
rv = pickle.loads(pickle.dumps(e))

assert e.x == rv.x
assert e.args == rv.args
assert not hasattr(rv, "z")
deepcopy(e)

def test_auto_exc_custom_reduce_respected(self, slots, frozen):
"""
A user-defined __reduce__ on an auto_exc class is not overwritten.
"""

@attr.s(auto_exc=True, slots=slots, frozen=frozen)
class FooError(Exception):
x = attr.ib()

def __reduce__(self):
return (self.__class__, (self.x,))

assert (FooError, (3,)) == FooError(3).__reduce__()

def test_eq_only(self, slots, frozen):
"""
Classes with order=False cannot be ordered.
Expand Down