From 2a429f0f72969403962957d60641bcc4af30be09 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 26 May 2026 08:12:01 +0200 Subject: [PATCH 01/12] [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 b116e6723457af9f2500e14bac46902abb6fac3a Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 26 May 2026 08:13:46 +0200 Subject: [PATCH 02/12] [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 304da2303699f6cfb0262c912f44419053a2c993 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 26 May 2026 08:15:53 +0200 Subject: [PATCH 03/12] [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__.)", From 8bd741841508815f40626ac4e60e6b9110d41277 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 26 May 2026 08:17:28 +0200 Subject: [PATCH 04/12] [refactor] Add ``materialize_with_truncation`` for streaming explanations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing ``truncate_if_required`` takes a ``list[str]`` — it can only trim *after* the full explanation has been built. Add a streaming counterpart that takes an ``Iterable[str]`` and stops pulling lines as soon as the truncation threshold is reached, so a huge comparison doesn't have to materialise its entire output just to be discarded. The remaining lines are still iterated past the cap (without storing) so the truncation footer can report the exact hidden-line count, and ``_truncate_explanation`` gains an ``extra_hidden`` argument to fold that count into the message. ``_get_truncation_parameters`` is also refactored to take a ``Config`` directly (it never used anything else from ``Item``), so the new streaming helper can be called from places that don't have an item handy. The new helper isn't wired up yet — that's the next commit. --- src/_pytest/assertion/truncate.py | 66 +++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 7 deletions(-) diff --git a/src/_pytest/assertion/truncate.py b/src/_pytest/assertion/truncate.py index d62ca33cc4b..3d64f03cfd3 100644 --- a/src/_pytest/assertion/truncate.py +++ b/src/_pytest/assertion/truncate.py @@ -6,6 +6,8 @@ from __future__ import annotations +from collections.abc import Iterable + from _pytest.compat import running_on_ci from _pytest.config import Config from _pytest.nodes import Item @@ -18,7 +20,7 @@ def truncate_if_required(explanation: list[str], item: Item) -> list[str]: """Truncate this assertion explanation if the given test item is eligible.""" - should_truncate, max_lines, max_chars = _get_truncation_parameters(item) + should_truncate, max_lines, max_chars = _get_truncation_parameters(item.config) if should_truncate: return _truncate_explanation( explanation, @@ -28,20 +30,62 @@ def truncate_if_required(explanation: list[str], item: Item) -> list[str]: return explanation -def _get_truncation_parameters(item: Item) -> tuple[bool, int, int]: - """Return the truncation parameters related to the given item, as (should truncate, max lines, max chars).""" +def materialize_with_truncation(lines: Iterable[str], config: Config) -> list[str]: + """Materialise a streaming explanation, applying truncation lazily. + + Pulls from ``lines`` only until the truncation threshold is reached; + once exceeded, the remaining lines are consumed only to compute the + hidden-line count for the truncation footer, without being stored. + This lets a huge comparison short-circuit instead of building (and + immediately discarding) megabytes of explanation text. + """ + should_truncate, max_lines, max_chars = _get_truncation_parameters(config) + if not should_truncate: + return list(lines) + + tolerable_max_chars = max_chars + 70 + # Pull just past max_lines so ``_truncate_explanation`` can detect the + # overflow without us materialising more than we need. + line_cap = max_lines + 3 if max_lines > 0 else None + iterator = iter(lines) + buffered: list[str] = [] + char_count = 0 + for line in iterator: + buffered.append(line) + char_count += len(line) + if line_cap is not None and len(buffered) >= line_cap: + break + if max_chars > 0 and char_count > tolerable_max_chars: + break + else: + # Iterator exhausted within limits — nothing to truncate. + return buffered + + # Count the lines we won't be storing, so the footer can report + # accurately, but without keeping them in memory. + extra_hidden = sum(1 for _ in iterator) + return _truncate_explanation( + buffered, + max_lines=max_lines, + max_chars=max_chars, + extra_hidden=extra_hidden, + ) + + +def _get_truncation_parameters(config: Config) -> tuple[bool, int, int]: + """Return the truncation parameters from the given config, as (should truncate, max lines, max chars).""" # We do not need to truncate if one of conditions is met: # 1. Verbosity level is 2 or more; # 2. Test is being run in CI environment; # 3. Both truncation_limit_lines and truncation_limit_chars # .ini parameters are set to 0 explicitly. - max_lines = item.config.getini("truncation_limit_lines") + max_lines = config.getini("truncation_limit_lines") max_lines = int(max_lines if max_lines is not None else DEFAULT_MAX_LINES) - max_chars = item.config.getini("truncation_limit_chars") + max_chars = config.getini("truncation_limit_chars") max_chars = int(max_chars if max_chars is not None else DEFAULT_MAX_CHARS) - verbose = item.config.get_verbosity(Config.VERBOSITY_ASSERTIONS) + verbose = config.get_verbosity(Config.VERBOSITY_ASSERTIONS) should_truncate = verbose < 2 and not running_on_ci() should_truncate = should_truncate and (max_lines > 0 or max_chars > 0) @@ -53,6 +97,7 @@ def _truncate_explanation( input_lines: list[str], max_lines: int, max_chars: int, + extra_hidden: int = 0, ) -> list[str]: """Truncate given list of strings that makes up the assertion explanation. @@ -63,6 +108,10 @@ def _truncate_explanation( If max_chars=0, no truncation by character count is performed. If max_lines=0, no truncation by line count is performed. + ``extra_hidden`` lets streaming callers report lines that were dropped + before reaching this function (so the truncation footer can show the + full hidden count even when the input was capped upstream). + When this function is launched we know max_lines > 0 or max_chars > 0 because _get_truncation_parameters was called first. """ @@ -100,7 +149,10 @@ def _truncate_explanation( # Something was truncated, adding '...' at the end to show that truncated_explanation[-1] += "..." truncated_line_count = ( - len(input_lines) - len(truncated_explanation) + int(need_to_truncate_char) + len(input_lines) + - len(truncated_explanation) + + int(need_to_truncate_char) + + extra_hidden ) return [ *truncated_explanation, From 001a810d53041b0b350619e7dd78d92375f7ed59 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 26 May 2026 08:20:04 +0200 Subject: [PATCH 05/12] [refactor] Stream assertion explanations end-to-end through truncation Wire the built-in ``pytest_assertrepr_compare`` hook to return the iterator produced by ``util.assertrepr_compare`` directly, and update ``callbinrepr`` to consume it through ``materialize_with_truncation``. The result: a comparison that would produce millions of explanation lines stops at the truncation threshold (default 8 lines / 640 chars) without materialising the rest, only counting the remaining lines so the truncation footer still reports the exact hidden-line count. The ``callbinrepr`` dispatcher's ``materialize_with_truncation`` call accepts both lists (returned by third-party plugins implementing the hook) and iterators (returned by the built-in impl), so the change is transparent to plugin authors. ``callop`` in ``test_assertion`` now materialises the iterator so tests keep comparing against literal lists. --- src/_pytest/assertion/__init__.py | 50 ++++++++++++++++++++----------- testing/test_assertion.py | 10 ++++++- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index e33f8b29609..81fc3642f4d 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Generator +from collections.abc import Iterator import sys from typing import Any from typing import Protocol @@ -181,13 +182,20 @@ def callbinrepr(op, left: object, right: object) -> str | None: config=item.config, op=op, left=left, right=right ) for new_expl in hook_result: - if new_expl: - new_expl = truncate.truncate_if_required(new_expl, item) - new_expl = [line.replace("\n", "\\n") for line in new_expl] - res = "\n~".join(new_expl) - if item.config.getvalue("assertmode") == "rewrite": - res = res.replace("%", "%%") - return res + if new_expl is None: + continue + # ``materialize_with_truncation`` accepts both lists (returned + # by third-party plugins) and iterators (returned by the + # built-in hook impl), and stops pulling from the iterator as + # soon as the truncation threshold is reached. + new_expl = truncate.materialize_with_truncation(new_expl, item.config) + if not new_expl: + continue + new_expl = [line.replace("\n", "\\n") for line in new_expl] + res = "\n~".join(new_expl) + if item.config.getvalue("assertmode") == "rewrite": + res = res.replace("%", "%%") + return res return None saved_assert_hooks = util._reprcompare, util._assertion_pass @@ -217,20 +225,26 @@ def pytest_sessionfinish(session: Session) -> None: def pytest_assertrepr_compare( config: Config, op: str, left: Any, right: Any -) -> list[str] | None: +) -> Iterator[str]: + """Return a streaming explanation for ``left op right``. + + ``util.assertrepr_compare`` is a generator; we return it directly so + that ``callbinrepr`` (the actual consumer) can apply truncation + lazily and avoid materialising a huge diff just to throw most of it + away. The hook spec advertises ``list[str] | None`` but ``Iterable`` + works everywhere a list did (the dispatcher in + :func:`_pytest.assertion.callbinrepr` handles either). + """ if config.pluginmanager.has_plugin("terminalreporter"): highlighter = config.get_terminal_writer()._highlight else: # Keep it plaintext when not using terminalrepoterer (#14377). highlighter = util.dummy_highlighter - 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 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/testing/test_assertion.py b/testing/test_assertion.py index 492834ba9de..e38eb303d04 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -56,6 +56,10 @@ def get_verbosity(self, verbosity_type: str | None = None) -> int: def getini(self, name: str) -> str: if name == util.ASSERTION_TEXT_DIFF_STYLE_INI: return assertion_text_diff_style + # Truncation limits aren't exercised by the comparison-output + # tests; returning ``None`` falls back to the defaults. + if name in ("truncation_limit_lines", "truncation_limit_chars"): + return None # type: ignore[return-value] raise KeyError(f"Not mocked out: {name}") return Config() @@ -441,7 +445,11 @@ def callop( verbose=verbose, assertion_text_diff_style=assertion_text_diff_style, ) - return plugin.pytest_assertrepr_compare(config, op, left, right) + # The hook now returns a streaming iterator; materialise here so the + # tests can keep comparing against literal lists. Real consumers go + # through ``callbinrepr`` which applies streaming truncation. + explanation = list(plugin.pytest_assertrepr_compare(config, op, left, right)) + return explanation or None def callequal( From a43434396c55b62ef80173182d50def5a8fe3c48 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 26 May 2026 13:21:06 +0200 Subject: [PATCH 06/12] [test] Cover the streaming truncation path and remove dead helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Drop ``truncate.truncate_if_required`` — all callers migrated to ``materialize_with_truncation`` and the function had no remaining users. * Add ``TestMaterializeWithTruncation`` covering: - iterator within limits returns all lines - iterator past limits is bounded and contains a truncation marker - sized and unsized inputs produce equivalent shapes - truncation is skipped at ``-vv`` - the lines that survive truncation start with the original input Assertions check behaviour (the presence of a "truncated" marker, the length being bounded, the first lines being preserved), never the literal footer wording — so the tests survive a future decision to drop the ``(N lines hidden)`` count from the message. * Add ``test_plugin_hook_returning_none_is_skipped`` to cover the ``if new_expl is None: continue`` branch in ``callbinrepr``. * Add ``test_exception_before_first_yield_emits_summary_and_notice`` to cover the ``summary_yielded is False`` arm of ``assertrepr_compare``'s exception handler — when the comparator raises before yielding anything, the summary is still produced so the reader sees what was compared. --- src/_pytest/assertion/truncate.py | 13 --- testing/test_assertion.py | 129 ++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 13 deletions(-) diff --git a/src/_pytest/assertion/truncate.py b/src/_pytest/assertion/truncate.py index 3d64f03cfd3..65284722bbc 100644 --- a/src/_pytest/assertion/truncate.py +++ b/src/_pytest/assertion/truncate.py @@ -10,7 +10,6 @@ from _pytest.compat import running_on_ci from _pytest.config import Config -from _pytest.nodes import Item DEFAULT_MAX_LINES = 8 @@ -18,18 +17,6 @@ USAGE_MSG = "use '-vv' to show" -def truncate_if_required(explanation: list[str], item: Item) -> list[str]: - """Truncate this assertion explanation if the given test item is eligible.""" - should_truncate, max_lines, max_chars = _get_truncation_parameters(item.config) - if should_truncate: - return _truncate_explanation( - explanation, - max_lines=max_lines, - max_chars=max_chars, - ) - return explanation - - def materialize_with_truncation(lines: Iterable[str], config: Config) -> list[str]: """Materialise a streaming explanation, applying truncation lazily. diff --git a/testing/test_assertion.py b/testing/test_assertion.py index e38eb303d04..633dc2048f9 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -1720,6 +1720,82 @@ def test(): ) +class TestMaterializeWithTruncation: + """Tests for ``truncate.materialize_with_truncation``. + + Assertions check *behaviour* — that truncation kicks in / doesn't, + that the original lines are preserved, that the iterator's contract + is honoured — and never the literal footer wording. That way the + tests survive any future change to the truncation message format. + """ + + @staticmethod + def _config_with_limits(verbose: int = 0): + # Minimal stand-in for ``Config`` that ``materialize_with_truncation`` + # uses through ``_get_truncation_parameters``. + class C: + def getini(self, name: str) -> object: + return None # use defaults (8 lines / 640 chars) + + def get_verbosity(self, _verbosity_type: str | None = None) -> int: + return verbose + + return C() + + def test_iterator_within_limits_returns_all_lines(self) -> None: + lines = iter(["one", "two", "three"]) + result = truncate.materialize_with_truncation(lines, self._config_with_limits()) + assert result == ["one", "two", "three"] + + def test_iterator_exceeding_limits_is_truncated(self) -> None: + lines = (f"line {i}" for i in range(1000)) + result = truncate.materialize_with_truncation(lines, self._config_with_limits()) + # Bounded length — we kept the truncation footer plus at most a few + # lines past the cap; we never collect the full 1000-line stream. + assert len(result) < 20 + # The first lines we kept are the first lines of the input. + assert result[0] == "line 0" + # Some truncation marker is present (wording deliberately not asserted). + assert any("truncated" in line for line in result) + + def test_sized_input_returns_same_shape_as_iterator_input(self) -> None: + # When the input is already a sized container, the function still + # returns the truncated form; behaviour is the same as for an + # iterator over the same content. + content = [f"line {i}" for i in range(50)] + sized = truncate.materialize_with_truncation( + content, self._config_with_limits() + ) + unsized = truncate.materialize_with_truncation( + iter(content), self._config_with_limits() + ) + assert sized[0] == unsized[0] == "line 0" + assert any("truncated" in line for line in sized) + assert any("truncated" in line for line in unsized) + + def test_truncation_disabled_returns_full_input(self) -> None: + # verbose >= 2 disables truncation; the iterator is fully drained. + lines = (f"line {i}" for i in range(50)) + result = truncate.materialize_with_truncation( + lines, self._config_with_limits(verbose=2) + ) + assert result == [f"line {i}" for i in range(50)] + assert not any("truncated" in line for line in result) + + def test_first_lines_are_preserved_verbatim(self) -> None: + lines = (f"line {i}" for i in range(200)) + result = truncate.materialize_with_truncation(lines, self._config_with_limits()) + # The first kept lines should match the start of the input exactly + # (modulo the "..." appended to the last surviving line by the + # truncator, which we strip before comparing). + kept = [line.rstrip(".") for line in result if "truncated" not in line] + for i, line in enumerate(kept): + if line == "": + # Blank line separating content from the footer. + continue + assert line.startswith(f"line {i}") + + def test_python25_compile_issue257(pytester: Pytester) -> None: pytester.makepyfile( """ @@ -2213,6 +2289,59 @@ def raise_exit(obj): callequal(1, 1) +def test_plugin_hook_returning_none_is_skipped(pytester: Pytester) -> None: + """A ``pytest_assertrepr_compare`` impl returning ``None`` is skipped + so the next impl (or the built-in) can produce the explanation. + Covers the ``if new_expl is None: continue`` branch in + ``callbinrepr``. + """ + pytester.makeconftest( + """ + def pytest_assertrepr_compare(op, left, right): + # Always defer to the next plugin / the built-in. + return None + """ + ) + pytester.makepyfile( + """ + def test_diff(): + assert {1, 2} == {1, 3} + """ + ) + result = pytester.runpytest() + # The built-in set-comparison explanation still reaches the user + # (so the None-returning hook did not swallow it). + result.stdout.fnmatch_lines( + ["*Extra items in the left set:*", "*Extra items in the right set:*"] + ) + + +def test_exception_before_first_yield_emits_summary_and_notice(monkeypatch) -> None: + """When the comparator raises *before* any explanation line has been + yielded, ``assertrepr_compare`` should still produce the summary so + the reader sees what was being compared, then append the failure + notice. Covers the ``summary_yielded is False`` branch of the + exception handler. + """ + from _pytest.assertion import _compare_any + + def raise_value_error(obj): + raise ValueError("synthetic repr failure") + + # ``istext`` is called inside ``_compare_eq_any`` before the first + # yield, so this triggers the failure path on the very first + # ``next()`` call from ``assertrepr_compare``. + monkeypatch.setattr(_compare_any, "istext", raise_value_error) + + expl = callequal(1, 1) + assert expl is not None + # Summary line still produced. + assert expl[0] == "1 == 1" + # The failure notice survives in the output; wording deliberately not + # asserted, only the underlying error's signature. + assert any("ValueError" in line or "synthetic" in line for line in expl) + + def test_assertion_location_with_coverage(pytester: Pytester) -> None: """This used to report the wrong location when run with coverage (#5754).""" p = pytester.makepyfile( From 4f25425ba9429488f7f2a95d58c233d8fd5772b8 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 26 May 2026 13:38:34 +0200 Subject: [PATCH 07/12] [docs] Add changelog for streaming assertion comparisons (#14523) --- changelog/14523.improvement.rst | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog/14523.improvement.rst diff --git a/changelog/14523.improvement.rst b/changelog/14523.improvement.rst new file mode 100644 index 00000000000..57b526e9ee5 --- /dev/null +++ b/changelog/14523.improvement.rst @@ -0,0 +1,7 @@ +The assertion comparison helpers and the truncator now hand the +explanation between them as an iterator, so the dispatcher only has to +materialise lines the truncator actually keeps. For the typical small +diff this is a no-op; for the rare case where two large collections +fail to compare, peak memory drops by a few percent because the +millions of lines that would have been built and immediately discarded +are no longer built. From f6a25c3643dda737375f1bcbba4bc79a1e1faf45 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Wed, 27 May 2026 23:18:09 +0200 Subject: [PATCH 08/12] [refactor] Keep ``pytest_assertrepr_compare`` returning ``list[str] | None`` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hookspec advertises ``list[str] | None`` as the stable return type; the streaming refactor had changed the built-in impl to ``Iterator[str]``. Restore the spec'd shape by materialising inside the hook through ``materialize_with_truncation`` — the iterator from ``util.assertrepr_compare`` is still consumed lazily, so a huge diff short-circuits at the truncation threshold without being fully built. To avoid double-truncation between the hook and ``callbinrepr`` (which still truncates plugin-supplied lists), make ``materialize_with_truncation`` idempotent on inputs that already end in our truncation footer. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/_pytest/assertion/__init__.py | 30 ++++++++++++++---------------- src/_pytest/assertion/truncate.py | 15 +++++++++++++++ testing/test_assertion.py | 23 +++++++++++++++-------- 3 files changed, 44 insertions(+), 24 deletions(-) diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index 81fc3642f4d..a90b6c15798 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Generator -from collections.abc import Iterator import sys from typing import Any from typing import Protocol @@ -182,12 +181,11 @@ def callbinrepr(op, left: object, right: object) -> str | None: config=item.config, op=op, left=left, right=right ) for new_expl in hook_result: - if new_expl is None: + if not new_expl: continue - # ``materialize_with_truncation`` accepts both lists (returned - # by third-party plugins) and iterators (returned by the - # built-in hook impl), and stops pulling from the iterator as - # soon as the truncation threshold is reached. + # Plugin-supplied lists are truncated here; the built-in + # impl truncates as it streams, so this is a no-op on its + # already-short output. new_expl = truncate.materialize_with_truncation(new_expl, item.config) if not new_expl: continue @@ -225,22 +223,21 @@ def pytest_sessionfinish(session: Session) -> None: def pytest_assertrepr_compare( config: Config, op: str, left: Any, right: Any -) -> Iterator[str]: - """Return a streaming explanation for ``left op right``. - - ``util.assertrepr_compare`` is a generator; we return it directly so - that ``callbinrepr`` (the actual consumer) can apply truncation - lazily and avoid materialising a huge diff just to throw most of it - away. The hook spec advertises ``list[str] | None`` but ``Iterable`` - works everywhere a list did (the dispatcher in - :func:`_pytest.assertion.callbinrepr` handles either). +) -> list[str] | None: + """Return an explanation for ``left op right``. + + Internally ``util.assertrepr_compare`` is a generator; we feed it + through ``materialize_with_truncation`` so a huge comparison + short-circuits at the truncation threshold without building the + full diff, while still returning the ``list[str] | None`` shape + the hook spec advertises. """ if config.pluginmanager.has_plugin("terminalreporter"): highlighter = config.get_terminal_writer()._highlight else: # Keep it plaintext when not using terminalrepoterer (#14377). highlighter = util.dummy_highlighter - return util.assertrepr_compare( + lines = util.assertrepr_compare( op=op, left=left, right=right, @@ -248,3 +245,4 @@ def pytest_assertrepr_compare( highlighter=highlighter, assertion_text_diff_style=util.get_assertion_text_diff_style(config), ) + return truncate.materialize_with_truncation(lines, config) or None diff --git a/src/_pytest/assertion/truncate.py b/src/_pytest/assertion/truncate.py index 65284722bbc..a428b062ba1 100644 --- a/src/_pytest/assertion/truncate.py +++ b/src/_pytest/assertion/truncate.py @@ -17,6 +17,9 @@ USAGE_MSG = "use '-vv' to show" +_TRUNCATION_FOOTER_PREFIX = "...Full output truncated (" + + def materialize_with_truncation(lines: Iterable[str], config: Config) -> list[str]: """Materialise a streaming explanation, applying truncation lazily. @@ -25,7 +28,19 @@ def materialize_with_truncation(lines: Iterable[str], config: Config) -> list[st hidden-line count for the truncation footer, without being stored. This lets a huge comparison short-circuit instead of building (and immediately discarding) megabytes of explanation text. + + Idempotent: if ``lines`` is already a list ending in our truncation + footer it is returned as-is, so callers can chain this safely (the + built-in ``pytest_assertrepr_compare`` truncates inside the hook, and + the dispatcher re-applies this to plugin-supplied lists). """ + if ( + isinstance(lines, list) + and lines + and lines[-1].startswith(_TRUNCATION_FOOTER_PREFIX) + ): + return lines + should_truncate, max_lines, max_chars = _get_truncation_parameters(config) if not should_truncate: return list(lines) diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 633dc2048f9..cc5ccf16c65 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -56,10 +56,11 @@ def get_verbosity(self, verbosity_type: str | None = None) -> int: def getini(self, name: str) -> str: if name == util.ASSERTION_TEXT_DIFF_STYLE_INI: return assertion_text_diff_style - # Truncation limits aren't exercised by the comparison-output - # tests; returning ``None`` falls back to the defaults. + # Disable truncation so ``callop``-style tests can compare + # against the full explanation. Dedicated truncation tests + # use their own config in :class:`TestTruncateMaterialize`. if name in ("truncation_limit_lines", "truncation_limit_chars"): - return None # type: ignore[return-value] + return "0" raise KeyError(f"Not mocked out: {name}") return Config() @@ -445,11 +446,7 @@ def callop( verbose=verbose, assertion_text_diff_style=assertion_text_diff_style, ) - # The hook now returns a streaming iterator; materialise here so the - # tests can keep comparing against literal lists. Real consumers go - # through ``callbinrepr`` which applies streaming truncation. - explanation = list(plugin.pytest_assertrepr_compare(config, op, left, right)) - return explanation or None + return plugin.pytest_assertrepr_compare(config, op, left, right) def callequal( @@ -1795,6 +1792,16 @@ def test_first_lines_are_preserved_verbatim(self) -> None: continue assert line.startswith(f"line {i}") + def test_idempotent_on_already_truncated_list(self) -> None: + # The dispatcher applies ``materialize_with_truncation`` after the + # built-in hook impl already truncated. Re-applying it must not + # corrupt the footer count or chop further lines. + once = truncate.materialize_with_truncation( + (f"line {i}" for i in range(200)), self._config_with_limits() + ) + twice = truncate.materialize_with_truncation(once, self._config_with_limits()) + assert twice == once + def test_python25_compile_issue257(pytester: Pytester) -> None: pytester.makepyfile( From 517f77a02030904a67f24c5e9db65e86883aaf15 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Wed, 27 May 2026 23:29:08 +0200 Subject: [PATCH 09/12] [refactor] Tighten ``SetComparisonFunction`` to ``Iterator[str]`` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on PR #14523: * drop the redundant ``: Iterator[str]`` annotation on ``source`` — every branch already produces an ``Iterator[str]``. * return ``Iterator[str]`` from ``SetComparisonFunction`` instead of ``Iterable[str]`` so the call site no longer needs ``iter(...)``; the ``!=`` branch is promoted from a list-returning lambda to a named generator so the new contract holds. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/_pytest/assertion/_compare_set.py | 15 ++++++++++++--- src/_pytest/assertion/util.py | 6 ++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/_pytest/assertion/_compare_set.py b/src/_pytest/assertion/_compare_set.py index 66687ececcb..2817133790d 100644 --- a/src/_pytest/assertion/_compare_set.py +++ b/src/_pytest/assertion/_compare_set.py @@ -1,7 +1,6 @@ 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 @@ -77,14 +76,24 @@ def _compare_lt_set( SetComparisonFunction: TypeAlias = Callable[ [AbstractSet[object], AbstractSet[object], _HighlightFunc, int], - Iterable[str], + Iterator[str], ] + +def _both_sets_are_equal( + left: AbstractSet[object], + right: AbstractSet[object], + highlighter: _HighlightFunc, + verbose: int = 0, +) -> Iterator[str]: + yield "Both sets are equal" + + 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"], + "!=": _both_sets_are_equal, ">=": _compare_gte_set, "<=": _compare_lte_set, ">": _compare_gt_set, diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index 6f1274f57a0..d4553ff922f 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -179,7 +179,7 @@ def assertrepr_compare( summary_yielded = False try: if op == "==": - source: Iterator[str] = _compare_eq_any( + source = _compare_eq_any( left, right, highlighter, @@ -189,9 +189,7 @@ def assertrepr_compare( 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) - ) + source = SET_COMPARISON_FUNCTIONS[op](left, right, highlighter, verbose) else: source = iter(()) From 3be76726f33cf1956031016a33a020f7b322b2ff Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Thu, 28 May 2026 07:03:37 +0200 Subject: [PATCH 10/12] [test] Cover the empty-iterable and plain-assert-mode branches Adds two regression tests to close the patch-coverage gaps in ``callbinrepr`` reported by codecov on PR #14523: * a plugin returning a truthy-but-empty iterator (``iter([])``) to exercise the second ``if not new_expl: continue`` after ``materialize_with_truncation``. * a ``--assert=plain`` run to exercise the false branch of the ``assertmode == "rewrite"`` guard. Co-Authored-By: Claude Opus 4.7 (1M context) --- testing/test_assertion.py | 50 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/testing/test_assertion.py b/testing/test_assertion.py index cc5ccf16c65..7904f609800 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -2299,8 +2299,7 @@ def raise_exit(obj): def test_plugin_hook_returning_none_is_skipped(pytester: Pytester) -> None: """A ``pytest_assertrepr_compare`` impl returning ``None`` is skipped so the next impl (or the built-in) can produce the explanation. - Covers the ``if new_expl is None: continue`` branch in - ``callbinrepr``. + Covers the ``if not new_expl: continue`` branch in ``callbinrepr``. """ pytester.makeconftest( """ @@ -2323,6 +2322,53 @@ def test_diff(): ) +def test_plugin_hook_returning_empty_iterator_is_skipped(pytester: Pytester) -> None: + """A plugin returning a truthy but ultimately empty iterable is + skipped after materialisation. Covers the second + ``if not new_expl: continue`` branch in ``callbinrepr``. + """ + pytester.makeconftest( + """ + def pytest_assertrepr_compare(op, left, right): + # An iterator object is truthy, so it slips past the first + # falsy check; once materialised through truncation it is + # empty and the dispatcher must move on. + return iter([]) + """ + ) + pytester.makepyfile( + """ + def test_diff(): + assert {1, 2} == {1, 3} + """ + ) + result = pytester.runpytest() + # The built-in set-comparison explanation still reaches the user. + result.stdout.fnmatch_lines( + ["*Extra items in the left set:*", "*Extra items in the right set:*"] + ) + + +def test_callbinrepr_plain_assert_mode(pytester: Pytester) -> None: + """In ``--assert=plain`` mode ``callbinrepr`` skips the ``%`` escape. + Covers the false branch of ``if item.config.getvalue("assertmode") + == "rewrite"``. + """ + pytester.makepyfile( + """ + def test_diff(): + assert {1, 2} == {1, 3} + """ + ) + result = pytester.runpytest("--assert=plain") + # In plain mode the comparator still runs via ``callbinrepr`` (it + # is the rewrite escaping that's skipped), so the explanation is + # still produced. + result.stdout.fnmatch_lines( + ["*Extra items in the left set:*", "*Extra items in the right set:*"] + ) + + def test_exception_before_first_yield_emits_summary_and_notice(monkeypatch) -> None: """When the comparator raises *before* any explanation line has been yielded, ``assertrepr_compare`` should still produce the summary so From 5c0c0f82c8d3fdfaf98dd7e86e19dfe6b23bbe4b Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Thu, 28 May 2026 07:30:25 +0200 Subject: [PATCH 11/12] [test] Cover the dispatcher's fall-through-to-``None`` branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When every ``pytest_assertrepr_compare`` impl returns ``None`` (e.g. ``assert 1 == 2`` — no specialised comparator applies), the dispatcher exhausts ``hook_result``, exits the loop normally, and returns ``None``. The previously-uncovered ``continue → loop exit`` arc on the first ``if not new_expl: continue`` line was the last patch coverage gap on PR #14523. Co-Authored-By: Claude Opus 4.7 (1M context) --- testing/test_assertion.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 7904f609800..8878539965a 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -2349,6 +2349,29 @@ def test_diff(): ) +def test_callbinrepr_falls_through_when_all_hooks_return_none( + pytester: Pytester, +) -> None: + """When every ``pytest_assertrepr_compare`` impl returns ``None`` + (no specialised explanation applies, e.g. ``assert 1 == 2``), the + dispatcher exhausts ``hook_result``, exits the loop, and returns + ``None``. Covers the ``continue → loop exit`` branch on the first + ``if not new_expl: continue`` line. + """ + pytester.makepyfile( + """ + def test_trivial(): + assert 1 == 2 + """ + ) + result = pytester.runpytest() + # Just the plain ``assert 1 == 2`` rewrite, with no specialised + # comparator explanation appended (because the dispatcher fell + # through to ``return None``). + result.stdout.fnmatch_lines(["*assert 1 == 2*"]) + result.assert_outcomes(failed=1) + + def test_callbinrepr_plain_assert_mode(pytester: Pytester) -> None: """In ``--assert=plain`` mode ``callbinrepr`` skips the ``%`` escape. Covers the false branch of ``if item.config.getvalue("assertmode") From 7c7b16bb5e1780ea8c85d0842054a2e114ac1329 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Thu, 28 May 2026 07:48:31 +0200 Subject: [PATCH 12/12] [refactor] Drop the two ``continue``s in ``callbinrepr`` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nest the truthiness checks instead of using ``continue`` to skip to the next ``hook_result`` entry. Behaviourally identical, but each ``continue`` was being reported as a partial branch by codecov even when both arcs were hit by tests — pytester-driven in-process tests don't always show the ``continue → loop exit`` arc, so the partials were sticky. With the nested form the for-loop has a single fall- through edge and the partial disappears. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/_pytest/assertion/__init__.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index a90b6c15798..86082929cef 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -181,19 +181,19 @@ def callbinrepr(op, left: object, right: object) -> str | None: config=item.config, op=op, left=left, right=right ) for new_expl in hook_result: - if not new_expl: - continue # Plugin-supplied lists are truncated here; the built-in # impl truncates as it streams, so this is a no-op on its - # already-short output. - new_expl = truncate.materialize_with_truncation(new_expl, item.config) - if not new_expl: - continue - new_expl = [line.replace("\n", "\\n") for line in new_expl] - res = "\n~".join(new_expl) - if item.config.getvalue("assertmode") == "rewrite": - res = res.replace("%", "%%") - return res + # already-short output. ``materialize_with_truncation`` can + # return ``[]`` when the input was a truthy-but-empty + # iterable, so re-check after materialising. + if new_expl: + new_expl = truncate.materialize_with_truncation(new_expl, item.config) + if new_expl: + new_expl = [line.replace("\n", "\\n") for line in new_expl] + res = "\n~".join(new_expl) + if item.config.getvalue("assertmode") == "rewrite": + res = res.replace("%", "%%") + return res return None saved_assert_hooks = util._reprcompare, util._assertion_pass