Coverage for dataexcept/serialization.py: 85%

120 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-03 20:46 +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 .redaction import redact_urls_in_text 

12 

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

14 

15_MAX_VALUE_DEPTH = 8 

16_NOT_SCALAR = object() 

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

18 

19 

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

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

22 

23 Normal DataExcept messages preserve URL paths because paths are commonly 

24 useful debugging context. Structured envelopes have a stricter boundary: 

25 third-party errors and arbitrary caller state may put credentials in the 

26 path itself, so exported text never preserves URL paths. 

27 """ 

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)): 50 ↛ 51line 50 didn't jump to line 51 because the condition on line 50 was never true

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)): 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true

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)): 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true

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 _exception_record( 

136 exc: BaseException, 

137 *, 

138 include_attributes: bool, 

139 max_depth: int, 

140 depth: int, 

141 seen: set[int], 

142) -> dict[str, Any]: 

143 if depth > max_depth: 

144 return {"truncated": True} 

145 

146 identity = id(exc) 

147 if identity in seen: 

148 return { 

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

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

151 "message": _safe_text(exc), 

152 "cycle": True, 

153 } 

154 

155 seen.add(identity) 

156 record: dict[str, Any] = { 

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

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

159 "message": _safe_text(exc), 

160 } 

161 if include_attributes: 

162 attributes = _attributes(exc) 

163 if attributes: 

164 record["attributes"] = attributes 

165 

166 members = _group_members(exc) 

167 if members is not None: 

168 record["exceptions"] = [ 

169 _exception_record( 

170 member, 

171 include_attributes=include_attributes, 

172 max_depth=max_depth, 

173 depth=depth + 1, 

174 seen=seen, 

175 ) 

176 for member in members 

177 ] 

178 

179 if exc.__cause__ is not None: 

180 record["cause"] = _exception_record( 

181 exc.__cause__, 

182 include_attributes=include_attributes, 

183 max_depth=max_depth, 

184 depth=depth + 1, 

185 seen=seen, 

186 ) 

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

188 record["context"] = _exception_record( 

189 exc.__context__, 

190 include_attributes=include_attributes, 

191 max_depth=max_depth, 

192 depth=depth + 1, 

193 seen=seen, 

194 ) 

195 

196 seen.discard(identity) 

197 return record 

198 

199 

200def exception_to_dict( 

201 exc: BaseException, 

202 *, 

203 include_attributes: bool = True, 

204 max_depth: int = 8, 

205) -> dict[str, Any]: 

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

207 

208 The representation contains the exception type, module and rendered 

209 message, optionally public instance attributes, bounded cause/context 

210 chains, and on Python 3.11+ the member tree of exception groups. Traceback 

211 frames and private attributes are deliberately excluded. 

212 """ 

213 if not isinstance(exc, BaseException): 

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

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

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

217 if max_depth < 0: 

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

219 return _exception_record( 

220 exc, 

221 include_attributes=include_attributes, 

222 max_depth=max_depth, 

223 depth=0, 

224 seen=set(), 

225 ) 

226 

227 

228def exception_to_json( 

229 exc: BaseException, 

230 *, 

231 include_attributes: bool = True, 

232 max_depth: int = 8, 

233 **json_kwargs: Any, 

234) -> str: 

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

236 json_kwargs["allow_nan"] = False 

237 return json.dumps( 

238 exception_to_dict( 

239 exc, 

240 include_attributes=include_attributes, 

241 max_depth=max_depth, 

242 ), 

243 **json_kwargs, 

244 )