Coverage for dataexcept/base.py: 93%
62 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-03 20:46 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-03 20:46 +0000
1"""The root of the DataExcept exception hierarchy.
3Every operational exception this package defines derives from
4:class:`DataExceptError`, so a caller can catch the whole library with one
5clause while still catching narrowly where it matters::
7 try:
8 run_pipeline()
9 except ValidationError:
10 ... # exactly this failure
11 except DataExceptError:
12 ... # anything else DataExcept raised
14(Constructors also raise plain ``TypeError`` when given invalid arguments.
15Those are programming errors, not operational ones, and are deliberately not
16part of this hierarchy.)
18The base also carries the serialization contract for the hierarchy. Two
19problems make that necessary:
21* Most constructors take several arguments while ``Exception.args`` holds only
22 the rendered message, so the default protocol -- which replays ``args``
23 through ``__init__`` -- cannot rebuild them.
24* Several exceptions accept arbitrary caller state (``DataValidationError``
25 takes any ``value``), and that state may not be pickleable at all.
27An exception that cannot cross a process boundary is useless exactly where a
28data pipeline needs it most, so rather than fail, unpickleable state is
29replaced by a description of what was there.
30"""
32from __future__ import annotations
34import pickle
35from typing import Any, Dict, Optional, Tuple, Type
37from .redaction import redact_urls_in_text
39__all__ = ["DataExceptError", "UnpicklableCause", "UnpicklableValue"]
41#: Attribute names used across the package to hold the exception that caused
42#: this one. Checked in order; the first that holds an exception wins.
43_CAUSE_ATTRIBUTES = ("original", "original_exception", "cause")
46class UnpicklableValue:
47 """Stands in for state that could not survive serialization.
49 An exception carrying a lambda, an open file or a lock would otherwise be
50 unraisable across a process boundary. Keeping a description preserves what
51 the value was for debugging, which is the reason it was attached.
52 """
54 __slots__ = ("description",)
56 def __init__(self, description: str) -> None:
57 self.description = description
59 def __repr__(self) -> str:
60 return f"<unpicklable: {self.description}>"
62 def __str__(self) -> str:
63 return self.__repr__()
65 def __eq__(self, other: object) -> bool:
66 return (
67 isinstance(other, UnpicklableValue)
68 and other.description == self.description
69 )
71 def __hash__(self) -> int:
72 return hash(self.description)
75def _safe(value: Any) -> Any:
76 """Return *value*, or a placeholder if it cannot be pickled."""
77 try:
78 pickle.dumps(value)
79 except Exception:
80 try:
81 description = f"{type(value).__name__}: {value!r}"
82 except Exception: # pragma: no cover - a repr that itself raises
83 description = type(value).__name__
84 return UnpicklableValue(description[:200])
85 return value
88def _safe_exception(exc: Optional[BaseException]) -> Optional[BaseException]:
89 """Return *exc*, or an exception describing it if it cannot be pickled."""
90 if exc is None:
91 return None
92 try:
93 pickle.dumps(exc)
94 except Exception:
95 return UnpicklableCause(f"{type(exc).__name__}: {exc}")
96 return exc
99def _rebuild(
100 cls: Type["DataExceptError"],
101 args: Tuple[Any, ...],
102 state: Dict[str, Any],
103 cause: Optional[BaseException] = None,
104 context: Optional[BaseException] = None,
105 suppress_context: bool = False,
106) -> "DataExceptError":
107 """Recreate *cls* without replaying its ``__init__``.
109 Constructors validate and render a message from their arguments; replaying
110 them would need those arguments, which ``args`` does not carry. Restoring
111 ``args`` and ``__dict__`` directly reproduces the exception exactly.
113 The three chain arguments carry defaults so that a payload pickled by an
114 earlier version -- which passed only ``cls``, ``args`` and ``state`` --
115 still loads. An exception can outlive an upgrade: it may sit in a task
116 queue, or be sent by a worker running the previous release.
118 ``__cause__``, ``__context__`` and ``__suppress_context__`` live outside
119 ``__dict__`` -- they are special exception state -- so they are restored
120 explicitly. Without this the chain is silently lost, and a traceback
121 rebuilt in another process no longer shows what actually failed.
122 """
123 exc = cls.__new__(cls)
124 Exception.__init__(exc, *args)
125 exc.__dict__.update(state)
126 exc.__cause__ = cause
127 exc.__context__ = context
128 exc.__suppress_context__ = suppress_context
129 return exc
132class DataExceptError(Exception):
133 """Base class for every operational exception DataExcept raises."""
135 #: Passed to redact_urls_in_text when scrubbing this class's message.
136 #: WebhookError sets it False, because a webhook URL's path *is* the
137 #: credential.
138 _keep_url_path = True
140 def __init__(self, *args: Any) -> None:
141 # One boundary for the whole hierarchy. Whatever built the message -- a
142 # constructor, a caller-supplied `message`, or the text of a wrapped
143 # exception quoting the original URL -- it is scrubbed here, because
144 # redacting only the structured argument leaves all three routes open.
145 keep_path = type(self)._keep_url_path
146 if args and isinstance(args[0], str): 146 ↛ 158line 146 didn't jump to line 158 because the condition on line 146 was always true
147 args = (redact_urls_in_text(args[0], keep_path=keep_path),) + args[1:]
149 # Many classes store the message on self.message and render *that* in
150 # __str__, and 18 interpolate some other attribute -- a field, a
151 # column, a resource -- any of which a caller can fill with a URL. So
152 # every stored string is swept, not just the message.
153 #
154 # redact_urls_in_text rather than redact_if_url: a message has the URL
155 # embedded in prose, and redact_if_url only handles a value that is
156 # wholly a URL. It is a no-op on anything without "://" in it, so
157 # ordinary names and file paths are untouched.
158 for name, value in list(self.__dict__.items()):
159 if isinstance(value, str) and "://" in value:
160 self.__dict__[name] = redact_urls_in_text(value, keep_path=keep_path)
162 super().__init__(*args)
163 # Constructors that wrap another exception record it on an attribute.
164 # Mirroring it into __cause__ is what makes a traceback print the
165 # underlying failure, exactly as `raise ... from exc` would; assigning
166 # __cause__ also sets __suppress_context__, as `raise from` does.
167 for attribute in _CAUSE_ATTRIBUTES:
168 candidate = getattr(self, attribute, None)
169 if isinstance(candidate, BaseException):
170 self.__cause__ = candidate
171 break
173 def __reduce__(self) -> Tuple[Any, Tuple[Any, ...]]:
174 args = tuple(_safe(arg) for arg in self.args)
175 state = {key: _safe(value) for key, value in self.__dict__.items()}
176 return (
177 _rebuild,
178 (
179 type(self),
180 args,
181 state,
182 _safe_exception(self.__cause__),
183 _safe_exception(self.__context__),
184 self.__suppress_context__,
185 ),
186 )
189class UnpicklableCause(DataExceptError):
190 """Stands in for a cause that could not be serialized.
192 ``__cause__`` and ``__context__`` must be exceptions, so the placeholder
193 used for ordinary attributes will not do here. Dropping the chain instead
194 would silently lose the reason for the failure.
195 """