Coverage for dataexcept/serialization.py: 91%

147 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-27 14:44 +0000

1"""Strict JSON-safe structured representations of exceptions.""" 

2 

3from __future__ import annotations 

4 

5import builtins 

6import json 

7import math 

8from collections.abc import Mapping, Sequence, Set 

9from typing import Any 

10 

11from .base import DataExceptError 

12from .redaction import redact_urls_in_text 

13 

14__all__ = ["exception_to_dict", "exception_to_json"] 

15 

16_MAX_VALUE_DEPTH = 8 

17_NOT_SCALAR = object() 

18_EXCEPTION_GROUP_TYPE = getattr(builtins, "BaseExceptionGroup", None) 

19_UNKNOWN_FAILURE = { 

20 "kind": "unknown", 

21 "retryable": None, 

22 "retry_after_seconds": None, 

23} 

24 

25 

26def _redact_export_text(text: str) -> str: 

27 """Scrub URLs for export, including their paths.""" 

28 return redact_urls_in_text(text, keep_path=False) 

29 

30 

31def _safe_text(value: Any) -> str: 

32 """Render *value* without raising and scrub credential-bearing URLs.""" 

33 try: 

34 text = str(value) 

35 except Exception: 

36 try: 

37 text = f"<unrepresentable {type(value).__name__}>" 

38 except Exception: # pragma: no cover - hostile type metadata 

39 text = "<unrepresentable>" 

40 return _redact_export_text(text) 

41 

42 

43def _safe_key(value: Any) -> str: 

44 if isinstance(value, str): 44 ↛ 46line 44 didn't jump to line 46 because the condition on line 44 was always true

45 return _redact_export_text(value) 

46 return _safe_text(value) 

47 

48 

49def _safe_scalar(value: Any) -> Any: 

50 if value is None or isinstance(value, (bool, int)): 

51 return value 

52 if isinstance(value, float): 

53 return value if math.isfinite(value) else str(value) 

54 if isinstance(value, str): 

55 return _redact_export_text(value) 

56 if isinstance(value, (bytes, bytearray)): 

57 return _safe_text(value) 

58 return _NOT_SCALAR 

59 

60 

61def _safe_mapping(value: Mapping[Any, Any], *, depth: int, seen: set[int]) -> Any: 

62 identity = id(value) 

63 seen.add(identity) 

64 try: 

65 return { 

66 _safe_key(key): _json_safe(item, depth=depth + 1, seen=seen) 

67 for key, item in value.items() 

68 } 

69 except Exception: 

70 return _safe_text(value) 

71 finally: 

72 seen.discard(identity) 

73 

74 

75def _safe_collection( 

76 value: Sequence[Any] | Set[Any], *, depth: int, seen: set[int] 

77) -> Any: 

78 identity = id(value) 

79 seen.add(identity) 

80 try: 

81 return [_json_safe(item, depth=depth + 1, seen=seen) for item in value] 

82 except Exception: 

83 return _safe_text(value) 

84 finally: 

85 seen.discard(identity) 

86 

87 

88def _json_safe(value: Any, *, depth: int = 0, seen: set[int] | None = None) -> Any: 

89 """Return *value* in a strict JSON-safe form without raising.""" 

90 if seen is None: 

91 seen = set() 

92 scalar = _safe_scalar(value) 

93 if scalar is not _NOT_SCALAR: 

94 return scalar 

95 if depth >= _MAX_VALUE_DEPTH: 95 ↛ 96line 95 didn't jump to line 96 because the condition on line 95 was never true

96 return "<truncated>" 

97 if id(value) in seen: 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true

98 return "<cycle>" 

99 if isinstance(value, Mapping): 

100 return _safe_mapping(value, depth=depth, seen=seen) 

101 if isinstance(value, (Sequence, Set)): 

102 return _safe_collection(value, depth=depth, seen=seen) 

103 return _safe_text(value) 

104 

105 

106def _attributes(exc: BaseException) -> dict[str, Any]: 

107 """Return public instance attributes in a JSON-safe representation.""" 

108 try: 

109 state = vars(exc) 

110 result: dict[str, Any] = {} 

111 for name, value in state.items(): 

112 if not isinstance(name, str) or name.startswith("_"): 

113 continue 

114 result[name] = _json_safe(value) 

115 return result 

116 except Exception: 

117 return {} 

118 

119 

120def _group_members(exc: BaseException) -> Sequence[BaseException] | None: 

121 """Return exception-group members without importing a 3.11-only symbol.""" 

122 if _EXCEPTION_GROUP_TYPE is None or not isinstance(exc, _EXCEPTION_GROUP_TYPE): 

123 return None 

124 try: 

125 members: object = getattr(exc, "exceptions", None) 

126 if not isinstance(members, tuple): 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true

127 return None 

128 if not all(isinstance(member, BaseException) for member in members): 128 ↛ 129line 128 didn't jump to line 129 because the condition on line 128 was never true

129 return None 

130 return members 

131 except Exception: 

