Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1"""Utilities for assertion debugging""" 

2import collections.abc 

3import pprint 

4from typing import AbstractSet 

5from typing import Any 

6from typing import Callable 

7from typing import Iterable 

8from typing import List 

9from typing import Mapping 

10from typing import Optional 

11from typing import Sequence 

12from typing import Tuple 

13 

14import _pytest._code 

15from _pytest import outcomes 

16from _pytest._io.saferepr import safeformat 

17from _pytest._io.saferepr import saferepr 

18from _pytest.compat import ATTRS_EQ_FIELD 

19 

20# The _reprcompare attribute on the util module is used by the new assertion 

21# interpretation code and assertion rewriter to detect this plugin was 

22# loaded and in turn call the hooks defined here as part of the 

23# DebugInterpreter. 

24_reprcompare = None # type: Optional[Callable[[str, object, object], Optional[str]]] 

25 

26# Works similarly as _reprcompare attribute. Is populated with the hook call 

27# when pytest_runtest_setup is called. 

28_assertion_pass = None # type: Optional[Callable[[int, str, str], None]] 

29 

30 

31class AlwaysDispatchingPrettyPrinter(pprint.PrettyPrinter): 

32 """PrettyPrinter that always dispatches (regardless of width).""" 

33 

34 def _format(self, object, stream, indent, allowance, context, level): 

35 p = self._dispatch.get(type(object).__repr__, None) 

36 

37 objid = id(object) 

38 if objid in context or p is None: 

39 return super()._format(object, stream, indent, allowance, context, level) 

40 

41 context[objid] = 1 

42 p(self, object, stream, indent, allowance, context, level + 1) 

43 del context[objid] 

44 

45 

46def _pformat_dispatch(object, indent=1, width=80, depth=None, *, compact=False): 

47 return AlwaysDispatchingPrettyPrinter( 

48 indent=1, width=80, depth=None, compact=False 

49 ).pformat(object) 

50 

51 

52def format_explanation(explanation: str) -> str: 

53 """This formats an explanation 

54 

55 Normally all embedded newlines are escaped, however there are 

56 three exceptions: \n{, \n} and \n~. The first two are intended 

57 cover nested explanations, see function and attribute explanations 

58 for examples (.visit_Call(), visit_Attribute()). The last one is 

59 for when one explanation needs to span multiple lines, e.g. when 

60 displaying diffs. 

61 """ 

62 lines = _split_explanation(explanation) 

63 result = _format_lines(lines) 

64 return "\n".join(result) 

65 

66 

67def _split_explanation(explanation: str) -> List[str]: 

68 """Return a list of individual lines in the explanation 

69 

70 This will return a list of lines split on '\n{', '\n}' and '\n~'. 

71 Any other newlines will be escaped and appear in the line as the 

72 literal '\n' characters. 

73 """ 

74 raw_lines = (explanation or "").split("\n") 

75 lines = [raw_lines[0]] 

76 for values in raw_lines[1:]: 

77 if values and values[0] in ["{", "}", "~", ">"]: 

78 lines.append(values) 

79 else: 

80 lines[-1] += "\\n" + values 

81 return lines 

82 

83 

84def _format_lines(lines: Sequence[str]) -> List[str]: 

85 """Format the individual lines 

86 

87 This will replace the '{', '}' and '~' characters of our mini 

88 formatting language with the proper 'where ...', 'and ...' and ' + 

89 ...' text, taking care of indentation along the way. 

90 

91 Return a list of formatted lines. 

92 """ 

93 result = list(lines[:1]) 

94 stack = [0] 

95 stackcnt = [0] 

96 for line in lines[1:]: 

97 if line.startswith("{"): 

98 if stackcnt[-1]: 

99 s = "and " 

100 else: 

101 s = "where " 

102 stack.append(len(result)) 

103 stackcnt[-1] += 1 

104 stackcnt.append(0) 

105 result.append(" +" + " " * (len(stack) - 1) + s + line[1:]) 

106 elif line.startswith("}"): 

107 stack.pop() 

108 stackcnt.pop() 

109 result[stack[-1]] += line[1:] 

110 else: 

111 assert line[0] in ["~", ">"] 

112 stack[-1] += 1 

113 indent = len(stack) if line.startswith("~") else len(stack) - 1 

114 result.append(" " * indent + line[1:]) 

115 assert len(stack) == 1 

116 return result 

117 

