From ddac97122cb5667db26ac36d0d9d9ed694a6d336 Mon Sep 17 00:00:00 2001 From: Jayashanker Padishala Date: Thu, 23 Jul 2026 07:59:54 -0700 Subject: [PATCH 1/2] Generate __reduce__ for auto_exc classes BaseException.__reduce__ re-creates an exception by calling cls(*self.args), which fails when __init__ cannot accept the args positionally (e.g. kw_only fields) and loses state __init__ does not reproduce. Generate a dedicated __reduce__ for exceptions that re-creates the instance with BaseException.__new__ (setting args without calling __init__) and restores attribute state: slots classes go through their generated frozen-safe __setstate__, dict classes apply state inside the reconstructor because default state application can go through setattr, which frozen classes reject. Fixes #734 --- changelog.d/1567.change.md | 1 + src/attr/_make.py | 58 +++++++++++++++++++ tests/test_functional.py | 111 +++++++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 changelog.d/1567.change.md diff --git a/changelog.d/1567.change.md b/changelog.d/1567.change.md new file mode 100644 index 000000000..ea6aac5b5 --- /dev/null +++ b/changelog.d/1567.change.md @@ -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. diff --git a/src/attr/_make.py b/src/attr/_make.py index 6794464e9..9f81a9959 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -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]]: @@ -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. @@ -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() diff --git a/tests/test_functional.py b/tests/test_functional.py index b8dfa4593..1ad989ffc 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -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. @@ -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. From 030e5db1543f4f285e027e21b010727f8ed90ccf Mon Sep 17 00:00:00 2001 From: Jayashanker Padishala Date: Thu, 23 Jul 2026 08:01:19 -0700 Subject: [PATCH 2/2] Rename changelog fragment to PR number --- changelog.d/{1567.change.md => 1595.change.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{1567.change.md => 1595.change.md} (100%) diff --git a/changelog.d/1567.change.md b/changelog.d/1595.change.md similarity index 100% rename from changelog.d/1567.change.md rename to changelog.d/1595.change.md