132 return None 

133 

134 

135def _failure_record(exc: DataExceptError) -> dict[str, Any]: 

136 """Return failure metadata without letting hostile overrides escape.""" 

137 try: 

138 metadata = exc.failure_metadata 

139 failure = { 

140 "kind": metadata.failure_kind, 

141 "retryable": metadata.retryable, 

142 "retry_after_seconds": metadata.retry_after_seconds, 

143 } 

144 if failure["kind"] not in {"transient", "permanent", "unknown"}: 144 ↛ 145line 144 didn't jump to line 145 because the condition on line 144 was never true

145 raise ValueError("invalid failure kind") 

146 if failure["retryable"] is not None and not isinstance( 146 ↛ 149line 146 didn't jump to line 149 because the condition on line 146 was never true

147 failure["retryable"], bool 

148 ): 

149 raise TypeError("invalid retryable value") 

150 

151 retry_after = failure["retry_after_seconds"] 

152 if retry_after is not None: 

153 if isinstance(retry_after, bool) or not isinstance( 

154 retry_after, (int, float) 

155 ): 

156 raise TypeError("invalid retry_after_seconds value") 

157 retry_after = float(retry_after) 

158 if not math.isfinite(retry_after) or retry_after < 0: 

159 raise ValueError("invalid retry_after_seconds value") 

160 failure["retry_after_seconds"] = retry_after 

161 

162 json.dumps(failure, allow_nan=False) 

163 return failure 

164 except Exception: 

165 return dict(_UNKNOWN_FAILURE) 

166 

167 

168def _failure_fields(exc: BaseException) -> dict[str, Any]: 

169 """Return the optional structured failure field for *exc*.""" 

170 if not isinstance(exc, DataExceptError): 

171 return {} 

172 return {"failure": _failure_record(exc)} 

173 

174 

175def _exception_record( 

176 exc: BaseException, 

177 *, 

178 include_attributes: bool, 

179 max_depth: int, 

180 depth: int, 

181 seen: set[int], 

182) -> dict[str, Any]: 

183 if depth > max_depth: 

184 return {"truncated": True} 

185 

186 identity = id(exc) 

187 if identity in seen: 

188 return { 

189 "type": type(exc).__name__, 

190 "module": type(exc).__module__, 

191 "message": _safe_text(exc), 

192 "cycle": True, 

193 } 

194 

195 seen.add(identity) 

196 record: dict[str, Any] = { 

197 "type": type(exc).__name__, 

198 "module": type(exc).__module__, 

199 "message": _safe_text(exc), 

200 } 

201 record.update(_failure_fields(exc)) 

202 if include_attributes: 

203 attributes = _attributes(exc) 

204 if attributes: 

205 record["attributes"] = attributes 

206 

207 members = _group_members(exc) 

208 if members is not None: 

209 record["exceptions"] = [ 

210 _exception_record( 

211 member, 

212 include_attributes=include_attributes, 

213 max_depth=max_depth, 

214 depth=depth + 1, 

215 seen=seen, 

216 ) 

217 for member in members 

218 ] 

219 

220 if exc.__cause__ is not None: 

221 record["cause"] = _exception_record( 

222 exc.__cause__, 

223 include_attributes=include_attributes, 

224 max_depth=max_depth, 

225 depth=depth + 1, 

226 seen=seen, 

227 ) 

228 if exc.__context__ is not None and not exc.__suppress_context__: 

229 record["context"] = _exception_record( 

230 exc.__context__, 

231 include_attributes=include_attributes, 

232 max_depth=max_depth, 

233 depth=depth + 1, 

234 seen=seen, 

235 ) 

236 

237 seen.discard(identity) 

238 return record 

239 

240 

241def exception_to_dict( 

242 exc: BaseException, 

243 *, 

244 include_attributes: bool = True, 

245 max_depth: int = 8, 

246) -> dict[str, Any]: 

247 """Return a strict JSON-safe structured representation of *exc*.""" 

248 if not isinstance(exc, BaseException): 

249 raise TypeError("exc must be an exception instance") 

250 if not isinstance(max_depth, int) or isinstance(max_depth, bool): 

251 raise TypeError("max_depth must be an integer") 

252 if max_depth < 0: 

253 raise ValueError("max_depth must be non-negative") 

254 return _exception_record( 

255 exc, 

256 include_attributes=include_attributes, 

257 max_depth=max_depth, 

258 depth=0, 

259 seen=set(), 

260 ) 

261 

262 

263def exception_to_json( 

264 exc: BaseException, 

265 *, 

266 include_attributes: bool = True, 

267 max_depth: int = 8, 

268 **json_kwargs: Any, 

269) -> str: 

270 """Return :func:`exception_to_dict` encoded as strict JSON.""" 

271 json_kwargs["allow_nan"] = False 

272 return json.dumps( 

273 exception_to_dict( 

274 exc, 

275 include_attributes=include_attributes, 

276 max_depth=max_depth, 

277 ), 

278 **json_kwargs, 

279 )