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