Skip to content

Commit 66a8ae9

Browse files
committed
Fix pickling kw-only attrs exceptions
1 parent 005e2fb commit 66a8ae9

2 files changed

Lines changed: 185 additions & 0 deletions

File tree

src/attr/_make.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939

4040
# This is used at least twice, so cache it here.
4141
_OBJ_SETATTR = object.__setattr__
42+
_BASE_EXCEPTION_REDUCE = BaseException.__dict__.get("__reduce__")
43+
_BASE_EXCEPTION_SETSTATE = BaseException.__dict__.get("__setstate__")
4244
_INIT_FACTORY_PAT = "__attr_factory_%s"
4345
_CLASSVAR_PREFIXES = (
4446
"typing.ClassVar",
@@ -103,6 +105,37 @@ def __reduce__(self, _none_constructor=type(None), _args=()): # noqa: B008
103105
return _none_constructor, _args
104106

105107

108+
def _reconstruct_exception(cls, args, state):
109+
"""
110+
Reconstruct an attrs exception for pickle without calling __init__.
111+
"""
112+
self = BaseException.__new__(cls)
113+
BaseException.__init__(self, *args)
114+
115+
if state is None:
116+
return self
117+
118+
setstate = getattr(self, "__setstate__", None)
119+
if (
120+
setstate is not None
121+
and getattr(cls, "__setstate__", None) is not _BASE_EXCEPTION_SETSTATE
122+
):
123+
setstate(state)
124+
return self
125+
126+
if isinstance(state, tuple):
127+
inst_dict, slot_state = state
128+
state = {}
129+
if inst_dict:
130+
state.update(inst_dict)
131+
state.update(slot_state)
132+
133+
for name, value in state.items():
134+
_OBJ_SETATTR(self, name, value)
135+
136+
return self
137+
138+
106139
def attrib(
107140
default=NOTHING,
108141
validator=None,
@@ -1018,6 +1051,34 @@ def __str__(self):
10181051
self._cls_dict["__str__"] = self._add_method_dunders(__str__)
10191052
return self
10201053

1054+
def add_exception_reduce(self):
1055+
def __reduce__(self):
1056+
getstate = getattr(self, "__getstate__", None)
1057+
if getstate is not None:
1058+
state = getstate()
1059+
else:
1060+
dict_state = getattr(self, "__dict__", None)
1061+
dict_state = dict_state.copy() if dict_state else None
1062+
1063+
if self.__attrs_props__.is_slotted:
1064+
slot_state = {
1065+
a.name: getattr(self, a.name)
1066+
for a in self.__attrs_attrs__
1067+
if a.name != "__weakref__"
1068+
}
1069+
state = (dict_state, slot_state)
1070+
else:
1071+
state = dict_state
1072+
1073+
return (
1074+
_reconstruct_exception,
1075+
(self.__class__, self.args, state),
1076+
)
1077+
1078+
__reduce__.__attrs_exception_reduce__ = True
1079+
self._cls_dict["__reduce__"] = self._add_method_dunders(__reduce__)
1080+
return self
1081+
10211082
def _make_getstate_setstate(self):
10221083
"""
10231084
Create custom __setstate__ and __getstate__ methods.
@@ -1563,6 +1624,22 @@ def wrap(cls):
15631624
msg = "Invalid value for cache_hash. To use hash caching, init must be True."
15641625
raise TypeError(msg)
15651626

1627+
if (
1628+
props.is_exception
1629+
and props.added_init
1630+
and any(a.init and a.kw_only for a in builder._attrs)
1631+
and not _has_own_attribute(cls, "__reduce__")
1632+
and (
1633+
getattr(cls, "__reduce__", None) is _BASE_EXCEPTION_REDUCE
1634+
or getattr(
1635+
getattr(cls, "__reduce__", None),
1636+
"__attrs_exception_reduce__",
1637+
False,
1638+
)
1639+
)
1640+
):
1641+
builder.add_exception_reduce()
1642+
15661643
if PY_3_13_PLUS and not _has_own_attribute(cls, "__replace__"):
15671644
builder.add_replace()
15681645

tests/test_functional.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,59 @@ class C2Slots:
4949
y = attr.ib(default=attr.Factory(list))
5050

5151

52+
@attr.s(auto_exc=True)
53+
class KwOnlyException(Exception):
54+
value = attr.ib(kw_only=True)
55+
56+
57+
@attr.s(auto_exc=True, frozen=True)
58+
class FrozenKwOnlyException(Exception):
59+
value = attr.ib(kw_only=True)
60+
61+
62+
@attr.s(auto_exc=True, slots=True)
63+
class SlottedKwOnlyException(Exception):
64+
value = attr.ib(kw_only=True)
65+
66+
67+
@attr.s(auto_exc=True, frozen=True, slots=True)
68+
class FrozenSlottedKwOnlyException(Exception):
69+
value = attr.ib(kw_only=True)
70+
71+
72+
@attr.s(auto_exc=True, frozen=True, slots=True, kw_only=True)
73+
class KwOnlyBaseException(Exception):
74+
has_default = attr.ib(default=42)
75+
76+
77+
@attr.s(auto_exc=True, frozen=True, slots=True)
78+
class KwOnlySubException(KwOnlyBaseException):
79+
no_default = attr.ib(kw_only=False)
80+
81+
82+
@attr.s(auto_exc=True)
83+
class CustomReduceKwOnlyException(Exception):
84+
value = attr.ib(kw_only=True)
85+
86+
def __reduce__(self):
87+
return "custom"
88+
89+
90+
class InheritedCustomReduceException(Exception):
91+
def __reduce__(self):
92+
return "inherited custom"
93+
94+
95+
@attr.s(auto_exc=True)
96+
class InheritedCustomReduceKwOnlyException(InheritedCustomReduceException):
97+
value = attr.ib(kw_only=True)
98+
99+
100+
@attr.s(auto_exc=True)
101+
class PositionalOnlyException(Exception):
102+
value = attr.ib()
103+
104+
52105
@attr.s
53106
class Base:
54107
x = attr.ib()
@@ -624,6 +677,61 @@ class FooError(Exception):
624677

625678
FooError(1)
626679

680+
@pytest.mark.parametrize(
681+
"cls",
682+
[
683+
KwOnlyException,
684+
FrozenKwOnlyException,
685+
SlottedKwOnlyException,
686+
FrozenSlottedKwOnlyException,
687+
],
688+
)
689+
def test_auto_exc_kw_only_pickles(self, cls):
690+
"""
691+
Keyword-only exception fields don't break pickle round-tripping.
692+
"""
693+
exc = cls(value=1)
694+
695+
rt = pickle.loads(pickle.dumps(exc))
696+
697+
assert isinstance(rt, cls)
698+
assert 1 == rt.value
699+
assert exc.args == rt.args
700+
701+
def test_auto_exc_kw_only_pickles_subclass_with_positional_field(self):
702+
"""
703+
Inherited keyword-only fields work with subclass positional fields.
704+
"""
705+
exc = KwOnlySubException("new", has_default=23)
706+
707+
rt = pickle.loads(pickle.dumps(exc))
708+
709+
assert isinstance(rt, KwOnlySubException)
710+
assert 23 == rt.has_default
711+
assert "new" == rt.no_default
712+
assert exc.args == rt.args
713+
714+
def test_auto_exc_does_not_overwrite_custom_reduce(self):
715+
"""
716+
A custom __reduce__ on a keyword-only exception is left alone.
717+
"""
718+
assert "custom" == CustomReduceKwOnlyException(value=1).__reduce__()
719+
720+
def test_auto_exc_does_not_overwrite_inherited_custom_reduce(self):
721+
"""
722+
An inherited custom __reduce__ is left alone.
723+
"""
724+
assert (
725+
"inherited custom"
726+
== InheritedCustomReduceKwOnlyException(value=1).__reduce__()
727+
)
728+
729+
def test_auto_exc_without_kw_only_does_not_add_reduce(self):
730+
"""
731+
Exceptions without keyword-only init fields keep BaseException reduce.
732+
"""
733+
assert "__reduce__" not in PositionalOnlyException.__dict__
734+
627735
def test_eq_only(self, slots, frozen):
628736
"""
629737
Classes with order=False cannot be ordered.

0 commit comments

Comments
 (0)