Coverage for /opt/homebrew/lib/python3.11/site-packages/_pytest/assertion/util.py: 14%

307 statements  

« prev     ^ index     » next       coverage.py v7.2.3, created at 2023-05-04 13:14 +0700

1"""Utilities for assertion debugging.""" 

2import collections.abc 

3import os 

4import pprint 

5from typing import AbstractSet 

6from typing import Any 

7from typing import Callable 

8from typing import Iterable 

9from typing import List 

10from typing import Mapping 

11from typing import Optional 

12from typing import Sequence 

13from unicodedata import normalize 

14 

15import _pytest._code 

16from _pytest import outcomes 

17from _pytest._io.saferepr import _pformat_dispatch 

18from _pytest._io.saferepr import saferepr 

19from _pytest._io.saferepr import saferepr_unlimited 

20from _pytest.config import Config 

21 

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

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

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

25# DebugInterpreter. 

26_reprcompare: Optional[Callable[[str, object, object], Optional[str]]] = None 

27 

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

29# when pytest_runtest_setup is called. 

30_assertion_pass: Optional[Callable[[int, str, str], None]] = None 

31 

32# Config object which is assigned during pytest_runtest_protocol. 

33_config: Optional[Config] = None 

34 

35 

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

37 r"""Format an explanation. 

38 

39 Normally all embedded newlines are escaped, however there are 

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

41 cover nested explanations, see function and attribute explanations 

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

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

44 displaying diffs. 

45 """ 

46 lines = _split_explanation(explanation) 

47 result = _format_lines(lines) 

48 return "\n".join(result) 

49 

50 

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

52 r"""Return a list of individual lines in the explanation. 

53 

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

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

56 literal '\n' characters. 

57 """ 

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

59 lines = [raw_lines[0]] 

60 for values in raw_lines[1:]: 

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

62 lines.append(values) 

63 else: 

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

65 return lines 

66 

67 

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

69 """Format the individual lines. 

70 

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

72 language with the proper 'where ...', 'and ...' and ' + ...' text, taking 

73 care of indentation along the way. 

74 

75 Return a list of formatted lines. 

76 """ 

77 result = list(lines[:1]) 

78 stack = [0] 

79 stackcnt = [0] 

80 for line in lines[1:]: 

81 if line.startswith("{"): 

82 if stackcnt[-1]: 

83 s = "and " 

84 else: 

85 s = "where " 

86 stack.append(len(result)) 

87 stackcnt[-1] += 1 

88 stackcnt.append(0) 

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

90 elif line.startswith("}"): 

91 stack.pop() 

92 stackcnt.pop() 

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

94 else: 

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

96 stack[-1] += 1 

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

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

99 assert len(stack) == 1 

100 return result 

101 

102 

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

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

105 

106 

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

108 return isinstance(x, str) 

109 

110 

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

112 return isinstance(x, dict) 

113 

114 

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

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

117 

118 

119def isnamedtuple(obj: Any) -> bool: 

120 return isinstance(obj, tuple) and getattr(obj, "_fields", None) is not None 

121 

122 

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

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

125 

126 

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

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

129 

130 

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

132 try: 

133 iter(obj) 

134 return not istext(obj) 

135 except TypeError: 

136 return False 

137 

138 

139def has_default_eq( 

140 obj: object, 

141) -> bool: 

142 """Check if an instance of an object contains the default eq 

143 

144 First, we check if the object's __eq__ attribute has __code__, 

145 if so, we check the equally of the method code filename (__code__.co_filename) 

146 to the default one generated by the dataclass and attr module 

147 for dataclasses the default co_filename is <string>, for attrs class, the __eq__ should contain "attrs eq generated" 

148 """ 

149 # inspired from https://github.com/willmcgugan/rich/blob/07d51ffc1aee6f16bd2e5a25b4e82850fb9ed778/rich/pretty.py#L68 

150 if hasattr(obj.__eq__, "__code__") and hasattr(obj.__eq__.__code__, "co_filename"): 

151 code_filename = obj.__eq__.__code__.co_filename 

152 

153 if isattrs(obj): 

154 return "attrs generated eq" in code_filename 

155 

156 return code_filename == "<string>" # data class 

157 return True 

158 

159 

160def assertrepr_compare( 

161 config, op: str, left: Any, right: Any, use_ascii: bool = False 

162) -> Optional[List[str]]: 

163 """Return specialised explanations for some operators/operands.""" 

164 verbose = config.getoption("verbose") 

165 

166 # Strings which normalize equal are often hard to distinguish when printed; use ascii() to make this easier. 

167 # See issue #3246. 

168 use_ascii = ( 

169 isinstance(left, str) 

170 and isinstance(right, str) 

171 and normalize("NFD", left) == normalize("NFD", right) 

172 ) 

173 

174 if verbose > 1: 

