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
« 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
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
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
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
32# Config object which is assigned during pytest_runtest_protocol.
33_config: Optional[Config] = None
36def format_explanation(explanation: str) -> str:
37 r"""Format an explanation.
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)
51def _split_explanation(explanation: str) -> List[str]:
52 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
68def _format_lines(lines: Sequence[str]) -> List[str]:
69 """Format the individual lines.
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.
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
103def issequence(x: Any) -> bool:
104 return isinstance(x, collections.abc.Sequence) and not isinstance(x, str)
107def istext(x: Any) -> bool:
108 return isinstance(x, str)
111def isdict(x: Any) -> bool:
112 return isinstance(x, dict)
115def isset(x: Any) -> bool:
116 return isinstance(x, (set, frozenset))
119def isnamedtuple(obj: Any) -> bool:
120 return isinstance(obj, tuple) and getattr(obj, "_fields", None) is not None
123def isdatacls(obj: Any) -> bool:
124 return getattr(obj, "__dataclass_fields__", None) is not None
127def isattrs(obj: Any) -> bool:
128 return getattr(obj, "__attrs_attrs__", None) is not None
131def isiterable(obj: Any) -> bool:
132 try:
133 iter(obj)
134 return not istext(obj)
135 except TypeError:
136 return False
139def has_default_eq(
140 obj: object,
141) -> bool:
142 """Check if an instance of an object contains the default eq
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
153 if isattrs(obj):
154 return "attrs generated eq" in code_filename
156 return code_filename == "<string>" # data class
157 return True
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")
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 )
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
184 left_repr = saferepr(left, maxsize=maxsize, use_ascii=use_ascii)
185 right_repr = saferepr(right, maxsize=maxsize, use_ascii=use_ascii)
187 summary = f"{left_repr} {op} {right_repr}"
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 ]
206 if not explanation:
207 return None
209 return [summary] + explanation
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
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
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)
240 if isiterable(left) and isiterable(right):
241 expl = _compare_eq_iterable(left, right, verbose)
242 explanation.extend(expl)
244 return explanation
247def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]:
248 """Return the explanation for the diff between text.
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
255 explanation: List[str] = []
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
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]
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
315 left_formatting = pprint.pformat(left).splitlines()
316 right_formatting = pprint.pformat(right).splitlines()
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()
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)
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
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]
362 explanation += [f"At index {i} diff: {left_value!r} != {right_value!r}"]
363 break
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
369 return explanation
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])
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
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
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
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
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
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)
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
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
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)