Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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