diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index a4530192407..e33f8b29609 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -223,11 +223,14 @@ def pytest_assertrepr_compare( else: # Keep it plaintext when not using terminalrepoterer (#14377). highlighter = util.dummy_highlighter - return util.assertrepr_compare( - op=op, - left=left, - right=right, - verbose=config.get_verbosity(Config.VERBOSITY_ASSERTIONS), - highlighter=highlighter, - assertion_text_diff_style=util.get_assertion_text_diff_style(config), + explanation = list( + util.assertrepr_compare( + op=op, + left=left, + right=right, + verbose=config.get_verbosity(Config.VERBOSITY_ASSERTIONS), + highlighter=highlighter, + assertion_text_diff_style=util.get_assertion_text_diff_style(config), + ) ) + return explanation or None diff --git a/src/_pytest/assertion/_compare_any.py b/src/_pytest/assertion/_compare_any.py index 27556c2e8db..9e577683736 100644 --- a/src/_pytest/assertion/_compare_any.py +++ b/src/_pytest/assertion/_compare_any.py @@ -28,26 +28,29 @@ def _compare_eq_any( highlighter: _HighlightFunc, verbose: int, assertion_text_diff_style: _AssertionTextDiffStyle, -) -> list[str]: - explanation = [] +) -> Iterator[str]: + """Yield the per-line explanation for ``left == right`` (without summary). + + Yields nothing when no specialised explanation applies, so consumers + can stream the output and bail out early (e.g. for truncation) without + materialising the entire diff first. + """ if istext(left) and istext(right): - explanation = list( - _compare_eq_text( - left, - right, - highlighter, - verbose, - assertion_text_diff_style, - ) + yield from _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) + yield from right._repr_compare(left) elif isinstance(left, ApproxBase): - explanation = left._repr_compare(right) + yield from left._repr_compare(right) elif type(left) is type(right) and ( isdatacls(left) or isattrs(left) or isnamedtuple(left) ): @@ -55,27 +58,22 @@ def _compare_eq_any( # 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 = list( - _compare_eq_cls( - left, - right, - highlighter, - verbose, - assertion_text_diff_style, - ) + yield from _compare_eq_cls( + left, + right, + highlighter, + verbose, + assertion_text_diff_style, ) elif issequence(left) and issequence(right): - explanation = list(_compare_eq_sequence(left, right, highlighter, verbose)) + yield from _compare_eq_sequence(left, right, highlighter, verbose) elif isset(left) and isset(right): - explanation = _compare_eq_set(left, right, highlighter, verbose) + yield from _compare_eq_set(left, right, highlighter, verbose) elif ismapping(left) and ismapping(right): - explanation = list(_compare_eq_mapping(left, right, highlighter, verbose)) + yield from _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 + yield from _compare_eq_iterable(left, right, highlighter, verbose) def _compare_eq_cls( diff --git a/src/_pytest/assertion/_compare_set.py b/src/_pytest/assertion/_compare_set.py index 0fac608fe5c..66687ececcb 100644 --- a/src/_pytest/assertion/_compare_set.py +++ b/src/_pytest/assertion/_compare_set.py @@ -1,6 +1,8 @@ from __future__ import annotations from collections.abc import Callable +from collections.abc import Iterable +from collections.abc import Iterator from collections.abc import Set as AbstractSet from typing import TypeAlias @@ -13,14 +15,12 @@ def _set_one_sided_diff( set1: AbstractSet[object], set2: AbstractSet[object], highlighter: _HighlightFunc, -) -> list[str]: - explanation = [] +) -> Iterator[str]: diff = set1 - set2 if diff: - explanation.append(f"Extra items in the {posn} set:") + yield f"Extra items in the {posn} set:" for item in diff: - explanation.append(highlighter(saferepr(item))) - return explanation + yield highlighter(saferepr(item)) def _compare_eq_set( @@ -28,58 +28,56 @@ def _compare_eq_set( 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 +) -> Iterator[str]: + yield from _set_one_sided_diff("left", left, right, highlighter) + yield from _set_one_sided_diff("right", right, left, highlighter) -def _compare_gt_set( +def _compare_gte_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 +) -> Iterator[str]: + yield from _set_one_sided_diff("right", right, left, highlighter) -def _compare_lt_set( +def _compare_lte_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 +) -> Iterator[str]: + yield from _set_one_sided_diff("left", left, right, highlighter) -def _compare_gte_set( +def _compare_gt_set( left: AbstractSet[object], right: AbstractSet[object], highlighter: _HighlightFunc, verbose: int = 0, -) -> list[str]: - return _set_one_sided_diff("right", right, left, highlighter) +) -> Iterator[str]: + if left == right: + yield "Both sets are equal" + else: + yield from _set_one_sided_diff("right", right, left, highlighter) -def _compare_lte_set( +def _compare_lt_set( left: AbstractSet[object], right: AbstractSet[object], highlighter: _HighlightFunc, verbose: int = 0, -) -> list[str]: - return _set_one_sided_diff("left", left, right, highlighter) +) -> Iterator[str]: + if left == right: + yield "Both sets are equal" + else: + yield from _set_one_sided_diff("left", left, right, highlighter) SetComparisonFunction: TypeAlias = Callable[ [AbstractSet[object], AbstractSet[object], _HighlightFunc, int], - list[str], + Iterable[str], ] SET_COMPARISON_FUNCTIONS: dict[str, SetComparisonFunction] = { diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index f6fe2a7e8f8..6f1274f57a0 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable +from collections.abc import Iterator from collections.abc import Sequence from typing import Literal from unicodedata import normalize @@ -139,8 +140,19 @@ def assertrepr_compare( verbose: int, highlighter: _HighlightFunc, assertion_text_diff_style: _AssertionTextDiffStyle, -) -> list[str] | None: - """Return specialised explanations for some operators/operands.""" +) -> Iterator[str]: + """Yield specialised explanations for some operators/operands. + + The first line yielded is always the summary (``left op right``); + subsequent lines are the per-line explanation. Yields nothing when no + specialised explanation applies, which lets consumers map an empty + iterator to "no explanation" without materialising anything. + + The iterator is lazy on purpose: a streaming consumer (e.g. the + truncator in ``pytest_assertrepr_compare``) can stop pulling lines as + soon as it has enough to show, so an enormous diff doesn't have to be + built in full just to be thrown away. + """ # Strings which normalize equal are often hard to distinguish when printed; use ascii() to make this easier. # See issue #3246. use_ascii = ( @@ -164,37 +176,41 @@ def assertrepr_compare( summary = f"{left_repr} {op} {right_repr}" - explanation = None + summary_yielded = False try: if op == "==": - explanation = _compare_eq_any( + source: Iterator[str] = _compare_eq_any( left, right, highlighter, verbose, assertion_text_diff_style, ) - elif op == "not in": - if istext(left) and istext(right): - explanation = list(_notin_text(left, right, verbose)) - elif op in {"!=", ">=", "<=", ">", "<"}: - if isset(left) and isset(right): - explanation = SET_COMPARISON_FUNCTIONS[op]( - left, right, highlighter, verbose - ) - + elif op == "not in" and istext(left) and istext(right): + source = _notin_text(left, right, verbose) + elif op in {"!=", ">=", "<=", ">", "<"} and isset(left) and isset(right): + source = iter( + SET_COMPARISON_FUNCTIONS[op](left, right, highlighter, verbose) + ) + else: + source = iter(()) + + for line in source: + if not summary_yielded: + yield summary + if line != "": + yield "" + summary_yielded = True + yield line except outcomes.Exit: raise except Exception: repr_crash = _pytest._code.ExceptionInfo.from_current()._getreprcrash() - explanation = [ - f"(pytest_assertion plugin: representation of details failed: {repr_crash}.", - " Probably an object has a faulty __repr__.)", - ] - - if not explanation: - return None - - if explanation[0] != "": - explanation = ["", *explanation] - return [summary, *explanation] + if not summary_yielded: + yield summary + yield "" + summary_yielded = True + yield ( + f"(pytest_assertion plugin: representation of details failed: {repr_crash}." + ) + yield " Probably an object has a faulty __repr__.)" diff --git a/testing/test_assertion.py b/testing/test_assertion.py index c25487bdf33..492834ba9de 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -1043,7 +1043,9 @@ def __repr__(self): assert expl is not None assert expl[0].startswith("{} == <[ValueError") assert "raised in repr" in expl[0] - assert expl[2:] == [ + # Streaming explanation: any per-line output produced before the + # bad repr is preserved, then the failure notice is appended. + assert expl[-2:] == [ "(pytest_assertion plugin: representation of details failed:" f" {__file__}:{A.__repr__.__code__.co_firstlineno + 1}: ValueError: 42.", " Probably an object has a faulty __repr__.)",