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 _pformat_dispatch 17from _pytest._io.saferepr import safeformat 18from _pytest._io.saferepr import saferepr 19from _pytest.compat import ATTRS_EQ_FIELD 20 21# The _reprcompare attribute on the util module is used by the new assertion 22# interpretation code and assertion rewriter to detect this plugin was 23# loaded and in turn call the hooks defined here as part of the 24# DebugInterpreter. 25_reprcompare = None # type: Optional[Callable[[str, object, object], Optional[str]]] 26 27# Works similarly as _reprcompare attribute. Is populated with the hook call 28# when pytest_runtest_setup is called. 29_assertion_pass = None # type: Optional[Callable[[int, str, str], None]] 30 31 32def format_explanation(explanation: str) -> str: 33 r"""Format an explanation. 34 35 Normally all embedded newlines are escaped, however there are 36 three exceptions: \n{, \n} and \n~. The first two are intended 37 cover nested explanations, see function and attribute explanations 38 for examples (.visit_Call(), visit_Attribute()). The last one is 39 for when one explanation needs to span multiple lines, e.g. when 40 displaying diffs. 41 """ 42 lines = _split_explanation(explanation) 43 result = _format_lines(lines) 44 return "\n".join(result) 45 46 47def _split_explanation(explanation: str) -> List[str]: 48 r"""Return a list of individual lines in the explanation. 49 50 This will return a list of lines split on '\n{', '\n}' and '\n~'. 51 Any other newlines will be escaped and appear in the line as the 52 literal '\n' characters. 53 """ 54 raw_lines = (explanation or "").split("\n") 55 lines = [raw_lines[0]] 56 for values in raw_lines[1:]: 57 if values and values[0] in ["{", "}", "~", ">"]: 58 lines.append(values) 59 else: 60 lines[-1] += "\\n" + values 61 return lines 62 63 64def _format_lines(lines: Sequence[str]) -> List[str]: 65 """Format the individual lines. 66 67 This will replace the '{', '}' and '~' characters of our mini formatting 68 language with the proper 'where ...', 'and ...' and ' + ...' text, taking 69 care of indentation along the way. 70 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 98 99def issequence(x: Any) -> bool: 100 return isinstance(x, collections.abc.Sequence) and not isinstance(x, str) 101 102 103def istext(x: Any) -> bool: 104 return isinstance(x, str) 105 106 107def isdict(x: Any) -> bool: 108 return isinstance(x, dict) 109 110 111def isset(x: Any) -> bool: 112 return isinstance(x, (set, frozenset)) 113 114 115def isdatacls(obj: Any) -> bool: 116 return getattr(obj, "__dataclass_fields__", None) is not None 117 118 119def isattrs(obj: Any) -> bool: 120 return getattr(obj, "__attrs_attrs__", None) is not None 121 122 123def isiterable(obj: Any) -> bool: 124 try: 125 iter(obj) 126 return not istext(obj) 127 except TypeError: 128 return False 129 130 131def assertrepr_compare(config, op: str, left: Any, right: Any) -> Optional[List[str]]: 132 """Return specialised explanations for some operators/operands.""" 133 verbose = config.getoption("verbose") 134 if verbose > 1: 135 left_repr = safeformat(left) 136 right_repr = safeformat(right) 137 else: 138 # XXX: "15 chars indentation" is wrong 139 # ("E AssertionError: assert "); should use term width. 140 maxsize = ( 141 80 - 15 - len(op) - 2 142 ) // 2 # 15 chars indentation, 1 space around op 143 left_repr = saferepr(left, maxsize=maxsize) 144 right_repr = saferepr(right, maxsize=maxsize) 145 146 summary = "{} {} {}".format(left_repr, op, right_repr) 147 148 explanation = None 149 try: 150 if op == "==": 151 explanation = _compare_eq_any(left, right, verbose) 152 elif op == "not in": 153 if istext(left) and istext(right): 154 explanation = _notin_text(left, right, verbose) 155 except outcomes.Exit: 156 raise 157 except Exception: 158 explanation = [ 159 "(pytest_assertion plugin: representation of details failed: {}.".format( 160 _pytest._code.ExceptionInfo.from_current()._getreprcrash() 161 ), 162 " Probably an object has a faulty __repr__.)", 163 ] 164 165 if not explanation: 166 return None 167 168 return [summary] + explanation 169 170 171def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]: 172 explanation = [] 173 if istext(left) and istext(right): 174 explanation = _diff_text(left, right, verbose) 175 else: 176 if issequence(left) and issequence(right): 177 explanation = _compare_eq_sequence(left, right, verbose) 178 elif isset(left) and isset(right): 179 explanation = _compare_eq_set(left, right, verbose) 180 elif isdict(left) and isdict(right): 181 explanation = _compare_eq_dict(left, right, verbose) 182 elif type(left) == type(right) and (isdatacls(left) or isattrs(left)): 183 type_fn = (isdatacls, isattrs) 184 explanation = _compare_eq_cls(left, right, verbose, type_fn) 185 elif verbose > 0: 186 explanation = _compare_eq_verbose(left, right) 187 if isiterable(left) and isiterable(right): 188 expl = _compare_eq_iterable(left, right, verbose) 189 explanation.extend(expl) 190 return explanation 191 192 193def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]: 194 """Return the explanation for the diff between text. 195 196 Unless --verbose is used this will skip leading and trailing 197 characters which are identical to keep the diff minimal. 198 """ 199 from difflib import ndiff 200 201 explanation = [] # type: List[str] 202 203 if verbose < 1: 204 i = 0 # just in case left or right has zero length 205 for i in range(min(len(left), len(right))): 206 if left[i] != right[i]: 207 break 208 if i > 42: 209 i -= 10 # Provide some context 210 explanation = [ 211 "Skipping %s identical leading characters in diff, use -v to show" % i 212 ] 213 left = left[i:] 214 right = right[i:] 215 if len(left) == len(right): 216 for i in range(len(left)): 217 if left[-i] != right[-i]: 218 break 219 if i > 42: 220 i -= 10 # Provide some context 221 explanation += [ 222 "Skipping {} identical trailing " 223 "characters in diff, use -v to show".format(i) 224 ] 225 left = left[:-i] 226 right = right[:-i] 227 keepends = True 228 if left.isspace() or right.isspace(): 229 left = repr(str(left)) 230 right = repr(str(right)) 231 explanation += ["Strings contain only whitespace, escaping them using repr()"] 232 # "right" is the expected base against which we compare "left", 233 # see https://github.com/pytest-dev/pytest/issues/3333 234 explanation += [ 235 line.strip("\n") 236 for line in ndiff(right.splitlines(keepends), left.splitlines(keepends)) 237 ] 238 return explanation 239 240 241def _compare_eq_verbose(left: Any, right: Any) -> List[str]: 242 keepends = True 243 left_lines = repr(left).splitlines(keepends) 244 right_lines = repr(right).splitlines(keepends) 245 246 explanation = [] # type: List[str] 247 explanation += ["+" + line for line in left_lines] 248 explanation += ["-" + line for line in right_lines] 249 250 return explanation 251 252 253def _surrounding_parens_on_own_lines(lines: List[str]) -> None: 254 """Move opening/closing parenthesis/bracket to own lines.""" 255 opening = lines[0][:1] 256 if opening in ["(", "[", "{"]: 257 lines[0] = " " + lines[0][1:] 258 lines[:] = [opening] + lines 259 closing = lines[-1][-1:] 260 if closing in [")", "]", "}"]: 261 lines[-1] = lines[-1][:-1] + "," 262 lines[:] = lines + [closing] 263 264 265def _compare_eq_iterable( 266 left: Iterable[Any], right: Iterable[Any], verbose: int = 0 267) -> List[str]: 268 if not verbose: 269 return ["Use -v to get the full diff"] 270 # dynamic import to speedup pytest 271 import difflib 272 273 left_formatting = pprint.pformat(left).splitlines() 274 right_formatting = pprint.pformat(right).splitlines() 275 276 # Re-format for different output lengths. 277 lines_left = len(left_formatting) 278 lines_right = len(right_formatting) 279 if lines_left != lines_right: 280 left_formatting = _pformat_dispatch(left).splitlines() 281 right_formatting = _pformat_dispatch(right).splitlines() 282 283 if lines_left > 1 or lines_right > 1: 284 _surrounding_parens_on_own_lines(left_formatting) 285 _surrounding_parens_on_own_lines(right_formatting) 286 287 explanation = ["Full diff:"] 288 # "right" is the expected base against which we compare "left", 289 # see https://github.com/pytest-dev/pytest/issues/3333 290 explanation.extend( 291 line.rstrip() for line in difflib.ndiff(right_formatting, left_formatting) 292 ) 293 return explanation 294 295 296def _compare_eq_sequence( 297 left: Sequence[Any], right: Sequence[Any], verbose: int = 0 298) -> List[str]: 299 comparing_bytes = isinstance(left, bytes) and isinstance(right, bytes) 300 explanation = [] # type: List[str] 301 len_left = len(left) 302 len_right = len(right) 303 for i in range(min(len_left, len_right)): 304 if left[i] != right[i]: 305 if comparing_bytes: 306 # when comparing bytes, we want to see their ascii representation 307 # instead of their numeric values (#5260) 308 # using a slice gives us the ascii representation: 309 # >>> s = b'foo' 310 # >>> s[0] 311 # 102 312 # >>> s[0:1] 313 # b'f' 314 left_value = left[i : i + 1] 315 right_value = right[i : i + 1] 316 else: 317 left_value = left[i] 318 right_value = right[i] 319 320 explanation += [ 321 "At index {} diff: {!r} != {!r}".format(i, left_value, right_value) 322 ] 323 break 324 325 if comparing_bytes: 326 # when comparing bytes, it doesn't help to show the "sides contain one or more 327 # items" longer explanation, so skip it 328 329 return explanation 330 331 len_diff = len_left - len_right 332 if len_diff: 333 if len_diff > 0: 334 dir_with_more = "Left" 335 extra = saferepr(left[len_right]) 336 else: 337 len_diff = 0 - len_diff 338 dir_with_more = "Right" 339 extra = saferepr(right[len_left]) 340 341 if len_diff == 1: 342 explanation += [ 343 "{} contains one more item: {}".format(dir_with_more, extra) 344 ] 345 else: 346 explanation += [ 347 "%s contains %d more items, first extra item: %s" 348 % (dir_with_more, len_diff, extra) 349 ] 350 return explanation 351 352 353def _compare_eq_set( 354 left: AbstractSet[Any], right: AbstractSet[Any], verbose: int = 0 355) -> List[str]: 356 explanation = [] 357 diff_left = left - right 358 diff_right = right - left 359 if diff_left: 360 explanation.append("Extra items in the left set:") 361 for item in diff_left: 362 explanation.append(saferepr(item)) 363 if diff_right: 364 explanation.append("Extra items in the right set:") 365 for item in diff_right: 366 explanation.append(saferepr(item)) 367 return explanation 368 369 370def _compare_eq_dict( 371 left: Mapping[Any, Any], right: Mapping[Any, Any], verbose: int = 0 372) -> List[str]: 373 explanation = [] # type: List[str] 374 set_left = set(left) 375 set_right = set(right) 376 common = set_left.intersection(set_right) 377 same = {k: left[k] for k in common if left[k] == right[k]} 378 if same and verbose < 2: 379 explanation += ["Omitting %s identical items, use -vv to show" % len(same)] 380 elif same: 381 explanation += ["Common items:"] 382 explanation += pprint.pformat(same).splitlines() 383 diff = {k for k in common if left[k] != right[k]} 384 if diff: 385 explanation += ["Differing items:"] 386 for k in diff: 387 explanation += [saferepr({k: left[k]}) + " != " + saferepr({k: right[k]})] 388 extra_left = set_left - set_right 389 len_extra_left = len(extra_left) 390 if len_extra_left: 391 explanation.append( 392 "Left contains %d more item%s:" 393 % (len_extra_left, "" if len_extra_left == 1 else "s") 394 ) 395 explanation.extend( 396 pprint.pformat({k: left[k] for k in extra_left}).splitlines() 397 ) 398 extra_right = set_right - set_left 399 len_extra_right = len(extra_right) 400 if len_extra_right: 401 explanation.append( 402 "Right contains %d more item%s:" 403 % (len_extra_right, "" if len_extra_right == 1 else "s") 404 ) 405 explanation.extend( 406 pprint.pformat({k: right[k] for k in extra_right}).splitlines() 407 ) 408 return explanation 409 410 411def _compare_eq_cls( 412 left: Any, 413 right: Any, 414 verbose: int, 415 type_fns: Tuple[Callable[[Any], bool], Callable[[Any], bool]], 416) -> List[str]: 417 isdatacls, isattrs = type_fns 418 if isdatacls(left): 419 all_fields = left.__dataclass_fields__ 420 fields_to_check = [field for field, info in all_fields.items() if info.compare] 421 elif isattrs(left): 422 all_fields = left.__attrs_attrs__ 423 fields_to_check = [ 424 field.name for field in all_fields if getattr(field, ATTRS_EQ_FIELD) 425 ] 426 427 indent = " " 428 same = [] 429 diff = [] 430 for field in fields_to_check: 431 if getattr(left, field) == getattr(right, field): 432 same.append(field) 433 else: 434 diff.append(field) 435 436 explanation = [] 437 if same or diff: 438 explanation += [""] 439 if same and verbose < 2: 440 explanation.append("Omitting %s identical items, use -vv to show" % len(same)) 441 elif same: 442 explanation += ["Matching attributes:"] 443 explanation += pprint.pformat(same).splitlines() 444 if diff: 445 explanation += ["Differing attributes:"] 446 explanation += pprint.pformat(diff).splitlines() 447 for field in diff: 448 field_left = getattr(left, field) 449 field_right = getattr(right, field) 450 explanation += [ 451 "", 452 "Drill down into differing attribute %s:" % field, 453 ("%s%s: %r != %r") % (indent, field, field_left, field_right), 454 ] 455 explanation += [ 456 indent + line 457 for line in _compare_eq_any(field_left, field_right, verbose) 458 ] 459 return explanation 460 461 462def _notin_text(term: str, text: str, verbose: int = 0) -> List[str]: 463 index = text.find(term) 464 head = text[:index] 465 tail = text[index + len(term) :] 466 correct_text = head + tail 467 diff = _diff_text(text, correct_text, verbose) 468 newdiff = ["%s is contained here:" % saferepr(term, maxsize=42)] 469 for line in diff: 470 if line.startswith("Skipping"): 471 continue 472 if line.startswith("- "): 473 continue 474 if line.startswith("+ "): 475 newdiff.append(" " + line[2:]) 476 else: 477 newdiff.append(line) 478 return newdiff 479