Skip to content

Commit 96d5b30

Browse files
Refactor DqliteError pickle plumbing to __getstate__/__setstate__
The previous __reduce__-with-dict-overlay shape captured state behind self.__dict__.copy(), hiding it from stack traces and making subclass refactors that add slot-based attributes silently lossy. Replace with explicit __getstate__ / __setstate__ so the captured state is visible and subclasses can override one method each rather than re-implementing __reduce__. Behaviour is unchanged for in-tree subclasses (all carry their extra fields on __dict__); the refactor is for future-maintenance clarity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4dd31f6 commit 96d5b30

1 file changed

Lines changed: 38 additions & 20 deletions

File tree

src/dqliteclient/exceptions.py

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Exceptions for dqlite client."""
22

3-
from typing import ClassVar
3+
from typing import Any, ClassVar
44

55
from dqlitewire.exceptions import ProtocolError as _WireProtocolError
66

@@ -40,28 +40,46 @@ def __init__(self, *args: object, raw_message: str | None = None) -> None:
4040
super().__init__(*args)
4141
self.raw_message = raw_message
4242

43+
def __getstate__(self) -> dict[str, object]:
44+
"""State capture for pickle.
45+
46+
Default ``Exception.__reduce__`` returns ``(cls, self.args)``
47+
which reconstructs via ``cls(*args)``. That loses every
48+
field set on the instance after ``Exception.__init__`` —
49+
most notably ``raw_message`` (set on the ``DqliteError``
50+
base) and the ``code`` carried by ``DqliteConnectionError``.
51+
The carriers exist precisely so the wire-level signal
52+
survives cross-process pickling (``ProcessPoolExecutor``,
53+
``multiprocessing.Queue``, Celery task results, SA's
54+
multiprocess pool).
55+
56+
Pair with :meth:`__setstate__` so the explicit
57+
``__getstate__`` / ``__setstate__`` shape replaces the
58+
previous ``__reduce__``-with-dict-overlay. The new shape
59+
makes the captured state visible in stack traces (no longer
60+
hidden behind ``self.__dict__.copy()``) and tolerates
61+
future subclass refactors that add slot-based attributes
62+
(those slot values can be added to the dict explicitly in
63+
the subclass override rather than silently lost behind
64+
``__dict__``).
65+
"""
66+
return self.__dict__.copy()
67+
68+
def __setstate__(self, state: dict[str, Any] | None) -> None:
69+
if state:
70+
self.__dict__.update(state)
71+
4372
def __reduce__(
4473
self,
4574
) -> tuple[type["DqliteError"], tuple[object, ...], dict[str, object]]:
46-
# Default ``Exception.__reduce__`` returns ``(cls, self.args)``,
47-
# which reconstructs via ``cls(*args)``. That loses every field
48-
# set on the instance after ``Exception.__init__`` — most
49-
# notably ``raw_message`` (set on the ``DqliteError`` base) and
50-
# the ``code`` carried by ``DqliteConnectionError``. The
51-
# carriers were added in cycle 27 (XP2 / XP3) precisely so the
52-
# wire-level signal would survive cross-process pickling
53-
# (``ProcessPoolExecutor``, ``multiprocessing.Queue``, Celery
54-
# task results, SA's multiprocess pool); without overriding
55-
# ``__reduce__`` the round-trip silently drops them.
56-
#
57-
# Return the 3-tuple ``(callable, args, state)`` form so pickle
58-
# reconstructs via ``cls(*args)`` then applies state via
59-
# ``self.__dict__.update(state)`` — preserving every attribute
60-
# we set on the instance (``raw_message`` on the base, ``code``
61-
# on subclasses, the truncated ``message`` on
62-
# ``OperationalError``). All subclasses of ``DqliteError``
63-
# inherit this discipline automatically.
64-
return (self.__class__, self.args, self.__dict__.copy())
75+
# 3-tuple form: pickle reconstructs via ``cls(*args)`` then
76+
# invokes ``__setstate__`` with the dict from
77+
# ``__getstate__``. Subclasses inherit this discipline
78+
# automatically — every subclass we ship today carries its
79+
# extra fields on ``__dict__`` (no ``__slots__``), so the
80+
# default ``__getstate__`` returning ``self.__dict__.copy()``
81+
# captures them.
82+
return (self.__class__, self.args, self.__getstate__())
6583

6684

6785
class DqliteConnectionError(DqliteError):

0 commit comments

Comments
 (0)