From 190dbc9aac0061917ef64b13f6b0c82644f2bd17 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 26 May 2026 08:12:01 +0200 Subject: [PATCH 1/3] [refactor] Make set comparison helpers yield strings Following Ronny's review comment on #13762, switch the set comparison helpers in ``_compare_set.py`` to return ``Iterator[str]`` so the composition is direct: ``_set_one_sided_diff`` ``yield``s, and the other helpers ``yield from`` it. This avoids the manual ``explanation = []; .append/.extend`` boilerplate. The "equal sets" branch of ``_compare_gt_set`` / ``_compare_lt_set`` used to peek at the diff for emptiness; replace that with a direct ``left == right`` check so the generator form stays idiomatic. ``SET_COMPARISON_FUNCTIONS`` and ``_compare_eq_set`` now return ``Iterable[str]`` / ``Iterator[str]``; the consumers in ``_compare_eq_any`` materialise with ``list(...)``. --- src/_pytest/assertion/_compare_any.py | 2 +- src/_pytest/assertion/_compare_set.py | 56 +++++++++++++-------------- src/_pytest/assertion/util.py | 4 +- 3 files changed, 30 insertions(+), 32 deletions(-) diff --git a/src/_pytest/assertion/_compare_any.py b/src/_pytest/assertion/_compare_any.py index 27556c2e8db..b75769cd43d 100644 --- a/src/_pytest/assertion/_compare_any.py +++ b/src/_pytest/assertion/_compare_any.py @@ -67,7 +67,7 @@ def _compare_eq_any( elif issequence(left) and issequence(right): explanation = list(_compare_eq_sequence(left, right, highlighter, verbose)) elif isset(left) and isset(right): - explanation = _compare_eq_set(left, right, highlighter, verbose) + explanation = list(_compare_eq_set(left, right, highlighter, verbose)) elif ismapping(left) and ismapping(right): explanation = list(_compare_eq_mapping(left, right, highlighter, verbose)) 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..d13ca40ea37 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -179,8 +179,8 @@ def assertrepr_compare( 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 + explanation = list( + SET_COMPARISON_FUNCTIONS[op](left, right, highlighter, verbose) ) except outcomes.Exit: From c3dea4dc2a6d03c374d15298673bfb050d0420ea Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 26 May 2026 08:13:46 +0200 Subject: [PATCH 2/3] [refactor] Make ``_compare_eq_any`` yield strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the ``list(...)`` wraps around each per-type comparator call in the match dispatch and ``yield from`` instead. ``_compare_eq_any`` becomes an ``Iterator[str]`` that yields nothing when no specialised explanation applies (replaces the previous ``list[str] | None`` sentinel). The two callers materialise: * ``util.assertrepr_compare`` does ``list(_compare_eq_any(...))`` before its empty/summary check. * ``_compare_eq_cls`` iterates the generator directly via ``for line in _compare_eq_any(...)``. No behavior change yet — this is the stepping stone for letting the truncator upstream consume the iterator lazily so huge diffs don't materialise just to be thrown away. --- src/_pytest/assertion/_compare_any.py | 52 +++++++++++++-------------- src/_pytest/assertion/util.py | 16 +++++---- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/_pytest/assertion/_compare_any.py b/src/_pytest/assertion/_compare_any.py index b75769cd43d..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 = list(_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/util.py b/src/_pytest/assertion/util.py index d13ca40ea37..b0d22b55d76 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -164,15 +164,17 @@ def assertrepr_compare( summary = f"{left_repr} {op} {right_repr}" - explanation = None + explanation: list[str] | None = None try: if op == "==": - explanation = _compare_eq_any( - left, - right, - highlighter, - verbose, - assertion_text_diff_style, + explanation = list( + _compare_eq_any( + left, + right, + highlighter, + verbose, + assertion_text_diff_style, + ) ) elif op == "not in": if istext(left) and istext(right): From 5b6752ec364f7863f6da579e46a74811e20a2db7 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 26 May 2026 08:15:53 +0200 Subject: [PATCH 3/3] [refactor] Make ``util.assertrepr_compare`` yield strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn ``assertrepr_compare`` into a generator. The first line yielded is the summary; subsequent lines are the explanation produced by ``_compare_eq_any``. Yields nothing when no specialised explanation applies — the consumer maps an empty iterator to ``None``. The ``pytest_assertrepr_compare`` hook impl in ``assertion/__init__`` materialises the iterator and returns ``list[str] | None`` so the public hook contract is unchanged. A follow-up commit replaces the ``list(...)`` call with a streaming truncator so an enormous diff doesn't have to be built in full just to be discarded. Behaviour change: previously, if an exception was raised while building the explanation (e.g. a faulty ``__repr__``), the partial output was discarded and only the failure notice was returned. The generator can't unyield lines it has already produced, so the new form preserves the partial output and appends the failure notice after it. This is arguably more useful — the reader sees what was being compared at the point the comparison failed. ``test_list_bad_repr`` is updated to assert that the failure notice appears at the end of the explanation instead of replacing the body. --- src/_pytest/assertion/__init__.py | 17 ++++--- src/_pytest/assertion/util.py | 76 ++++++++++++++++++------------- testing/test_assertion.py | 4 +- 3 files changed, 58 insertions(+), 39 deletions(-) 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/util.py b/src/_pytest/assertion/util.py index b0d22b55d76..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,39 +176,41 @@ def assertrepr_compare( summary = f"{left_repr} {op} {right_repr}" - explanation: list[str] | None = None + summary_yielded = False try: if op == "==": - explanation = list( - _compare_eq_any( - left, - right, - highlighter, - verbose, - assertion_text_diff_style, - ) + 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 = list( - 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__.)",