118 

119def issequence(x: Any) -> bool: 

120 return isinstance(x, collections.abc.Sequence) and not isinstance(x, str) 

121 

122 

123def istext(x: Any) -> bool: 

124 return isinstance(x, str) 

125 

126 

127def isdict(x: Any) -> bool: 

128 return isinstance(x, dict) 

129 

130 

131def isset(x: Any) -> bool: 

132 return isinstance(x, (set, frozenset)) 

133 

134 

135def isdatacls(obj: Any) -> bool: 

136 return getattr(obj, "__dataclass_fields__", None) is not None 

137 

138 

139def isattrs(obj: Any) -> bool: 

140 return getattr(obj, "__attrs_attrs__", None) is not None 

141 

142 

143def isiterable(obj: Any) -> bool: 

144 try: 

145 iter(obj) 

146 return not istext(obj) 

147 except TypeError: 

148 return False 

149 

150 

151def assertrepr_compare(config, op: str, left: Any, right: Any) -> Optional[List[str]]: 

152 """Return specialised explanations for some operators/operands""" 

153 verbose = config.getoption("verbose") 

154 if verbose > 1: 

155 left_repr = safeformat(left) 

156 right_repr = safeformat(right) 

157 else: 

158 # XXX: "15 chars indentation" is wrong 

159 # ("E AssertionError: assert "); should use term width. 

160 maxsize = ( 

161 80 - 15 - len(op) - 2 

162 ) // 2 # 15 chars indentation, 1 space around op 

163 left_repr = saferepr(left, maxsize=maxsize) 

164 right_repr = saferepr(right, maxsize=maxsize) 

165 

166 summary = "{} {} {}".format(left_repr, op, right_repr) 

167 

168 explanation = None 

169 try: 

170 if op == "==": 

171 if istext(left) and istext(right): 

172 explanation = _diff_text(left, right, verbose) 

173 else: 

174 if issequence(left) and issequence(right): 

175 explanation = _compare_eq_sequence(left, right, verbose) 

176 elif isset(left) and isset(right): 

177 explanation = _compare_eq_set(left, right, verbose) 

178 elif isdict(left) and isdict(right): 

179 explanation = _compare_eq_dict(left, right, verbose) 

180 elif type(left) == type(right) and (isdatacls(left) or isattrs(left)): 

181 type_fn = (isdatacls, isattrs) 

182 explanation = _compare_eq_cls(left, right, verbose, type_fn) 

183 elif verbose > 0: 

184 explanation = _compare_eq_verbose(left, right) 

185 if isiterable(left) and isiterable(right): 

186 expl = _compare_eq_iterable(left, right, verbose) 

187 if explanation is not None: 

188 explanation.extend(expl) 

189 else: 

190 explanation = expl 

191 elif op == "not in": 

192 if istext(left) and istext(right): 

193 explanation = _notin_text(left, right, verbose) 

194 except outcomes.Exit: 

195 raise 

196 except Exception: 

197 explanation = [ 

198 "(pytest_assertion plugin: representation of details failed. " 

199 "Probably an object has a faulty __repr__.)", 

200 str(_pytest._code.ExceptionInfo.from_current()), 

201 ] 

202 

203 if not explanation: 

204 return None 

205 

206 return [summary] + explanation 

207 

208 

209def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]: 

210 """Return the explanation for the diff between text. 

211 

212 Unless --verbose is used this will skip leading and trailing 

213 characters which are identical to keep the diff minimal. 

214 """ 

215 from difflib import ndiff 

216 

217 explanation = [] # type: List[str] 

218 

219 if verbose < 1: 

220 i = 0 # just in case left or right has zero length 

221 for i in range(min(len(left), len(right))): 

222 if left[i] != right[i]: 

223 break 

224 if i > 42: 

225 i -= 10 # Provide some context 

226 explanation = [ 

227 "Skipping %s identical leading characters in diff, use -v to show" % i 

228 ] 

229 left = left[i:] 

230 right = right[i:] 

231 if len(left) == len(right): 

232 for i in range(len(left)): 

233 if left[-i] != right[-i]: 

234 break 

235 if i > 42: 

236 i -= 10 # Provide some context 

237 explanation += [ 

238 "Skipping {} identical trailing " 

239 "characters in diff, use -v to show".format(i) 

240 ] 

241 left = left[:-i] 

242 right = right[:-i] 

243 keepends = True 