175 left_repr = saferepr_unlimited(left, use_ascii=use_ascii) 

176 right_repr = saferepr_unlimited(right, use_ascii=use_ascii) 

177 else: 

178 # XXX: "15 chars indentation" is wrong 

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

180 maxsize = ( 

181 80 - 15 - len(op) - 2 

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

183 

184 left_repr = saferepr(left, maxsize=maxsize, use_ascii=use_ascii) 

185 right_repr = saferepr(right, maxsize=maxsize, use_ascii=use_ascii) 

186 

187 summary = f"{left_repr} {op} {right_repr}" 

188 

189 explanation = None 

190 try: 

191 if op == "==": 

192 explanation = _compare_eq_any(left, right, verbose) 

193 elif op == "not in": 

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

195 explanation = _notin_text(left, right, verbose) 

196 except outcomes.Exit: 

197 raise 

198 except Exception: 

199 explanation = [ 

200 "(pytest_assertion plugin: representation of details failed: {}.".format( 

201 _pytest._code.ExceptionInfo.from_current()._getreprcrash() 

202 ), 

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

204 ] 

205 

206 if not explanation: 

207 return None 

208 

209 return [summary] + explanation 

210 

211 

212def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]: 

213 explanation = [] 

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

215 explanation = _diff_text(left, right, verbose) 

216 else: 

217 from _pytest.python_api import ApproxBase 

218 

219 if isinstance(left, ApproxBase) or isinstance(right, ApproxBase): 

220 # Although the common order should be obtained == expected, this ensures both ways 

221 approx_side = left if isinstance(left, ApproxBase) else right 

222 other_side = right if isinstance(left, ApproxBase) else left 

223 

224 explanation = approx_side._repr_compare(other_side) 

225 elif type(left) == type(right) and ( 

226 isdatacls(left) or isattrs(left) or isnamedtuple(left) 

227 ): 

228 # Note: unlike dataclasses/attrs, namedtuples compare only the 

229 # field values, not the type or field names. But this branch 

230 # intentionally only handles the same-type case, which was often 

231 # used in older code bases before dataclasses/attrs were available. 

232 explanation = _compare_eq_cls(left, right, verbose) 

233 elif issequence(left) and issequence(right): 

234 explanation = _compare_eq_sequence(left, right, verbose) 

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

236 explanation = _compare_eq_set(left, right, verbose) 

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

238 explanation = _compare_eq_dict(left, right, verbose) 

239 

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

241 expl = _compare_eq_iterable(left, right, verbose) 

242 explanation.extend(expl) 

243 

244 return explanation 

245 

246 

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

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

249 

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

251 characters which are identical to keep the diff minimal. 

