util.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615
  1. # mypy: allow-untyped-defs
  2. """Utilities for assertion debugging."""
  3. from __future__ import annotations
  4. import collections.abc
  5. from collections.abc import Callable
  6. from collections.abc import Iterable
  7. from collections.abc import Mapping
  8. from collections.abc import Sequence
  9. from collections.abc import Set as AbstractSet
  10. import pprint
  11. from typing import Any
  12. from typing import Literal
  13. from typing import Protocol
  14. from unicodedata import normalize
  15. from _pytest import outcomes
  16. import _pytest._code
  17. from _pytest._io.pprint import PrettyPrinter
  18. from _pytest._io.saferepr import saferepr
  19. from _pytest._io.saferepr import saferepr_unlimited
  20. from _pytest.compat import running_on_ci
  21. from _pytest.config import Config
  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: Callable[[str, object, object], str | None] | None = None
  27. # Works similarly as _reprcompare attribute. Is populated with the hook call
  28. # when pytest_runtest_setup is called.
  29. _assertion_pass: Callable[[int, str, str], None] | None = None
  30. # Config object which is assigned during pytest_runtest_protocol.
  31. _config: Config | None = None
  32. class _HighlightFunc(Protocol):
  33. def __call__(self, source: str, lexer: Literal["diff", "python"] = "python") -> str:
  34. """Apply highlighting to the given source."""
  35. def dummy_highlighter(source: str, lexer: Literal["diff", "python"] = "python") -> str:
  36. """Dummy highlighter that returns the text unprocessed.
  37. Needed for _notin_text, as the diff gets post-processed to only show the "+" part.
  38. """
  39. return source
  40. def format_explanation(explanation: str) -> str:
  41. r"""Format an explanation.
  42. Normally all embedded newlines are escaped, however there are
  43. three exceptions: \n{, \n} and \n~. The first two are intended
  44. cover nested explanations, see function and attribute explanations
  45. for examples (.visit_Call(), visit_Attribute()). The last one is
  46. for when one explanation needs to span multiple lines, e.g. when
  47. displaying diffs.
  48. """
  49. lines = _split_explanation(explanation)
  50. result = _format_lines(lines)
  51. return "\n".join(result)
  52. def _split_explanation(explanation: str) -> list[str]:
  53. r"""Return a list of individual lines in the explanation.
  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. def _format_lines(lines: Sequence[str]) -> list[str]:
  67. """Format the individual lines.
  68. This will replace the '{', '}' and '~' characters of our mini formatting
  69. language with the proper 'where ...', 'and ...' and ' + ...' text, taking
  70. care of indentation along the way.
  71. Return a list of formatted lines.
  72. """
  73. result = list(lines[:1])
  74. stack = [0]
  75. stackcnt = [0]
  76. for line in lines[1:]:
  77. if line.startswith("{"):
  78. if stackcnt[-1]:
  79. s = "and "
  80. else:
  81. s = "where "
  82. stack.append(len(result))
  83. stackcnt[-1] += 1
  84. stackcnt.append(0)
  85. result.append(" +" + " " * (len(stack) - 1) + s + line[1:])
  86. elif line.startswith("}"):
  87. stack.pop()
  88. stackcnt.pop()
  89. result[stack[-1]] += line[1:]
  90. else:
  91. assert line[0] in ["~", ">"]
  92. stack[-1] += 1
  93. indent = len(stack) if line.startswith("~") else len(stack) - 1
  94. result.append(" " * indent + line[1:])
  95. assert len(stack) == 1
  96. return result
  97. def issequence(x: Any) -> bool:
  98. return isinstance(x, collections.abc.Sequence) and not isinstance(x, str)
  99. def istext(x: Any) -> bool:
  100. return isinstance(x, str)
  101. def isdict(x: Any) -> bool:
  102. return isinstance(x, dict)
  103. def isset(x: Any) -> bool:
  104. return isinstance(x, set | frozenset)
  105. def isnamedtuple(obj: Any) -> bool:
  106. return isinstance(obj, tuple) and getattr(obj, "_fields", None) is not None
  107. def isdatacls(obj: Any) -> bool:
  108. return getattr(obj, "__dataclass_fields__", None) is not None
  109. def isattrs(obj: Any) -> bool:
  110. return getattr(obj, "__attrs_attrs__", None) is not None
  111. def isiterable(obj: Any) -> bool:
  112. try:
  113. iter(obj)
  114. return not istext(obj)
  115. except Exception:
  116. return False
  117. def has_default_eq(
  118. obj: object,
  119. ) -> bool:
  120. """Check if an instance of an object contains the default eq
  121. First, we check if the object's __eq__ attribute has __code__,
  122. if so, we check the equally of the method code filename (__code__.co_filename)
  123. to the default one generated by the dataclass and attr module
  124. for dataclasses the default co_filename is <string>, for attrs class, the __eq__ should contain "attrs eq generated"
  125. """
  126. # inspired from https://github.com/willmcgugan/rich/blob/07d51ffc1aee6f16bd2e5a25b4e82850fb9ed778/rich/pretty.py#L68
  127. if hasattr(obj.__eq__, "__code__") and hasattr(obj.__eq__.__code__, "co_filename"):
  128. code_filename = obj.__eq__.__code__.co_filename
  129. if isattrs(obj):
  130. return "attrs generated " in code_filename
  131. return code_filename == "<string>" # data class
  132. return True
  133. def assertrepr_compare(
  134. config, op: str, left: Any, right: Any, use_ascii: bool = False
  135. ) -> list[str] | None:
  136. """Return specialised explanations for some operators/operands."""
  137. verbose = config.get_verbosity(Config.VERBOSITY_ASSERTIONS)
  138. # Strings which normalize equal are often hard to distinguish when printed; use ascii() to make this easier.
  139. # See issue #3246.
  140. use_ascii = (
  141. isinstance(left, str)
  142. and isinstance(right, str)
  143. and normalize("NFD", left) == normalize("NFD", right)
  144. )
  145. if verbose > 1:
  146. left_repr = saferepr_unlimited(left, use_ascii=use_ascii)
  147. right_repr = saferepr_unlimited(right, use_ascii=use_ascii)
  148. else:
  149. # XXX: "15 chars indentation" is wrong
  150. # ("E AssertionError: assert "); should use term width.
  151. maxsize = (
  152. 80 - 15 - len(op) - 2
  153. ) // 2 # 15 chars indentation, 1 space around op
  154. left_repr = saferepr(left, maxsize=maxsize, use_ascii=use_ascii)
  155. right_repr = saferepr(right, maxsize=maxsize, use_ascii=use_ascii)
  156. summary = f"{left_repr} {op} {right_repr}"
  157. highlighter = config.get_terminal_writer()._highlight
  158. explanation = None
  159. try:
  160. if op == "==":
  161. explanation = _compare_eq_any(left, right, highlighter, verbose)
  162. elif op == "not in":
  163. if istext(left) and istext(right):
  164. explanation = _notin_text(left, right, verbose)
  165. elif op == "!=":
  166. if isset(left) and isset(right):
  167. explanation = ["Both sets are equal"]
  168. elif op == ">=":
  169. if isset(left) and isset(right):
  170. explanation = _compare_gte_set(left, right, highlighter, verbose)
  171. elif op == "<=":
  172. if isset(left) and isset(right):
  173. explanation = _compare_lte_set(left, right, highlighter, verbose)
  174. elif op == ">":
  175. if isset(left) and isset(right):
  176. explanation = _compare_gt_set(left, right, highlighter, verbose)
  177. elif op == "<":
  178. if isset(left) and isset(right):
  179. explanation = _compare_lt_set(left, right, highlighter, verbose)
  180. except outcomes.Exit:
  181. raise
  182. except Exception:
  183. repr_crash = _pytest._code.ExceptionInfo.from_current()._getreprcrash()
  184. explanation = [
  185. f"(pytest_assertion plugin: representation of details failed: {repr_crash}.",
  186. " Probably an object has a faulty __repr__.)",
  187. ]
  188. if not explanation:
  189. return None
  190. if explanation[0] != "":
  191. explanation = ["", *explanation]
  192. return [summary, *explanation]
  193. def _compare_eq_any(
  194. left: Any, right: Any, highlighter: _HighlightFunc, verbose: int = 0
  195. ) -> list[str]:
  196. explanation = []
  197. if istext(left) and istext(right):
  198. explanation = _diff_text(left, right, highlighter, verbose)
  199. else:
  200. from _pytest.python_api import ApproxBase
  201. if isinstance(left, ApproxBase) or isinstance(right, ApproxBase):
  202. # Although the common order should be obtained == expected, this ensures both ways
  203. approx_side = left if isinstance(left, ApproxBase) else right
  204. other_side = right if isinstance(left, ApproxBase) else left
  205. explanation = approx_side._repr_compare(other_side)
  206. elif type(left) is type(right) and (
  207. isdatacls(left) or isattrs(left) or isnamedtuple(left)
  208. ):
  209. # Note: unlike dataclasses/attrs, namedtuples compare only the
  210. # field values, not the type or field names. But this branch
  211. # intentionally only handles the same-type case, which was often
  212. # used in older code bases before dataclasses/attrs were available.
  213. explanation = _compare_eq_cls(left, right, highlighter, verbose)
  214. elif issequence(left) and issequence(right):
  215. explanation = _compare_eq_sequence(left, right, highlighter, verbose)
  216. elif isset(left) and isset(right):
  217. explanation = _compare_eq_set(left, right, highlighter, verbose)
  218. elif isdict(left) and isdict(right):
  219. explanation = _compare_eq_dict(left, right, highlighter, verbose)
  220. if isiterable(left) and isiterable(right):
  221. expl = _compare_eq_iterable(left, right, highlighter, verbose)
  222. explanation.extend(expl)
  223. return explanation
  224. def _diff_text(
  225. left: str, right: str, highlighter: _HighlightFunc, verbose: int = 0
  226. ) -> list[str]:
  227. """Return the explanation for the diff between text.
  228. Unless --verbose is used this will skip leading and trailing
  229. characters which are identical to keep the diff minimal.
  230. """
  231. from difflib import ndiff
  232. explanation: list[str] = []
  233. if verbose < 1:
  234. i = 0 # just in case left or right has zero length
  235. for i in range(min(len(left), len(right))):
  236. if left[i] != right[i]:
  237. break
  238. if i > 42:
  239. i -= 10 # Provide some context
  240. explanation = [
  241. f"Skipping {i} identical leading characters in diff, use -v to show"
  242. ]
  243. left = left[i:]
  244. right = right[i:]
  245. if len(left) == len(right):
  246. for i in range(len(left)):
  247. if left[-i] != right[-i]:
  248. break
  249. if i > 42:
  250. i -= 10 # Provide some context
  251. explanation += [
  252. f"Skipping {i} identical trailing "
  253. "characters in diff, use -v to show"
  254. ]
  255. left = left[:-i]
  256. right = right[:-i]
  257. keepends = True
  258. if left.isspace() or right.isspace():
  259. left = repr(str(left))
  260. right = repr(str(right))
  261. explanation += ["Strings contain only whitespace, escaping them using repr()"]
  262. # "right" is the expected base against which we compare "left",
  263. # see https://github.com/pytest-dev/pytest/issues/3333
  264. explanation.extend(
  265. highlighter(
  266. "\n".join(
  267. line.strip("\n")
  268. for line in ndiff(right.splitlines(keepends), left.splitlines(keepends))
  269. ),
  270. lexer="diff",
  271. ).splitlines()
  272. )
  273. return explanation
  274. def _compare_eq_iterable(
  275. left: Iterable[Any],
  276. right: Iterable[Any],
  277. highlighter: _HighlightFunc,
  278. verbose: int = 0,
  279. ) -> list[str]:
  280. if verbose <= 0 and not running_on_ci():
  281. return ["Use -v to get more diff"]
  282. # dynamic import to speedup pytest
  283. import difflib
  284. left_formatting = PrettyPrinter().pformat(left).splitlines()
  285. right_formatting = PrettyPrinter().pformat(right).splitlines()
  286. explanation = ["", "Full diff:"]
  287. # "right" is the expected base against which we compare "left",
  288. # see https://github.com/pytest-dev/pytest/issues/3333
  289. explanation.extend(
  290. highlighter(
  291. "\n".join(
  292. line.rstrip()
  293. for line in difflib.ndiff(right_formatting, left_formatting)
  294. ),
  295. lexer="diff",
  296. ).splitlines()
  297. )
  298. return explanation
  299. def _compare_eq_sequence(
  300. left: Sequence[Any],
  301. right: Sequence[Any],
  302. highlighter: _HighlightFunc,
  303. verbose: int = 0,
  304. ) -> list[str]:
  305. comparing_bytes = isinstance(left, bytes) and isinstance(right, bytes)
  306. explanation: list[str] = []
  307. len_left = len(left)
  308. len_right = len(right)
  309. for i in range(min(len_left, len_right)):
  310. if left[i] != right[i]:
  311. if comparing_bytes:
  312. # when comparing bytes, we want to see their ascii representation
  313. # instead of their numeric values (#5260)
  314. # using a slice gives us the ascii representation:
  315. # >>> s = b'foo'
  316. # >>> s[0]
  317. # 102
  318. # >>> s[0:1]
  319. # b'f'
  320. left_value = left[i : i + 1]
  321. right_value = right[i : i + 1]
  322. else:
  323. left_value = left[i]
  324. right_value = right[i]
  325. explanation.append(
  326. f"At index {i} diff:"
  327. f" {highlighter(repr(left_value))} != {highlighter(repr(right_value))}"
  328. )
  329. break
  330. if comparing_bytes:
  331. # when comparing bytes, it doesn't help to show the "sides contain one or more
  332. # items" longer explanation, so skip it
  333. return explanation
  334. len_diff = len_left - len_right
  335. if len_diff:
  336. if len_diff > 0:
  337. dir_with_more = "Left"
  338. extra = saferepr(left[len_right])
  339. else:
  340. len_diff = 0 - len_diff
  341. dir_with_more = "Right"
  342. extra = saferepr(right[len_left])
  343. if len_diff == 1:
  344. explanation += [
  345. f"{dir_with_more} contains one more item: {highlighter(extra)}"
  346. ]
  347. else:
  348. explanation += [
  349. f"{dir_with_more} contains {len_diff} more items, first extra item: {highlighter(extra)}"
  350. ]
  351. return explanation
  352. def _compare_eq_set(
  353. left: AbstractSet[Any],
  354. right: AbstractSet[Any],
  355. highlighter: _HighlightFunc,
  356. verbose: int = 0,
  357. ) -> list[str]:
  358. explanation = []
  359. explanation.extend(_set_one_sided_diff("left", left, right, highlighter))
  360. explanation.extend(_set_one_sided_diff("right", right, left, highlighter))
  361. return explanation
  362. def _compare_gt_set(
  363. left: AbstractSet[Any],
  364. right: AbstractSet[Any],
  365. highlighter: _HighlightFunc,
  366. verbose: int = 0,
  367. ) -> list[str]:
  368. explanation = _compare_gte_set(left, right, highlighter)
  369. if not explanation:
  370. return ["Both sets are equal"]
  371. return explanation
  372. def _compare_lt_set(
  373. left: AbstractSet[Any],
  374. right: AbstractSet[Any],
  375. highlighter: _HighlightFunc,
  376. verbose: int = 0,
  377. ) -> list[str]:
  378. explanation = _compare_lte_set(left, right, highlighter)
  379. if not explanation:
  380. return ["Both sets are equal"]
  381. return explanation
  382. def _compare_gte_set(
  383. left: AbstractSet[Any],
  384. right: AbstractSet[Any],
  385. highlighter: _HighlightFunc,
  386. verbose: int = 0,
  387. ) -> list[str]:
  388. return _set_one_sided_diff("right", right, left, highlighter)
  389. def _compare_lte_set(
  390. left: AbstractSet[Any],
  391. right: AbstractSet[Any],
  392. highlighter: _HighlightFunc,
  393. verbose: int = 0,
  394. ) -> list[str]:
  395. return _set_one_sided_diff("left", left, right, highlighter)
  396. def _set_one_sided_diff(
  397. posn: str,
  398. set1: AbstractSet[Any],
  399. set2: AbstractSet[Any],
  400. highlighter: _HighlightFunc,
  401. ) -> list[str]:
  402. explanation = []
  403. diff = set1 - set2
  404. if diff:
  405. explanation.append(f"Extra items in the {posn} set:")
  406. for item in diff:
  407. explanation.append(highlighter(saferepr(item)))
  408. return explanation
  409. def _compare_eq_dict(
  410. left: Mapping[Any, Any],
  411. right: Mapping[Any, Any],
  412. highlighter: _HighlightFunc,
  413. verbose: int = 0,
  414. ) -> list[str]:
  415. explanation: list[str] = []
  416. set_left = set(left)
  417. set_right = set(right)
  418. common = set_left.intersection(set_right)
  419. same = {k: left[k] for k in common if left[k] == right[k]}
  420. if same and verbose < 2:
  421. explanation += [f"Omitting {len(same)} identical items, use -vv to show"]
  422. elif same:
  423. explanation += ["Common items:"]
  424. explanation += highlighter(pprint.pformat(same)).splitlines()
  425. diff = {k for k in common if left[k] != right[k]}
  426. if diff:
  427. explanation += ["Differing items:"]
  428. for k in diff:
  429. explanation += [
  430. highlighter(saferepr({k: left[k]}))
  431. + " != "
  432. + highlighter(saferepr({k: right[k]}))
  433. ]
  434. extra_left = set_left - set_right
  435. len_extra_left = len(extra_left)
  436. if len_extra_left:
  437. explanation.append(
  438. f"Left contains {len_extra_left} more item{'' if len_extra_left == 1 else 's'}:"
  439. )
  440. explanation.extend(
  441. highlighter(pprint.pformat({k: left[k] for k in extra_left})).splitlines()
  442. )
  443. extra_right = set_right - set_left
  444. len_extra_right = len(extra_right)
  445. if len_extra_right:
  446. explanation.append(
  447. f"Right contains {len_extra_right} more item{'' if len_extra_right == 1 else 's'}:"
  448. )
  449. explanation.extend(
  450. highlighter(pprint.pformat({k: right[k] for k in extra_right})).splitlines()
  451. )
  452. return explanation
  453. def _compare_eq_cls(
  454. left: Any, right: Any, highlighter: _HighlightFunc, verbose: int
  455. ) -> list[str]:
  456. if not has_default_eq(left):
  457. return []
  458. if isdatacls(left):
  459. import dataclasses
  460. all_fields = dataclasses.fields(left)
  461. fields_to_check = [info.name for info in all_fields if info.compare]
  462. elif isattrs(left):
  463. all_fields = left.__attrs_attrs__
  464. fields_to_check = [field.name for field in all_fields if getattr(field, "eq")]
  465. elif isnamedtuple(left):
  466. fields_to_check = left._fields
  467. else:
  468. assert False
  469. indent = " "
  470. same = []
  471. diff = []
  472. for field in fields_to_check:
  473. if getattr(left, field) == getattr(right, field):
  474. same.append(field)
  475. else:
  476. diff.append(field)
  477. explanation = []
  478. if same or diff:
  479. explanation += [""]
  480. if same and verbose < 2:
  481. explanation.append(f"Omitting {len(same)} identical items, use -vv to show")
  482. elif same:
  483. explanation += ["Matching attributes:"]
  484. explanation += highlighter(pprint.pformat(same)).splitlines()
  485. if diff:
  486. explanation += ["Differing attributes:"]
  487. explanation += highlighter(pprint.pformat(diff)).splitlines()
  488. for field in diff:
  489. field_left = getattr(left, field)
  490. field_right = getattr(right, field)
  491. explanation += [
  492. "",
  493. f"Drill down into differing attribute {field}:",
  494. f"{indent}{field}: {highlighter(repr(field_left))} != {highlighter(repr(field_right))}",
  495. ]
  496. explanation += [
  497. indent + line
  498. for line in _compare_eq_any(
  499. field_left, field_right, highlighter, verbose
  500. )
  501. ]
  502. return explanation
  503. def _notin_text(term: str, text: str, verbose: int = 0) -> list[str]:
  504. index = text.find(term)
  505. head = text[:index]
  506. tail = text[index + len(term) :]
  507. correct_text = head + tail
  508. diff = _diff_text(text, correct_text, dummy_highlighter, verbose)
  509. newdiff = [f"{saferepr(term, maxsize=42)} is contained here:"]
  510. for line in diff:
  511. if line.startswith("Skipping"):
  512. continue
  513. if line.startswith("- "):
  514. continue
  515. if line.startswith("+ "):
  516. newdiff.append(" " + line[2:])
  517. else:
  518. newdiff.append(line)
  519. return newdiff