244 if left.isspace() or right.isspace(): 

245 left = repr(str(left)) 

246 right = repr(str(right)) 

247 explanation += ["Strings contain only whitespace, escaping them using repr()"] 

248 explanation += [ 

249 line.strip("\n") 

250 for line in ndiff(left.splitlines(keepends), right.splitlines(keepends)) 

251 ] 

252 return explanation 

253 

254 

255def _compare_eq_verbose(left: Any, right: Any) -> List[str]: 

256 keepends = True 

257 left_lines = repr(left).splitlines(keepends) 

258 right_lines = repr(right).splitlines(keepends) 

259 

260 explanation = [] # type: List[str] 

261 explanation += ["-" + line for line in left_lines] 

262 explanation += ["+" + line for line in right_lines] 

263 

264 return explanation 

265 

266 

267def _surrounding_parens_on_own_lines(lines: List[str]) -> None: 

268 """Move opening/closing parenthesis/bracket to own lines.""" 

269 opening = lines[0][:1] 

270 if opening in ["(", "[", "{"]: 

271 lines[0] = " " + lines[0][1:] 

272 lines[:] = [opening] + lines 

273 closing = lines[-1][-1:] 

274 if closing in [")", "]", "}"]: 

275 lines[-1] = lines[-1][:-1] + "," 

276 lines[:] = lines + [closing] 

277 

278 

279def _compare_eq_iterable( 

280 left: Iterable[Any], right: Iterable[Any], verbose: int = 0 

281) -> List[str]: 

282 if not verbose: 

283 return ["Use -v to get the full diff"] 

284 # dynamic import to speedup pytest 

285 import difflib 

286 

287 left_formatting = pprint.pformat(left).splitlines() 

288 right_formatting = pprint.pformat(right).splitlines() 

289 

290 # Re-format for different output lengths. 

291 lines_left = len(left_formatting) 

292 lines_right = len(right_formatting) 

293 if lines_left != lines_right: 

294 left_formatting = _pformat_dispatch(left).splitlines() 

295 right_formatting = _pformat_dispatch(right).splitlines() 

296 

297 if lines_left > 1 or lines_right > 1: 

298 _surrounding_parens_on_own_lines(left_formatting) 

299 _surrounding_parens_on_own_lines(right_formatting) 

300 

301 explanation = ["Full diff:"] 

302 explanation.extend( 

303 line.rstrip() for line in difflib.ndiff(left_formatting, right_formatting) 

304 ) 

305 return explanation 

306 

307 

308def _compare_eq_sequence( 

309 left: Sequence[Any], right: Sequence[Any], verbose: int = 0 

310) -> List[str]: 

311 comparing_bytes = isinstance(left, bytes) and isinstance(right, bytes) 

312 explanation = [] # type: List[str] 

313 len_left = len(left) 

314 len_right = len(right) 

315 for i in range(min(len_left, len_right)): 

316 if left[i] != right[i]: 

317 if comparing_bytes: 

318 # when comparing bytes, we want to see their ascii representation 

319 # instead of their numeric values (#5260) 

320 # using a slice gives us the ascii representation: 

321 # >>> s = b'foo' 

322 # >>> s[0] 

323 # 102 

324 # >>> s[0:1] 

325 # b'f' 

326 left_value = left[i : i + 1] 

327 right_value = right[i : i + 1] 

328 else: 

329 left_value = left[i] 

330 right_value = right[i] 

331 

332 explanation += [ 

333 "At index {} diff: {!r} != {!r}".format(i, left_value, right_value) 

334 ] 

335 break 

336 

337 if comparing_bytes: 

338 # when comparing bytes, it doesn't help to show the "sides contain one or more items" 

339 # longer explanation, so skip it 

340 return explanation 

341 

342 len_diff = len_left - len_right 

343 if len_diff: 

344 if len_diff > 0: 

345 dir_with_more = "Left" 

346 extra = saferepr(left[len_right]) 

347 else: 

348 len_diff = 0 - len_diff 

349 dir_with_more = "Right" 

350 extra = saferepr(right[len_left]) 

351 

352 if len_diff == 1: 

353 explanation += [ 

354 "{} contains one more item: {}".format(dir_with_more, extra) 

355 ] 

356 else: 

357 explanation += [ 

358 "%s contains %d more items, first extra item: %s" 

359 % (dir_with_more, len_diff, extra) 

360 ] 