252 """ 

253 from difflib import ndiff 

254 

255 explanation: List[str] = [] 

256 

257 if verbose < 1: 

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

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

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

261 break 

262 if i > 42: 

263 i -= 10 # Provide some context 

264 explanation = [ 

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

266 ] 

267 left = left[i:] 

268 right = right[i:] 

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

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

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

272 break 

273 if i > 42: 

274 i -= 10 # Provide some context 

275 explanation += [ 

276 "Skipping {} identical trailing " 

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

278 ] 

279 left = left[:-i] 

280 right = right[:-i] 

281 keepends = True 

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

283 left = repr(str(left)) 

284 right = repr(str(right)) 

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

286 # "right" is the expected base against which we compare "left", 

287 # see https://github.com/pytest-dev/pytest/issues/3333 

288 explanation += [ 

289 line.strip("\n") 

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

291 ] 

292 return explanation 

293 

294 

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

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

297 opening = lines[0][:1] 

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

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

300 lines[:] = [opening] + lines 

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

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

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

304 lines[:] = lines + [closing] 

305 

306 

307def _compare_eq_iterable( 

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

309) -> List[str]: 

310 if verbose <= 0 and not running_on_ci(): 

311 return ["Use -v to get more diff"] 

312 # dynamic import to speedup pytest 

313 import difflib 

314 

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

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

317 

318 # Re-format for different output lengths. 

319 lines_left = len(left_formatting) 

320 lines_right = len(right_formatting) 

321 if lines_left != lines_right: 

322 left_formatting = _pformat_dispatch(left).splitlines() 

323 right_formatting = _pformat_dispatch(right).splitlines() 

324 

325 if lines_left > 1 or lines_right > 1: 

326 _surrounding_parens_on_own_lines(left_formatting) 

327 _surrounding_parens_on_own_lines(right_formatting) 

328 

329 explanation = ["Full diff:"] 

330 # "right" is the expected base against which we compare "left", 

331 # see https://github.com/pytest-dev/pytest/issues/3333 

332 explanation.extend( 

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

334 ) 

335 return explanation 

336 

337 

338def _compare_eq_sequence( 

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

340) -> List[str]: 

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

342 explanation: List[str] = [] 

343 len_left = len(left) 

344 len_right = len(right) 

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

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

347 if comparing_bytes: 

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

349 # instead of their numeric values (#5260) 

350 # using a slice gives us the ascii representation: 

351 # >>> s = b'foo' 

352 # >>> s[0] 

353 # 102 

354 # >>> s[0:1] 

355 # b'f' 

356 left_value = left[i : i + 1] 

357 right_value = right[i : i + 1] 

358 else: 

359 left_value = left[i] 

360 right_value = right[i] 

361 

362 explanation += [f"At index {i} diff: {left_value!r} != {right_value!r}"] 

363 break 

364 

365 if comparing_bytes: 

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

367 # items" longer explanation, so skip it 

368 

369 return explanation 

370 

371 len_diff = len_left - len_right 

372 if len_diff: 

373 if len_diff > 0: 

374 dir_with_more = "Left" 

375 extra = saferepr(left[len_right]) 

376 else: 

377 len_diff = 0 - len_diff 

378 dir_with_more = "Right" 

379 extra = saferepr(right[len_left]) 

380 

381 if len_diff == 1: 

382 explanation += [f"{dir_with_more} contains one more item: {extra}"] 

383 else: 

384 explanation += [ 

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

386 % (dir_with_more, len_diff, extra) 

387 ] 

388 return explanation 

389 

390 

391def _compare_eq_set( 

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

393) -> List[str]: 

394 explanation = [] 

395 diff_left = left - right 

396 diff_right = right - left 

397 if diff_left: 

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

399 for item in diff_left: 

400 explanation.append(saferepr(item)) 

401 if diff_right: 

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

403 for item in diff_right: 

404 explanation.append(saferepr(item)) 

405 return explanation 

406 

407 

408def _compare_eq_dict( 

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

410) -> List[str]: 

411 explanation: List[str] = [] 

412 set_left = set(left) 

413 set_right = set(right) 

414 common = set_left.intersection(set_right) 

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

416 if same and verbose < 2: 

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

418 elif same: 

419 explanation += ["Common items:"] 

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

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

422 if diff: 

423 explanation += ["Differing items:"] 

424 for k in diff: 

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

426 extra_left = set_left - set_right 

427 len_extra_left = len(extra_left) 

428 if len_extra_left: 

429 explanation.append( 

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

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

432 ) 

433 explanation.extend( 

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

435 ) 

436 extra_right = set_right - set_left 

437 len_extra_right = len(extra_right) 

438 if len_extra_right: 

439 explanation.append( 

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

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

442 ) 

443 explanation.extend( 

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

445 ) 

446 return explanation 

447 

448 

449def _compare_eq_cls(left: Any, right: Any, verbose: int) -> List[str]: 

450 if not has_default_eq(left): 

451 return [] 

452 if isdatacls(left): 

453 import dataclasses 

454 

455 all_fields = dataclasses.fields(left) 

456 fields_to_check = [info.name for info in all_fields if info.compare] 

457 elif isattrs(left): 

458 all_fields = left.__attrs_attrs__ 

459 fields_to_check = [field.name for field in all_fields if getattr(field, "eq")] 

460 elif isnamedtuple(left): 

461 fields_to_check = left._fields 

462 else: 

463 assert False 

464 

465 indent = " " 

466 same = [] 

467 diff = [] 

468 for field in fields_to_check: 

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

470 same.append(field) 

471 else: 

472 diff.append(field) 

473 

474 explanation = [] 

475 if same or diff: 

476 explanation += [""] 

477 if same and verbose < 2: 

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

479 elif same: 

480 explanation += ["Matching attributes:"] 

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

482 if diff: 

483 explanation += ["Differing attributes:"] 

484 explanation += pprint.pformat(diff).splitlines() 

485 for field in diff: 

486 field_left = getattr(left, field) 

487 field_right = getattr(right, field) 

488 explanation += [ 

489 "", 

490 "Drill down into differing attribute %s:" % field, 

491 ("%s%s: %r != %r") % (indent, field, field_left, field_right), 

492 ] 

493 explanation += [ 

494 indent + line 

495 for line in _compare_eq_any(field_left, field_right, verbose) 

496 ] 

497 return explanation 

498 

499 

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

501 index = text.find(term) 

502 head = text[:index] 

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

504 correct_text = head + tail 

505 diff = _diff_text(text, correct_text, verbose) 

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

507 for line in diff: 

508 if line.startswith("Skipping"): 

509 continue 

510 if line.startswith("- "): 

511 continue 

512 if line.startswith("+ "): 

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

514 else: 

515 newdiff.append(line) 

516 return newdiff 

517 

518 

519def running_on_ci() -> bool: 

520 """Check if we're currently running on a CI system.""" 

521 env_vars = ["CI", "BUILD_NUMBER"] 

522 return any(var in os.environ for var in env_vars)