Coverage for dataexcept/base.py: 93%

62 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-03 20:46 +0000

1"""The root of the DataExcept exception hierarchy. 

2 

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:: 

6 

7 try: 

8 run_pipeline() 

9 except ValidationError: 

10 ... # exactly this failure 

11 except DataExceptError: 

12 ... # anything else DataExcept raised 

13 

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.) 

17 

18The base also carries the serialization contract for the hierarchy. Two 

19problems make that necessary: 

20 

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. 

26 

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""" 

31 

32from __future__ import annotations 

33 

34import pickle 

35from typing import Any, Dict, Optional, Tuple, Type 

36 

37from .redaction import redact_urls_in_text 

38 

39__all__ = ["DataExceptError", "UnpicklableCause", "UnpicklableValue"] 

40 

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") 

44 

45 

46class UnpicklableValue: 

47 """Stands in for state that could not survive serialization. 

48 

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 """ 

53 

54 __slots__ = ("description",) 

55 

56 def __init__(self, description: str) -> None: 

57 self.description = description 

58 

59 def __repr__(self) -> str: 

60 return f"<unpicklable: {self.description}>" 

61 

62 def __str__(self) -> str: 

63 return self.__repr__() 

64 

65 def __eq__(self, other: object) -> bool: 

66 return ( 

67 isinstance(other, UnpicklableValue) 

68 and other.description == self.description 

69 ) 

70 

71 def __hash__(self) -> int: 

72 return hash(self.description) 

73 

74 

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 

86 

87 

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 

97 

98 

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__``. 

108 

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. 

112 

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. 

117 

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 

130 

131 

132class DataExceptError(Exception): 

133 """Base class for every operational exception DataExcept raises.""" 

134 

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 

139 

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:] 

148 

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) 

161 

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 

172 

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 ) 

187 

188 

189class UnpicklableCause(DataExceptError): 

190 """Stands in for a cause that could not be serialized. 

191 

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 """