diff --git a/src/_pytest/assertion/_compare_any.py b/src/_pytest/assertion/_compare_any.py new file mode 100644 index 00000000000..3955cd93ba7 --- /dev/null +++ b/src/_pytest/assertion/_compare_any.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import dataclasses +import pprint + +from _pytest.assertion._compare_mapping import _compare_eq_mapping +from _pytest.assertion._compare_sequence import _compare_eq_iterable +from _pytest.assertion._compare_sequence import _compare_eq_sequence +from _pytest.assertion._compare_set import _compare_eq_set +from _pytest.assertion._guards import has_default_eq +from _pytest.assertion._guards import isattrs +from _pytest.assertion._guards import isdatacls +from _pytest.assertion._guards import isiterable +from _pytest.assertion._guards import ismapping +from _pytest.assertion._guards import isnamedtuple +from _pytest.assertion._guards import issequence +from _pytest.assertion._guards import isset +from _pytest.assertion._guards import istext +from _pytest.assertion._typing import _AssertionTextDiffStyle +from _pytest.assertion._typing import _HighlightFunc +from _pytest.assertion.compare_text import _compare_eq_text + + +def _compare_eq_any( + left: object, + right: object, + highlighter: _HighlightFunc, + verbose: int, + assertion_text_diff_style: _AssertionTextDiffStyle, +) -> list[str]: + explanation = [] + if istext(left) and istext(right): + explanation = _compare_eq_text( + left, + right, + highlighter, + verbose, + assertion_text_diff_style, + ) + else: + from _pytest.python_api import ApproxBase + + # Although the common order should be obtained == approx(...), allow both ways. + if isinstance(right, ApproxBase): + explanation = right._repr_compare(left) + elif isinstance(left, ApproxBase): + explanation = left._repr_compare(right) + elif type(left) is type(right) and ( + isdatacls(left) or isattrs(left) or isnamedtuple(left) + ): + # Note: unlike dataclasses/attrs, namedtuples compare only the + # field values, not the type or field names. But this branch + # intentionally only handles the same-type case, which was often + # used in older code bases before dataclasses/attrs were available. + explanation = _compare_eq_cls( + left, + right, + highlighter, + verbose, + assertion_text_diff_style, + ) + elif issequence(left) and issequence(right): + explanation = _compare_eq_sequence(left, right, highlighter, verbose) + elif isset(left) and isset(right): + explanation = _compare_eq_set(left, right, highlighter, verbose) + elif ismapping(left) and ismapping(right): + explanation = _compare_eq_mapping(left, right, highlighter, verbose) + + if isiterable(left) and isiterable(right): + expl = _compare_eq_iterable(left, right, highlighter, verbose) + explanation.extend(expl) + + return explanation + + +def _compare_eq_cls( + left: object, + right: object, + highlighter: _HighlightFunc, + verbose: int, + assertion_text_diff_style: _AssertionTextDiffStyle, +) -> list[str]: + if not has_default_eq(left): + return [] + if isdatacls(left): + all_fields = dataclasses.fields(left) + fields_to_check = [info.name for info in all_fields if info.compare] + elif isattrs(left): + all_fields = left.__attrs_attrs__ # type: ignore[attr-defined] + fields_to_check = [field.name for field in all_fields if getattr(field, "eq")] + elif isnamedtuple(left): + fields_to_check = left._fields # type: ignore[attr-defined] + else: + assert False + + indent = " " + same = [] + diff = [] + for field in fields_to_check: + if getattr(left, field) == getattr(right, field): + same.append(field) + else: + diff.append(field) + + explanation = [] + if same or diff: + explanation += [""] + if same and verbose < 2: + explanation.append(f"Omitting {len(same)} identical items, use -vv to show") + elif same: + explanation += ["Matching attributes:"] + explanation += highlighter(pprint.pformat(same)).splitlines() + if diff: + explanation += ["Differing attributes:"] + explanation += highlighter(pprint.pformat(diff)).splitlines() + for field in diff: + field_left = getattr(left, field) + field_right = getattr(right, field) + explanation += [ + "", + f"Drill down into differing attribute {field}:", + f"{indent}{field}: {highlighter(repr(field_left))} != {highlighter(repr(field_right))}", + ] + explanation += [ + indent + line + for line in _compare_eq_any( + field_left, + field_right, + highlighter, + verbose, + assertion_text_diff_style, + ) + ] + return explanation diff --git a/src/_pytest/assertion/_compare_mapping.py b/src/_pytest/assertion/_compare_mapping.py new file mode 100644 index 00000000000..c1d27ad3630 --- /dev/null +++ b/src/_pytest/assertion/_compare_mapping.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from collections.abc import Mapping +import pprint + +from _pytest._io.saferepr import saferepr +from _pytest.assertion._typing import _HighlightFunc + + +def _compare_eq_mapping( + left: Mapping[object, object], + right: Mapping[object, object], + highlighter: _HighlightFunc, + verbose: int = 0, +) -> list[str]: + explanation: list[str] = [] + set_left = set(left) + set_right = set(right) + common = set_left.intersection(set_right) + same = {k: left[k] for k in common if left[k] == right[k]} + if same and verbose < 2: + explanation += [f"Omitting {len(same)} identical items, use -vv to show"] + elif same: + explanation += ["Common items:"] + explanation += highlighter(pprint.pformat(same)).splitlines() + diff = {k for k in common if left[k] != right[k]} + if diff: + explanation += ["Differing items:"] + for k in diff: + explanation += [ + highlighter(saferepr({k: left[k]})) + + " != " + + highlighter(saferepr({k: right[k]})) + ] + extra_left = set_left - set_right + len_extra_left = len(extra_left) + if len_extra_left: + explanation.append( + f"Left contains {len_extra_left} more item{'' if len_extra_left == 1 else 's'}:" + ) + explanation.extend( + highlighter(pprint.pformat({k: left[k] for k in extra_left})).splitlines() + ) + extra_right = set_right - set_left + len_extra_right = len(extra_right) + if len_extra_right: + explanation.append( + f"Right contains {len_extra_right} more item{'' if len_extra_right == 1 else 's'}:" + ) + explanation.extend( + highlighter(pprint.pformat({k: right[k] for k in extra_right})).splitlines() + ) + return explanation diff --git a/src/_pytest/assertion/_compare_sequence.py b/src/_pytest/assertion/_compare_sequence.py new file mode 100644 index 00000000000..b2d0596cf11 --- /dev/null +++ b/src/_pytest/assertion/_compare_sequence.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from collections.abc import Iterable +from collections.abc import Sequence + +from _pytest._io.pprint import PrettyPrinter +from _pytest._io.saferepr import saferepr +from _pytest.assertion._typing import _HighlightFunc +from _pytest.compat import running_on_ci + + +def _compare_eq_iterable( + left: Iterable[object], + right: Iterable[object], + highlighter: _HighlightFunc, + verbose: int = 0, +) -> list[str]: + if verbose <= 0 and not running_on_ci(): + return ["Use -v to get more diff"] + # dynamic import to speedup pytest + import difflib + + left_formatting = PrettyPrinter().pformat(left).splitlines() + right_formatting = PrettyPrinter().pformat(right).splitlines() + + explanation = ["", "Full diff:"] + # "right" is the expected base against which we compare "left", + # see https://github.com/pytest-dev/pytest/issues/3333 + explanation.extend( + highlighter( + "\n".join( + line.rstrip() + for line in difflib.ndiff(right_formatting, left_formatting) + ), + lexer="diff", + ).splitlines() + ) + return explanation + + +def _compare_eq_sequence( + left: Sequence[object], + right: Sequence[object], + highlighter: _HighlightFunc, + verbose: int = 0, +) -> list[str]: + comparing_bytes = isinstance(left, bytes) and isinstance(right, bytes) + explanation: list[str] = [] + len_left = len(left) + len_right = len(right) + for i in range(min(len_left, len_right)): + if left[i] != right[i]: + if comparing_bytes: + # when comparing bytes, we want to see their ascii representation + # instead of their numeric values (#5260) + # using a slice gives us the ascii representation: + # >>> s = b'foo' + # >>> s[0] + # 102 + # >>> s[0:1] + # b'f' + left_value: object = left[i : i + 1] + right_value: object = right[i : i + 1] + else: + left_value = left[i] + right_value = right[i] + + explanation.append( + f"At index {i} diff:" + f" {highlighter(repr(left_value))} != {highlighter(repr(right_value))}" + ) + break + + if comparing_bytes: + # when comparing bytes, it doesn't help to show the "sides contain one or more + # items" longer explanation, so skip it + + return explanation + + len_diff = len_left - len_right + if len_diff: + if len_diff > 0: + dir_with_more = "Left" + extra = saferepr(left[len_right]) + else: + len_diff = 0 - len_diff + dir_with_more = "Right" + extra = saferepr(right[len_left]) + + if len_diff == 1: + explanation += [ + f"{dir_with_more} contains one more item: {highlighter(extra)}" + ] + else: + explanation += [ + f"{dir_with_more} contains {len_diff} more items, first extra item: {highlighter(extra)}" + ] + return explanation diff --git a/src/_pytest/assertion/_compare_set.py b/src/_pytest/assertion/_compare_set.py new file mode 100644 index 00000000000..0fac608fe5c --- /dev/null +++ b/src/_pytest/assertion/_compare_set.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from collections.abc import Callable +from collections.abc import Set as AbstractSet +from typing import TypeAlias + +from _pytest._io.saferepr import saferepr +from _pytest.assertion._typing import _HighlightFunc + + +def _set_one_sided_diff( + posn: str, + set1: AbstractSet[object], + set2: AbstractSet[object], + highlighter: _HighlightFunc, +) -> list[str]: + explanation = [] + diff = set1 - set2 + if diff: + explanation.append(f"Extra items in the {posn} set:") + for item in diff: + explanation.append(highlighter(saferepr(item))) + return explanation + + +def _compare_eq_set( + left: AbstractSet[object], + right: AbstractSet[object], + highlighter: _HighlightFunc, + verbose: int = 0, +) -> list[str]: + explanation = [] + explanation.extend(_set_one_sided_diff("left", left, right, highlighter)) + explanation.extend(_set_one_sided_diff("right", right, left, highlighter)) + return explanation + + +def _compare_gt_set( + left: AbstractSet[object], + right: AbstractSet[object], + highlighter: _HighlightFunc, + verbose: int = 0, +) -> list[str]: + explanation = _compare_gte_set(left, right, highlighter) + if not explanation: + return ["Both sets are equal"] + return explanation + + +def _compare_lt_set( + left: AbstractSet[object], + right: AbstractSet[object], + highlighter: _HighlightFunc, + verbose: int = 0, +) -> list[str]: + explanation = _compare_lte_set(left, right, highlighter) + if not explanation: + return ["Both sets are equal"] + return explanation + + +def _compare_gte_set( + left: AbstractSet[object], + right: AbstractSet[object], + highlighter: _HighlightFunc, + verbose: int = 0, +) -> list[str]: + return _set_one_sided_diff("right", right, left, highlighter) + + +def _compare_lte_set( + left: AbstractSet[object], + right: AbstractSet[object], + highlighter: _HighlightFunc, + verbose: int = 0, +) -> list[str]: + return _set_one_sided_diff("left", left, right, highlighter) + + +SetComparisonFunction: TypeAlias = Callable[ + [AbstractSet[object], AbstractSet[object], _HighlightFunc, int], + list[str], +] + +SET_COMPARISON_FUNCTIONS: dict[str, SetComparisonFunction] = { + # == can't be done here without a prior refactor because there's an additional + # explanation for iterable in _compare_eq_any + # "==": _compare_eq_set, + "!=": lambda *a, **kw: ["Both sets are equal"], + ">=": _compare_gte_set, + "<=": _compare_lte_set, + ">": _compare_gt_set, + "<": _compare_lt_set, +} diff --git a/src/_pytest/assertion/_guards.py b/src/_pytest/assertion/_guards.py new file mode 100644 index 00000000000..bb9fedd6e65 --- /dev/null +++ b/src/_pytest/assertion/_guards.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import collections.abc +from collections.abc import Mapping +import dataclasses +from typing import TypeGuard + + +def issequence(x: object) -> TypeGuard[collections.abc.Sequence[object]]: + return isinstance(x, collections.abc.Sequence) and not isinstance(x, str) + + +def istext(x: object) -> TypeGuard[str]: + return isinstance(x, str) + + +def ismapping(x: object) -> TypeGuard[Mapping[object, object]]: + return isinstance(x, Mapping) + + +def isset(x: object) -> TypeGuard[set[object] | frozenset[object]]: + return isinstance(x, set | frozenset) + + +def isnamedtuple(obj: object) -> bool: + return isinstance(obj, tuple) and getattr(obj, "_fields", None) is not None + + +isdatacls = dataclasses.is_dataclass + + +def isattrs(obj: object) -> bool: + return getattr(obj, "__attrs_attrs__", None) is not None + + +def isiterable(obj: object) -> TypeGuard[collections.abc.Iterable[object]]: + try: + iter(obj) # type: ignore[call-overload] + return not istext(obj) + except Exception: + return False + + +def has_default_eq(obj: object) -> bool: + """Check if an instance of an object contains the default eq + + First, we check if the object's __eq__ attribute has __code__, + if so, we check the equally of the method code filename (__code__.co_filename) + to the default one generated by the dataclass and attr module + for dataclasses the default co_filename is , for attrs class, the __eq__ should contain "attrs eq generated" + """ + # inspired from https://github.com/willmcgugan/rich/blob/07d51ffc1aee6f16bd2e5a25b4e82850fb9ed778/rich/pretty.py#L68 + if hasattr(obj.__eq__, "__code__") and hasattr(obj.__eq__.__code__, "co_filename"): + code_filename = obj.__eq__.__code__.co_filename + + if isattrs(obj): + return "attrs generated " in code_filename + + return code_filename == "" # data class + return True diff --git a/src/_pytest/assertion/_typing.py b/src/_pytest/assertion/_typing.py new file mode 100644 index 00000000000..f032f34e0bc --- /dev/null +++ b/src/_pytest/assertion/_typing.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from typing import Literal +from typing import Protocol + + +_AssertionTextDiffStyle = Literal["ndiff", "block"] + + +class _HighlightFunc(Protocol): # noqa: PYI046 + def __call__(self, source: str, lexer: Literal["diff", "python"] = "python") -> str: + """Apply highlighting to the given source.""" diff --git a/src/_pytest/assertion/compare_text.py b/src/_pytest/assertion/compare_text.py new file mode 100644 index 00000000000..8122afe2c9e --- /dev/null +++ b/src/_pytest/assertion/compare_text.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from _pytest._io.saferepr import saferepr +from _pytest.assertion._typing import _AssertionTextDiffStyle +from _pytest.assertion._typing import _HighlightFunc +from _pytest.assertion.highlight import dummy_highlighter +from _pytest.compat import assert_never + + +def _compare_eq_text( + left: str, + right: str, + highlighter: _HighlightFunc, + verbose: int, + assertion_text_diff_style: _AssertionTextDiffStyle, +) -> list[str]: + match assertion_text_diff_style: + case "block": + return _diff_text_block(left, right) + case "ndiff": + return _diff_text(left, right, highlighter, verbose) + case unreachable: + assert_never(unreachable) + + +def _diff_text_block(left: str, right: str) -> list[str]: + return [ + "Left:", + *_format_text_block_lines(left), + "", + "Right:", + *_format_text_block_lines(right), + ] + + +def _format_text_block_lines(text: str) -> list[str]: + return [f" {line}" for line in text.split("\n")] + + +def _diff_text( + left: str, right: str, highlighter: _HighlightFunc, verbose: int = 0 +) -> list[str]: + """Return the explanation for the diff between text. + + Unless --verbose is used this will skip leading and trailing + characters which are identical to keep the diff minimal. + """ + from difflib import ndiff + + explanation: list[str] = [] + + if verbose < 1: + i = 0 # just in case left or right has zero length + for i in range(min(len(left), len(right))): + if left[i] != right[i]: + break + if i > 42: + i -= 10 # Provide some context + explanation = [ + f"Skipping {i} identical leading characters in diff, use -v to show" + ] + left = left[i:] + right = right[i:] + if len(left) == len(right): + for i in range(len(left)): + if left[-i] != right[-i]: + break + if i > 42: + i -= 10 # Provide some context + explanation += [ + f"Skipping {i} identical trailing " + "characters in diff, use -v to show" + ] + left = left[:-i] + right = right[:-i] + keepends = True + if left.isspace() or right.isspace(): + left = repr(str(left)) + right = repr(str(right)) + explanation += ["Strings contain only whitespace, escaping them using repr()"] + # "right" is the expected base against which we compare "left", + # see https://github.com/pytest-dev/pytest/issues/3333 + explanation.extend( + highlighter( + "\n".join( + line.strip("\n") + for line in ndiff(right.splitlines(keepends), left.splitlines(keepends)) + ), + lexer="diff", + ).splitlines() + ) + return explanation + + +def _notin_text(term: str, text: str, verbose: int = 0) -> list[str]: + index = text.find(term) + head = text[:index] + tail = text[index + len(term) :] + correct_text = head + tail + diff = _diff_text(text, correct_text, dummy_highlighter, verbose) + newdiff = [f"{saferepr(term, maxsize=42)} is contained here:"] + for line in diff: + if line.startswith("Skipping"): + continue + if line.startswith("- "): + continue + if line.startswith("+ "): + newdiff.append(" " + line[2:]) + else: + newdiff.append(line) + return newdiff diff --git a/src/_pytest/assertion/highlight.py b/src/_pytest/assertion/highlight.py new file mode 100644 index 00000000000..9ae833f19e6 --- /dev/null +++ b/src/_pytest/assertion/highlight.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from typing import Literal + + +def dummy_highlighter(source: str, lexer: Literal["diff", "python"] = "python") -> str: + """Dummy highlighter that returns the text unprocessed. + + Needed for _notin_text, as the diff gets post-processed to only show the "+" part. + """ + return source diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index aacc02ed44a..d6f78ab92c0 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -3,26 +3,23 @@ from __future__ import annotations -import collections.abc from collections.abc import Callable -from collections.abc import Iterable -from collections.abc import Mapping from collections.abc import Sequence -from collections.abc import Set as AbstractSet -import dataclasses -import pprint from typing import Literal -from typing import Protocol -from typing import TypeGuard from unicodedata import normalize from _pytest import outcomes import _pytest._code -from _pytest._io.pprint import PrettyPrinter from _pytest._io.saferepr import saferepr from _pytest._io.saferepr import saferepr_unlimited -from _pytest.compat import assert_never -from _pytest.compat import running_on_ci +from _pytest.assertion._compare_any import _compare_eq_any +from _pytest.assertion._compare_set import SET_COMPARISON_FUNCTIONS +from _pytest.assertion._guards import isset +from _pytest.assertion._guards import istext +from _pytest.assertion._typing import _AssertionTextDiffStyle +from _pytest.assertion._typing import _HighlightFunc +from _pytest.assertion.compare_text import _notin_text +from _pytest.assertion.highlight import dummy_highlighter as dummy_highlighter from _pytest.config import Config from _pytest.config import UsageError @@ -41,7 +38,6 @@ _config: Config | None = None ASSERTION_TEXT_DIFF_STYLE_INI = "assertion_text_diff_style" -_AssertionTextDiffStyle = Literal["ndiff", "block"] ASSERTION_TEXT_DIFF_STYLE_NDIFF: Literal["ndiff"] = "ndiff" ASSERTION_TEXT_DIFF_STYLE_BLOCK: Literal["block"] = "block" ASSERTION_TEXT_DIFF_STYLE_CHOICES = ( @@ -50,19 +46,6 @@ ) -class _HighlightFunc(Protocol): - def __call__(self, source: str, lexer: Literal["diff", "python"] = "python") -> str: - """Apply highlighting to the given source.""" - - -def dummy_highlighter(source: str, lexer: Literal["diff", "python"] = "python") -> str: - """Dummy highlighter that returns the text unprocessed. - - Needed for _notin_text, as the diff gets post-processed to only show the "+" part. - """ - return source - - def get_assertion_text_diff_style(config: Config) -> _AssertionTextDiffStyle: style = str(config.getini(ASSERTION_TEXT_DIFF_STYLE_INI)) match style: @@ -148,60 +131,6 @@ def _format_lines(lines: Sequence[str]) -> list[str]: return result -def issequence(x: object) -> TypeGuard[collections.abc.Sequence[object]]: - return isinstance(x, collections.abc.Sequence) and not isinstance(x, str) - - -def istext(x: object) -> TypeGuard[str]: - return isinstance(x, str) - - -def ismapping(x: object) -> TypeGuard[Mapping[object, object]]: - return isinstance(x, Mapping) - - -def isset(x: object) -> TypeGuard[set[object] | frozenset[object]]: - return isinstance(x, set | frozenset) - - -def isnamedtuple(obj: object) -> bool: - return isinstance(obj, tuple) and getattr(obj, "_fields", None) is not None - - -isdatacls = dataclasses.is_dataclass - - -def isattrs(obj: object) -> bool: - return getattr(obj, "__attrs_attrs__", None) is not None - - -def isiterable(obj: object) -> TypeGuard[collections.abc.Iterable[object]]: - try: - iter(obj) # type: ignore[call-overload] - return not istext(obj) - except Exception: - return False - - -def has_default_eq(obj: object) -> bool: - """Check if an instance of an object contains the default eq - - First, we check if the object's __eq__ attribute has __code__, - if so, we check the equally of the method code filename (__code__.co_filename) - to the default one generated by the dataclass and attr module - for dataclasses the default co_filename is , for attrs class, the __eq__ should contain "attrs eq generated" - """ - # inspired from https://github.com/willmcgugan/rich/blob/07d51ffc1aee6f16bd2e5a25b4e82850fb9ed778/rich/pretty.py#L68 - if hasattr(obj.__eq__, "__code__") and hasattr(obj.__eq__.__code__, "co_filename"): - code_filename = obj.__eq__.__code__.co_filename - - if isattrs(obj): - return "attrs generated " in code_filename - - return code_filename == "" # data class - return True - - def assertrepr_compare( op: str, left: object, @@ -248,21 +177,11 @@ def assertrepr_compare( elif op == "not in": if istext(left) and istext(right): explanation = _notin_text(left, right, verbose) - elif op == "!=": - if isset(left) and isset(right): - explanation = ["Both sets are equal"] - elif op == ">=": + elif op in {"!=", ">=", "<=", ">", "<"}: if isset(left) and isset(right): - explanation = _compare_gte_set(left, right, highlighter, verbose) - elif op == "<=": - if isset(left) and isset(right): - explanation = _compare_lte_set(left, right, highlighter, verbose) - elif op == ">": - if isset(left) and isset(right): - explanation = _compare_gt_set(left, right, highlighter, verbose) - elif op == "<": - if isset(left) and isset(right): - explanation = _compare_lt_set(left, right, highlighter, verbose) + explanation = SET_COMPARISON_FUNCTIONS[op]( + left, right, highlighter, verbose + ) except outcomes.Exit: raise @@ -279,424 +198,3 @@ def assertrepr_compare( if explanation[0] != "": explanation = ["", *explanation] return [summary, *explanation] - - -def _compare_eq_any( - left: object, - right: object, - highlighter: _HighlightFunc, - verbose: int, - assertion_text_diff_style: _AssertionTextDiffStyle, -) -> list[str]: - explanation = [] - if istext(left) and istext(right): - explanation = _compare_eq_text( - left, - right, - highlighter, - verbose, - assertion_text_diff_style, - ) - else: - from _pytest.python_api import ApproxBase - - # Although the common order should be obtained == approx(...), allow both ways. - if isinstance(right, ApproxBase): - explanation = right._repr_compare(left) - elif isinstance(left, ApproxBase): - explanation = left._repr_compare(right) - elif type(left) is type(right) and ( - isdatacls(left) or isattrs(left) or isnamedtuple(left) - ): - # Note: unlike dataclasses/attrs, namedtuples compare only the - # field values, not the type or field names. But this branch - # intentionally only handles the same-type case, which was often - # used in older code bases before dataclasses/attrs were available. - explanation = _compare_eq_cls( - left, - right, - highlighter, - verbose, - assertion_text_diff_style, - ) - elif issequence(left) and issequence(right): - explanation = _compare_eq_sequence(left, right, highlighter, verbose) - elif isset(left) and isset(right): - explanation = _compare_eq_set(left, right, highlighter, verbose) - elif ismapping(left) and ismapping(right): - explanation = _compare_eq_mapping(left, right, highlighter, verbose) - - if isiterable(left) and isiterable(right): - expl = _compare_eq_iterable(left, right, highlighter, verbose) - explanation.extend(expl) - - return explanation - - -def _compare_eq_text( - left: str, - right: str, - highlighter: _HighlightFunc, - verbose: int, - assertion_text_diff_style: _AssertionTextDiffStyle, -) -> list[str]: - match assertion_text_diff_style: - case "block": - return _diff_text_block(left, right) - case "ndiff": - return _diff_text(left, right, highlighter, verbose) - case unreachable: - assert_never(unreachable) - - -def _diff_text_block(left: str, right: str) -> list[str]: - return [ - "Left:", - *_format_text_block_lines(left), - "", - "Right:", - *_format_text_block_lines(right), - ] - - -def _format_text_block_lines(text: str) -> list[str]: - return [f" {line}" for line in text.split("\n")] - - -def _diff_text( - left: str, right: str, highlighter: _HighlightFunc, verbose: int = 0 -) -> list[str]: - """Return the explanation for the diff between text. - - Unless --verbose is used this will skip leading and trailing - characters which are identical to keep the diff minimal. - """ - from difflib import ndiff - - explanation: list[str] = [] - - if verbose < 1: - i = 0 # just in case left or right has zero length - for i in range(min(len(left), len(right))): - if left[i] != right[i]: - break - if i > 42: - i -= 10 # Provide some context - explanation = [ - f"Skipping {i} identical leading characters in diff, use -v to show" - ] - left = left[i:] - right = right[i:] - if len(left) == len(right): - for i in range(len(left)): - if left[-i] != right[-i]: - break - if i > 42: - i -= 10 # Provide some context - explanation += [ - f"Skipping {i} identical trailing " - "characters in diff, use -v to show" - ] - left = left[:-i] - right = right[:-i] - keepends = True - if left.isspace() or right.isspace(): - left = repr(str(left)) - right = repr(str(right)) - explanation += ["Strings contain only whitespace, escaping them using repr()"] - # "right" is the expected base against which we compare "left", - # see https://github.com/pytest-dev/pytest/issues/3333 - explanation.extend( - highlighter( - "\n".join( - line.strip("\n") - for line in ndiff(right.splitlines(keepends), left.splitlines(keepends)) - ), - lexer="diff", - ).splitlines() - ) - return explanation - - -def _compare_eq_iterable( - left: Iterable[object], - right: Iterable[object], - highlighter: _HighlightFunc, - verbose: int = 0, -) -> list[str]: - if verbose <= 0 and not running_on_ci(): - return ["Use -v to get more diff"] - # dynamic import to speedup pytest - import difflib - - left_formatting = PrettyPrinter().pformat(left).splitlines() - right_formatting = PrettyPrinter().pformat(right).splitlines() - - explanation = ["", "Full diff:"] - # "right" is the expected base against which we compare "left", - # see https://github.com/pytest-dev/pytest/issues/3333 - explanation.extend( - highlighter( - "\n".join( - line.rstrip() - for line in difflib.ndiff(right_formatting, left_formatting) - ), - lexer="diff", - ).splitlines() - ) - return explanation - - -def _compare_eq_sequence( - left: Sequence[object], - right: Sequence[object], - highlighter: _HighlightFunc, - verbose: int = 0, -) -> list[str]: - comparing_bytes = isinstance(left, bytes) and isinstance(right, bytes) - explanation: list[str] = [] - len_left = len(left) - len_right = len(right) - for i in range(min(len_left, len_right)): - if left[i] != right[i]: - if comparing_bytes: - # when comparing bytes, we want to see their ascii representation - # instead of their numeric values (#5260) - # using a slice gives us the ascii representation: - # >>> s = b'foo' - # >>> s[0] - # 102 - # >>> s[0:1] - # b'f' - left_value: object = left[i : i + 1] - right_value: object = right[i : i + 1] - else: - left_value = left[i] - right_value = right[i] - - explanation.append( - f"At index {i} diff:" - f" {highlighter(repr(left_value))} != {highlighter(repr(right_value))}" - ) - break - - if comparing_bytes: - # when comparing bytes, it doesn't help to show the "sides contain one or more - # items" longer explanation, so skip it - - return explanation - - len_diff = len_left - len_right - if len_diff: - if len_diff > 0: - dir_with_more = "Left" - extra = saferepr(left[len_right]) - else: - len_diff = 0 - len_diff - dir_with_more = "Right" - extra = saferepr(right[len_left]) - - if len_diff == 1: - explanation += [ - f"{dir_with_more} contains one more item: {highlighter(extra)}" - ] - else: - explanation += [ - f"{dir_with_more} contains {len_diff} more items, first extra item: {highlighter(extra)}" - ] - return explanation - - -def _compare_eq_set( - left: AbstractSet[object], - right: AbstractSet[object], - highlighter: _HighlightFunc, - verbose: int = 0, -) -> list[str]: - explanation = [] - explanation.extend(_set_one_sided_diff("left", left, right, highlighter)) - explanation.extend(_set_one_sided_diff("right", right, left, highlighter)) - return explanation - - -def _compare_gt_set( - left: AbstractSet[object], - right: AbstractSet[object], - highlighter: _HighlightFunc, - verbose: int = 0, -) -> list[str]: - explanation = _compare_gte_set(left, right, highlighter) - if not explanation: - return ["Both sets are equal"] - return explanation - - -def _compare_lt_set( - left: AbstractSet[object], - right: AbstractSet[object], - highlighter: _HighlightFunc, - verbose: int = 0, -) -> list[str]: - explanation = _compare_lte_set(left, right, highlighter) - if not explanation: - return ["Both sets are equal"] - return explanation - - -def _compare_gte_set( - left: AbstractSet[object], - right: AbstractSet[object], - highlighter: _HighlightFunc, - verbose: int = 0, -) -> list[str]: - return _set_one_sided_diff("right", right, left, highlighter) - - -def _compare_lte_set( - left: AbstractSet[object], - right: AbstractSet[object], - highlighter: _HighlightFunc, - verbose: int = 0, -) -> list[str]: - return _set_one_sided_diff("left", left, right, highlighter) - - -def _set_one_sided_diff( - posn: str, - set1: AbstractSet[object], - set2: AbstractSet[object], - highlighter: _HighlightFunc, -) -> list[str]: - explanation = [] - diff = set1 - set2 - if diff: - explanation.append(f"Extra items in the {posn} set:") - for item in diff: - explanation.append(highlighter(saferepr(item))) - return explanation - - -def _compare_eq_mapping( - left: Mapping[object, object], - right: Mapping[object, object], - highlighter: _HighlightFunc, - verbose: int = 0, -) -> list[str]: - explanation: list[str] = [] - set_left = set(left) - set_right = set(right) - common = set_left.intersection(set_right) - same = {k: left[k] for k in common if left[k] == right[k]} - if same and verbose < 2: - explanation += [f"Omitting {len(same)} identical items, use -vv to show"] - elif same: - explanation += ["Common items:"] - explanation += highlighter(pprint.pformat(same)).splitlines() - diff = {k for k in common if left[k] != right[k]} - if diff: - explanation += ["Differing items:"] - for k in diff: - explanation += [ - highlighter(saferepr({k: left[k]})) - + " != " - + highlighter(saferepr({k: right[k]})) - ] - extra_left = set_left - set_right - len_extra_left = len(extra_left) - if len_extra_left: - explanation.append( - f"Left contains {len_extra_left} more item{'' if len_extra_left == 1 else 's'}:" - ) - explanation.extend( - highlighter(pprint.pformat({k: left[k] for k in extra_left})).splitlines() - ) - extra_right = set_right - set_left - len_extra_right = len(extra_right) - if len_extra_right: - explanation.append( - f"Right contains {len_extra_right} more item{'' if len_extra_right == 1 else 's'}:" - ) - explanation.extend( - highlighter(pprint.pformat({k: right[k] for k in extra_right})).splitlines() - ) - return explanation - - -def _compare_eq_cls( - left: object, - right: object, - highlighter: _HighlightFunc, - verbose: int, - assertion_text_diff_style: _AssertionTextDiffStyle, -) -> list[str]: - if not has_default_eq(left): - return [] - if isdatacls(left): - all_fields = dataclasses.fields(left) - fields_to_check = [info.name for info in all_fields if info.compare] - elif isattrs(left): - all_fields = left.__attrs_attrs__ # type: ignore[attr-defined] - fields_to_check = [field.name for field in all_fields if getattr(field, "eq")] - elif isnamedtuple(left): - fields_to_check = left._fields # type: ignore[attr-defined] - else: - assert False - - indent = " " - same = [] - diff = [] - for field in fields_to_check: - if getattr(left, field) == getattr(right, field): - same.append(field) - else: - diff.append(field) - - explanation = [] - if same or diff: - explanation += [""] - if same and verbose < 2: - explanation.append(f"Omitting {len(same)} identical items, use -vv to show") - elif same: - explanation += ["Matching attributes:"] - explanation += highlighter(pprint.pformat(same)).splitlines() - if diff: - explanation += ["Differing attributes:"] - explanation += highlighter(pprint.pformat(diff)).splitlines() - for field in diff: - field_left = getattr(left, field) - field_right = getattr(right, field) - explanation += [ - "", - f"Drill down into differing attribute {field}:", - f"{indent}{field}: {highlighter(repr(field_left))} != {highlighter(repr(field_right))}", - ] - explanation += [ - indent + line - for line in _compare_eq_any( - field_left, - field_right, - highlighter, - verbose, - assertion_text_diff_style, - ) - ] - return explanation - - -def _notin_text(term: str, text: str, verbose: int = 0) -> list[str]: - index = text.find(term) - head = text[:index] - tail = text[index + len(term) :] - correct_text = head + tail - diff = _diff_text(text, correct_text, dummy_highlighter, verbose) - newdiff = [f"{saferepr(term, maxsize=42)} is contained here:"] - for line in diff: - if line.startswith("Skipping"): - continue - if line.startswith("- "): - continue - if line.startswith("+ "): - newdiff.append(" " + line[2:]) - else: - newdiff.append(line) - return newdiff diff --git a/src/_pytest/raises.py b/src/_pytest/raises.py index 9cd0beadb73..9c9285c4423 100644 --- a/src/_pytest/raises.py +++ b/src/_pytest/raises.py @@ -492,9 +492,9 @@ def _check_match(self, e: BaseException) -> bool: else "" ) if isinstance(self.rawmatch, str): + from _pytest.assertion.compare_text import _diff_text + from _pytest.assertion.highlight import dummy_highlighter from _pytest.assertion.util import _config - from _pytest.assertion.util import _diff_text - from _pytest.assertion.util import dummy_highlighter from _pytest.config import Config verbose = ( diff --git a/testing/test_assertion.py b/testing/test_assertion.py index bb7c101f667..ce9ea10ec25 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -4,6 +4,7 @@ from collections.abc import Iterator from collections.abc import Mapping from collections.abc import MutableSequence +import dataclasses import sys import textwrap from typing import Any @@ -15,6 +16,8 @@ import _pytest.assertion as plugin from _pytest.assertion import truncate from _pytest.assertion import util +from _pytest.assertion._compare_any import _compare_eq_cls +from _pytest.assertion.compare_text import _compare_eq_text from _pytest.config import Config as _Config from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester @@ -475,7 +478,7 @@ def test_text_diff(self) -> None: ] def test_text_diff_ndiff_style(self) -> None: - assert util._compare_eq_text( + assert _compare_eq_text( "spam", "eggs", util.dummy_highlighter, @@ -493,6 +496,15 @@ def test_text_skipping(self) -> None: for line in lines: assert "a" * 50 not in line + def test_text_skipping_trailing(self) -> None: + # Same length, differ at index 1, then ~50 identical trailing chars. + # Exercises the trailing-skip branch in ``_diff_text``. + lines = callequal("a" + "x" + "z" * 50, "a" + "y" + "z" * 50) + assert lines is not None + assert any("identical trailing" in line for line in lines) + for line in lines: + assert "z" * 50 not in line + def test_text_skipping_verbose(self) -> None: lines = callequal("a" * 50 + "spam", "a" * 50 + "eggs", verbose=1) assert lines is not None @@ -1734,6 +1746,42 @@ def test_reprcompare_notin() -> None: ] +def test_reprcompare_notin_nontext() -> None: + # ``not in`` with non-text operands has no specialised explanation. + assert callop("not in", 1, [1, 2]) is None + + +def test_reprcompare_notin_long_text() -> None: + # Long enough surrounding context to make the underlying ``_diff_text`` call + # emit a "Skipping ... identical leading characters" line, which + # ``_notin_text`` filters out. + lines = callop("not in", "x", "a" * 50 + "x") + assert lines is not None + assert not any("Skipping" in line for line in lines) + + +def test_compare_eq_cls_no_comparable_fields() -> None: + # A dataclass with no compared fields always compares equal, so the + # comparison hook is never reached via a failed assertion. Call the + # helper directly to exercise the "nothing to report" path. + @dataclasses.dataclass + class NoCompare: + x: int = dataclasses.field(default=0, compare=False) + + assert ( + list( + _compare_eq_cls( + NoCompare(1), + NoCompare(2), + util.dummy_highlighter, + 0, + util.ASSERTION_TEXT_DIFF_STYLE_NDIFF, + ) + ) + == [] + ) + + def test_reprcompare_whitespaces() -> None: assert callequal("\r\n", "\n") == [ r"'\r\n' == '\n'", @@ -2142,10 +2190,12 @@ def f(): def test_exit_from_assertrepr_compare(monkeypatch) -> None: + from _pytest.assertion import _compare_any + def raise_exit(obj): outcomes.exit("Quitting debugger") - monkeypatch.setattr(util, "istext", raise_exit) + monkeypatch.setattr(_compare_any, "istext", raise_exit) with pytest.raises(outcomes.Exit, match="Quitting debugger"): callequal(1, 1)