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
34 changes: 34 additions & 0 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,37 @@ def evolve(*args, **changes):
return cls(**changes)


def _reconstruct_exc(cls, kwargs):
"""
Reconstruct an attrs exception from keyword arguments.

Used by pickle to properly handle keyword-only arguments.
"""
return cls(**kwargs)


def _make_exc_reduce(attrs):
"""
Create a ``__reduce__`` for exception classes that properly handles
keyword-only arguments during pickling.

BaseException's default ``__reduce__`` passes all values as positional
args, which fails when some attrs are keyword-only.
"""
init_attrs = tuple(a for a in attrs if a.init)

def __reduce__(self):
return (
_reconstruct_exc,
(
self.__class__,
{a.name: getattr(self, a.name) for a in init_attrs},
),
)

return __reduce__


class _ClassBuilder:
"""
Iteratively build *one* class.
Expand Down Expand Up @@ -749,6 +780,9 @@ def __init__(
self._cls_dict["__setstate__"],
) = self._make_getstate_setstate()

if props.is_exception:
self._cls_dict["__reduce__"] = _make_exc_reduce(attrs)

# tuples of script, globs, hook
self._script_snippets: list[
tuple[str, dict, Callable[[dict, dict], Any]]
Expand Down
56 changes: 56 additions & 0 deletions tests/test_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,28 @@ class WithMetaSlots(metaclass=Meta):
FromMakeClass = attr.make_class("FromMakeClass", ["x"])


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


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


@attr.s(auto_exc=True)
class MixedExc(Exception):
x = attr.ib()
y = attr.ib(kw_only=True)


@attr.s(auto_exc=True, slots=True)
class MixedExcSlots(Exception):
x = attr.ib()
y = attr.ib(kw_only=True)


class TestFunctional:
"""
Functional tests.
Expand Down Expand Up @@ -613,6 +635,40 @@ class FooError(Exception):

FooError(1)

@pytest.mark.parametrize(
"cls",
[KwOnlyExc, KwOnlyExcSlots],
)
def test_auto_exc_kw_only_pickle(self, cls):
"""
Exceptions with kw_only=True can be pickled and unpickled.

Regression test for #734.
"""
exc = cls(x=42)
exc2 = pickle.loads(pickle.dumps(exc))

assert exc2.x == 42
assert isinstance(exc2, cls)

@pytest.mark.parametrize(
"cls",
[MixedExc, MixedExcSlots],
)
def test_auto_exc_mixed_kw_only_pickle(self, cls):
"""
Exceptions with a mix of positional and kw_only attrs can be
pickled and unpickled.

Regression test for #734.
"""
exc = cls(1, y=2)
exc2 = pickle.loads(pickle.dumps(exc))

assert exc2.x == 1
assert exc2.y == 2
assert isinstance(exc2, cls)

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