361 return explanation 

362 

363 

364def _compare_eq_set( 

365 left: AbstractSet[Any], right: AbstractSet[Any], verbose: int = 0 

366) -> List[str]: 

367 explanation = [] 

368 diff_left = left - right 

369 diff_right = right - left 

370 if diff_left: 

371 explanation.append("Extra items in the left set:") 

372 for item in diff_left: 

373 explanation.append(saferepr(item)) 

374 if diff_right: 

375 explanation.append("Extra items in the right set:") 

376 for item in diff_right: 

377 explanation.append(saferepr(item)) 

378 return explanation 

379 

380 

381def _compare_eq_dict( 

382 left: Mapping[Any, Any], right: Mapping[Any, Any], verbose: int = 0 

383) -> List[str]: 

384 explanation = [] # type: List[str] 

385 set_left = set(left) 

386 set_right = set(right) 

387 common = set_left.intersection(set_right) 

388 same = {k: left[k] for k in common if left[k] == right[k]} 

389 if same and verbose < 2: 

390 explanation += ["Omitting %s identical items, use -vv to show" % len(same)] 

391 elif same: 

392 explanation += ["Common items:"] 

393 explanation += pprint.pformat(same).splitlines() 

394 diff = {k for k in common if left[k] != right[k]} 

395 if diff: 

396 explanation += ["Differing items:"] 

397 for k in diff: 

398 explanation += [saferepr({k: left[k]}) + " != " + saferepr({k: right[k]})] 

399 extra_left = set_left - set_right 

400 len_extra_left = len(extra_left) 

401 if len_extra_left: 

402 explanation.append( 

403 "Left contains %d more item%s:" 

404 % (len_extra_left, "" if len_extra_left == 1 else "s") 

405 ) 

406 explanation.extend( 

407 pprint.pformat({k: left[k] for k in extra_left}).splitlines() 

408 ) 

409 extra_right = set_right - set_left 

410 len_extra_right = len(extra_right) 

411 if len_extra_right: 

412 explanation.append( 

413 "Right contains %d more item%s:" 

414 % (len_extra_right, "" if len_extra_right == 1 else "s") 

415 ) 

416 explanation.extend( 

417 pprint.pformat({k: right[k] for k in extra_right}).splitlines() 

418 ) 

419 return explanation 

420 

421 

422def _compare_eq_cls( 

423 left: Any, 

424 right: Any, 

425 verbose: int, 

426 type_fns: Tuple[Callable[[Any], bool], Callable[[Any], bool]], 

427) -> List[str]: 

428 isdatacls, isattrs = type_fns 

429 if isdatacls(left): 

430 all_fields = left.__dataclass_fields__ 

431 fields_to_check = [field for field, info in all_fields.items() if info.compare] 

432 elif isattrs(left): 

433 all_fields = left.__attrs_attrs__ 

434 fields_to_check = [ 

435 field.name for field in all_fields if getattr(field, ATTRS_EQ_FIELD) 

436 ] 

437 

438 same = [] 

439 diff = [] 

440 for field in fields_to_check: 

441 if getattr(left, field) == getattr(right, field): 

442 same.append(field) 

443 else: 

444 diff.append(field) 

445 

446 explanation = [] 

447 if same and verbose < 2: 

448 explanation.append("Omitting %s identical items, use -vv to show" % len(same)) 

449 elif same: 

450 explanation += ["Matching attributes:"] 

451 explanation += pprint.pformat(same).splitlines() 

452 if diff: 

453 explanation += ["Differing attributes:"] 

454 for field in diff: 

455 explanation += [ 

456 ("%s: %r != %r") % (field, getattr(left, field), getattr(right, field)) 

457 ] 

458 return explanation 

459 

460 

461def _notin_text(term: str, text: str, verbose: int = 0) -> List[str]: 

462 index = text.find(term) 

463 head = text[:index] 

464 tail = text[index + len(term) :] 

465 correct_text = head + tail 

466 diff = _diff_text(correct_text, text, verbose) 

467 newdiff = ["%s is contained here:" % saferepr(term, maxsize=42)] 

468 for line in diff: 

469 if line.startswith("Skipping"): 

470 continue 

471 if line.startswith("- "): 

472 continue 

473 if line.startswith("+ "): 

474 newdiff.append(" " + line[2:]) 

475 else: 

476 newdiff.append(line) 

477 return newdiff