From 190dbc9aac0061917ef64b13f6b0c82644f2bd17 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 26 May 2026 08:12:01 +0200 Subject: [PATCH 01/18] [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 02/18] [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 03/18] [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 bd99c3147a8e926831d7e8927781192d978ecfb7 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 26 May 2026 08:17:28 +0200 Subject: [PATCH 04/18] [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 d3e5a1744598f9485885683615bd0be6cb453a53 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 26 May 2026 08:20:04 +0200 Subject: [PATCH 05/18] [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 889b806aef76d9445128260b09c8ea85900d8f2b Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 26 May 2026 13:21:06 +0200 Subject: [PATCH 06/18] [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 7f132d726fc3ed12d73baeadeecca8044811245c Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 26 May 2026 13:38:34 +0200 Subject: [PATCH 07/18] [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 9d24322280ad2ef17999c065f165c49168d9e406 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Wed, 27 May 2026 23:18:09 +0200 Subject: [PATCH 08/18] [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 d8cbc60cc9876896477116a2e2c4a5eebddc7dda Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Wed, 27 May 2026 23:29:08 +0200 Subject: [PATCH 09/18] [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 baf153beb2f5df861568c0e07995ba60745ec8cb Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Thu, 28 May 2026 07:03:37 +0200 Subject: [PATCH 10/18] [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 c9d6ce79153fbc860176455549619bba8a93e7ca Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Thu, 28 May 2026 07:30:25 +0200 Subject: [PATCH 11/18] [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 8407fe55313fba4db5d296fb1784904f84cc980f Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Thu, 28 May 2026 07:48:31 +0200 Subject: [PATCH 12/18] [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 From 6022f03b52170d95d07c52e9baf35f31bdc98401 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Sat, 30 May 2026 17:40:44 +0200 Subject: [PATCH 13/18] [refactor] Drop the hidden-line count from the truncation footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming truncator previously drained the input iterator just to count how many lines it was discarding, so the footer could read ``...Full output truncated (N lines hidden), use '-vv' to show``. With the count gone the footer is a constant string, the iterator is released as soon as the budget is reached, and the idempotency check on already-truncated lists collapses to "the message is a constant, so re-emitting it is harmless". Benchmark — ``materialize_with_truncation`` over a lazy generator whose per-line cost is non-trivial (upper bound of the change's value): n before after speedup 1,000 0.39 ms 0.03 ms ~13x 10,000 4.18 ms 0.03 ms ~140x 100,000 41.29 ms 0.04 ms ~1,000x 1,000,000 419.90 ms 0.04 ms ~10,000x Before is O(n) in iterator length, after is O(1) — confirms the rest of the generator is dropped instead of drained. Benchmark — full ``util.assertrepr_compare`` path on two large sets (today's reality, where ``_compare_eq_iterable`` still builds the diff up-front via ``pformat().splitlines()`` + ``difflib.ndiff``): n before after delta 1,000 2.91 ms 2.68 ms -8% 10,000 31.09 ms 28.26 ms -9% 100,000 376.91 ms 361.03 ms -4% 500,000 2299 ms 2194 ms -5% Marginal today because the upstream isn't lazy yet; the large win will land when ``_compare_eq_iterable`` is made to yield instead of pre-building. --- changelog/14523.improvement.rst | 7 +++ doc/en/example/reportingdemo.rst | 2 +- doc/en/how-to/output.rst | 4 +- src/_pytest/assertion/__init__.py | 12 +++--- src/_pytest/assertion/truncate.py | 71 +++++-------------------------- testing/python/approx.py | 2 +- testing/test_assertion.py | 18 +++----- 7 files changed, 34 insertions(+), 82 deletions(-) diff --git a/changelog/14523.improvement.rst b/changelog/14523.improvement.rst index 57b526e9ee5..3531387cf2c 100644 --- a/changelog/14523.improvement.rst +++ b/changelog/14523.improvement.rst @@ -5,3 +5,10 @@ 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. + +The truncation footer no longer reports the exact number of hidden +lines: ``...Full output truncated (N lines hidden), use '-vv' to +show`` is now ``...Full output truncated, use '-vv' to show``. +Dropping the count means the streaming truncator can return as soon +as it has reached the budget, without draining the rest of the +iterator just to count what it discarded. diff --git a/doc/en/example/reportingdemo.rst b/doc/en/example/reportingdemo.rst index 29ba190b7e7..2e1681cd9cd 100644 --- a/doc/en/example/reportingdemo.rst +++ b/doc/en/example/reportingdemo.rst @@ -148,7 +148,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: E 1 E 1... E - E ...Full output truncated (7 lines hidden), use '-vv' to show + E ...Full output truncated, use '-vv' to show failure_demo.py:62: AssertionError _________________ TestSpecialisedExplanations.test_eq_list _________________ diff --git a/doc/en/how-to/output.rst b/doc/en/how-to/output.rst index db36a5a7206..752d0206526 100644 --- a/doc/en/how-to/output.rst +++ b/doc/en/how-to/output.rst @@ -172,7 +172,7 @@ Now we can increase pytest's verbosity: E 'banana', E 'apple',... E - E ...Full output truncated (7 lines hidden), use '-vv' to show + E ...Full output truncated, use '-vv' to show test_verbosity_example.py:8: AssertionError ____________________________ test_numbers_fail _____________________________ @@ -190,7 +190,7 @@ Now we can increase pytest's verbosity: E {'10': 10, '20': 20, '30': 30, '40': 40} E ... E - E ...Full output truncated (16 lines hidden), use '-vv' to show + E ...Full output truncated, use '-vv' to show test_verbosity_example.py:14: AssertionError ___________________________ test_long_text_fail ____________________________ diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index 86082929cef..7968b056b02 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -181,11 +181,13 @@ def callbinrepr(op, left: object, right: object) -> str | None: config=item.config, op=op, left=left, right=right ) for new_expl in hook_result: - # 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. ``materialize_with_truncation`` can - # return ``[]`` when the input was a truthy-but-empty - # iterable, so re-check after materialising. + # Plugin-supplied lists are truncated here; the built-in impl + # already truncates as it streams, so re-applying truncation + # to its output is a near no-op (the body fits the budget, + # only the footer line is re-emitted with the same wording). + # ``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: diff --git a/src/_pytest/assertion/truncate.py b/src/_pytest/assertion/truncate.py index a428b062ba1..e137d8e0c1f 100644 --- a/src/_pytest/assertion/truncate.py +++ b/src/_pytest/assertion/truncate.py @@ -17,30 +17,14 @@ 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. 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. - - 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). + once exceeded, the rest of the iterator is dropped without being + consumed. This lets a huge comparison short-circuit instead of + building (and immediately discarding) megabytes of explanation text. """ - 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) @@ -49,10 +33,9 @@ def materialize_with_truncation(lines: Iterable[str], config: Config) -> list[st # 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: + for line in lines: buffered.append(line) char_count += len(line) if line_cap is not None and len(buffered) >= line_cap: @@ -63,15 +46,7 @@ def materialize_with_truncation(lines: Iterable[str], config: Config) -> list[st # 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, - ) + return _truncate_explanation(buffered, max_lines=max_lines, max_chars=max_chars) def _get_truncation_parameters(config: Config) -> tuple[bool, int, int]: @@ -99,7 +74,6 @@ 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. @@ -110,27 +84,12 @@ 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. """ - # The length of the truncation explanation depends on the number of lines - # removed but is at least 68 characters: - # The real value is - # 64 (for the base message: - # '...\n...Full output truncated (1 line hidden), use '-vv' to show")' - # ) - # + 1 (for plural) - # + int(math.log10(len(input_lines) - max_lines)) (number of hidden line, at least 1) - # + 3 for the '...' added to the truncated line - # But if there's more than 100 lines it's very likely that we're going to - # truncate, so we don't need the exact value using log10. - tolerable_max_chars = ( - max_chars + 70 # 64 + 1 (for plural) + 2 (for '99') + 3 for '...' - ) + # Slack on top of ``max_chars`` so a body that just fits the budget + # doesn't get truncated solely to make room for the footer. + tolerable_max_chars = max_chars + 70 # The truncation explanation add two lines to the output if max_lines == 0 or len(input_lines) <= max_lines + 2: if max_chars == 0 or sum(len(s) for s in input_lines) <= tolerable_max_chars: @@ -140,27 +99,19 @@ def _truncate_explanation( # Truncate first to max_lines, and then truncate to max_chars if necessary truncated_explanation = input_lines[:max_lines] # We reevaluate the need to truncate chars following removal of some lines - need_to_truncate_char = ( + if ( max_chars > 0 and sum(len(e) for e in truncated_explanation) > tolerable_max_chars - ) - if need_to_truncate_char: + ): truncated_explanation = _truncate_by_char_count( truncated_explanation, max_chars ) # 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) - + extra_hidden - ) return [ *truncated_explanation, "", - f"...Full output truncated ({truncated_line_count} line" - f"{'' if truncated_line_count == 1 else 's'} hidden), {USAGE_MSG}", + f"...Full output truncated, {USAGE_MSG}", ] diff --git a/testing/python/approx.py b/testing/python/approx.py index 88d46cbb755..c5ca03fe823 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -313,7 +313,7 @@ def test_error_messages_with_different_verbosity(self, assert_approx_raises_rege rf"^ \(0,\)\s+\| {SOME_FLOAT} \| {SOME_FLOAT} ± {SOME_FLOAT}e-{SOME_INT}$", rf"^ \(1,\)\s+\| {SOME_FLOAT} \| {SOME_FLOAT} ± {SOME_FLOAT}e-{SOME_INT}\.\.\.$", "^ $", - rf"^ ...Full output truncated \({SOME_INT} lines hidden\), use '-vv' to show$", + r"^ ...Full output truncated, use '-vv' to show$", ], verbosity_level=0, ) diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 8878539965a..5cd63dd7190 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -1159,7 +1159,7 @@ def test_recursive_dataclasses(self, pytester: Pytester) -> None: "E Drill down into differing attribute g:", "E g: S(a=10, b='ten') != S(a=20, b='xxx')...", "E ", - "E ...Full output truncated (51 lines hidden), use '-vv' to show", + "E ...Full output truncated, use '-vv' to show", ], consecutive=True, ) @@ -1532,7 +1532,6 @@ def test_truncates_at_8_lines_when_given_list_of_empty_strings(self) -> None: assert result != expl assert len(result) == 8 + self.LINES_IN_TRUNCATION_MSG assert "Full output truncated" in result[-1] - assert "42 lines hidden" in result[-1] last_line_before_trunc_msg = result[-self.LINES_IN_TRUNCATION_MSG - 1] assert last_line_before_trunc_msg.endswith("...") @@ -1543,7 +1542,6 @@ def test_truncates_at_8_lines_when_first_8_lines_are_LT_max_chars(self) -> None: assert result != expl assert len(result) == 8 + self.LINES_IN_TRUNCATION_MSG assert "Full output truncated" in result[-1] - assert f"{total_lines - 8} lines hidden" in result[-1] last_line_before_trunc_msg = result[-self.LINES_IN_TRUNCATION_MSG - 1] assert last_line_before_trunc_msg.endswith("...") @@ -1562,7 +1560,7 @@ def test_truncates_full_line_because_of_max_chars(self) -> None: "a" * 10, "...", "", - "...Full output truncated (1 line hidden), use '-vv' to show", + "...Full output truncated, use '-vv' to show", ] def test_truncates_edgecase_when_truncation_message_makes_the_result_longer_for_chars( @@ -1587,7 +1585,6 @@ def test_truncates_at_8_lines_when_first_8_lines_are_EQ_max_chars(self) -> None: assert result != expl assert len(result) == 16 - 8 + self.LINES_IN_TRUNCATION_MSG assert "Full output truncated" in result[-1] - assert "8 lines hidden" in result[-1] last_line_before_trunc_msg = result[-self.LINES_IN_TRUNCATION_MSG - 1] assert last_line_before_trunc_msg.endswith("...") @@ -1597,7 +1594,6 @@ def test_truncates_at_4_lines_when_first_4_lines_are_GT_max_chars(self) -> None: assert result != expl assert len(result) == 4 + self.LINES_IN_TRUNCATION_MSG assert "Full output truncated" in result[-1] - assert "7 lines hidden" in result[-1] last_line_before_trunc_msg = result[-self.LINES_IN_TRUNCATION_MSG - 1] assert last_line_before_trunc_msg.endswith("...") @@ -1607,7 +1603,6 @@ def test_truncates_at_1_line_when_first_line_is_GT_max_chars(self) -> None: assert result != expl assert len(result) == 1 + self.LINES_IN_TRUNCATION_MSG assert "Full output truncated" in result[-1] - assert "1000 lines hidden" in result[-1] last_line_before_trunc_msg = result[-self.LINES_IN_TRUNCATION_MSG - 1] assert last_line_before_trunc_msg.endswith("...") @@ -1615,7 +1610,6 @@ def test_full_output_truncated(self, monkeypatch, pytester: Pytester) -> None: """Test against full runpytest() output.""" line_count = 7 line_len = 100 - expected_truncated_lines = 2 pytester.makepyfile( rf""" def test_many_lines(): @@ -1634,7 +1628,7 @@ def test_many_lines(): [ "*+ 1*", "*+ 3*", - f"*truncated ({expected_truncated_lines} lines hidden)*use*-vv*", + "*Full output truncated*use*-vv*", ] ) @@ -1648,7 +1642,7 @@ def test_many_lines(): [ "*+ 1*", "*+ 3*", - f"*truncated ({expected_truncated_lines} lines hidden)*use*-vv*", + "*Full output truncated*use*-vv*", ] ) @@ -1704,9 +1698,7 @@ def test(): result = pytester.runpytest() if expected_lines_hidden != 0: - result.stdout.fnmatch_lines( - [f"*truncated ({expected_lines_hidden} lines hidden)*"] - ) + result.stdout.fnmatch_lines(["*Full output truncated*"]) else: result.stdout.no_fnmatch_line("*truncated*") result.stdout.fnmatch_lines( From 9dc6dc1ca987b652ae954f2119db521471ad1389 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Sat, 30 May 2026 19:44:18 +0200 Subject: [PATCH 14/18] [refactor] Stream ``difflib.ndiff`` output line-by-line in ``_compare_eq_iterable`` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Full diff:" block built the entire diff as a single joined string (``"\n".join(difflib.ndiff(...))``), passed it through the syntax highlighter, and then split it back into lines. That eagerly drained ``difflib.ndiff`` even when the streaming truncator only intended to consume the first handful of lines. Yield each ndiff line through the highlighter individually instead. The diff lexer is line-oriented, so per-line highlighting produces equivalent output — each line just starts with a redundant ``\x1b[0m`` reset (invisible to the terminal). One ``test_comparisons_handle_colors`` parametrisation was updated to match the new byte stream. Benchmark — ``util.assertrepr_compare`` on two large sets with a small symmetric difference (the realistic huge-diff case), measured through ``materialize_with_truncation``: n before after delta 1,000 2.68 ms 2.44 ms -9% 10,000 28.26 ms 25.44 ms -10% 100,000 361.03 ms 316.03 ms -12% 500,000 2194 ms 1993 ms -9% Modest because ``PrettyPrinter().pformat(...)`` and ``SequenceMatcher``'s opcode computation still run up-front; the saving is just the bookkeeping for the ndiff output lines past the truncator's budget. A streaming pformat was prototyped (a line-collecting stream class) but reverted: a pure-Python ``write``-handler can't keep up with C-level ``StringIO`` + ``splitlines``, and the slowdown outweighed the memory saving. --- changelog/14523.improvement.rst | 7 +++++++ src/_pytest/assertion/_compare_sequence.py | 15 +++++++++------ testing/test_assertion.py | 8 ++++---- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/changelog/14523.improvement.rst b/changelog/14523.improvement.rst index 3531387cf2c..e70147a3955 100644 --- a/changelog/14523.improvement.rst +++ b/changelog/14523.improvement.rst @@ -12,3 +12,10 @@ show`` is now ``...Full output truncated, use '-vv' to show``. Dropping the count means the streaming truncator can return as soon as it has reached the budget, without draining the rest of the iterator just to count what it discarded. + +The "Full diff:" block for large iterables now passes each +``difflib.ndiff`` line through the syntax highlighter individually +instead of joining the full diff first. The streaming truncator can +stop ``difflib.ndiff`` as soon as its budget is filled. As a +side-effect, each diff line now starts with a redundant ``\x1b[0m`` +reset (invisible to the terminal). diff --git a/src/_pytest/assertion/_compare_sequence.py b/src/_pytest/assertion/_compare_sequence.py index cd0043bf7ce..b129bd2b3d5 100644 --- a/src/_pytest/assertion/_compare_sequence.py +++ b/src/_pytest/assertion/_compare_sequence.py @@ -29,12 +29,15 @@ def _compare_eq_iterable( yield "Full diff:" # "right" is the expected base against which we compare "left", # see https://github.com/pytest-dev/pytest/issues/3333 - yield from highlighter( - "\n".join( - line.rstrip() for line in difflib.ndiff(right_formatting, left_formatting) - ), - lexer="diff", - ).splitlines() + # + # Yield each ndiff line through the highlighter individually so the + # streaming truncator can stop pulling from ``difflib.ndiff`` as + # soon as its budget is full. The diff lexer is line-oriented, so + # per-line highlighting is equivalent — it just adds a redundant + # ``\x1b[0m`` reset at the start of each line (invisible to the + # terminal). + for line in difflib.ndiff(right_formatting, left_formatting): + yield highlighter(line.rstrip(), lexer="diff") def _compare_eq_sequence( diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 5cd63dd7190..95e31332c0c 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -2456,8 +2456,8 @@ def test(): """, [ "{bold}{red}E At index 1 diff: {reset}{number}1{hl-reset}{endline} != {reset}{number}2*", - "{bold}{red}E {light-red}- 2,{hl-reset}{endline}{reset}", - "{bold}{red}E {light-green}+ 1,{hl-reset}{endline}{reset}", + "{bold}{red}E {reset}{light-red}- 2,{hl-reset}{endline}{reset}", + "{bold}{red}E {reset}{light-green}+ 1,{hl-reset}{endline}{reset}", ], ), ( @@ -2475,8 +2475,8 @@ def test(): "{bold}{red}E Right contains 1 more item:{reset}", "{bold}{red}E {reset}{{{str}'{hl-reset}{str}number-is-0{hl-reset}{str}'{hl-reset}: {number}0*", "{bold}{red}E {reset}{light-gray} {hl-reset} {{{endline}{reset}", - "{bold}{red}E {light-gray} {hl-reset} 'number-is-1': 1,{endline}{reset}", - "{bold}{red}E {light-green}+ 'number-is-5': 5,{hl-reset}{endline}{reset}", + "{bold}{red}E {reset}{light-gray} {hl-reset} 'number-is-1': 1,{endline}{reset}", + "{bold}{red}E {reset}{light-green}+ 'number-is-5': 5,{hl-reset}{endline}{reset}", ], ), ( From d3f6655a4e9f8eac7272f87583c13bc19d66f217 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Sat, 30 May 2026 20:08:37 +0200 Subject: [PATCH 15/18] [perf] Make ``PrettyPrinter`` lazy via a budget-aware stream + sort fast path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes unlock ``_compare_eq_iterable`` for huge collections, where it was previously O(N) in input size regardless of how few lines the truncator wanted downstream. 1. ``_LineBudgetStream`` + ``PrettyPrinter.pformat_lines(max_lines=...)``: a stream whose ``write`` raises ``_LineBudgetExceeded`` once enough ``\n``-terminated lines have accumulated. The formatter's recursion unwinds at the next write and the caller keeps the partial output. PrettyPrinter writes one chunk per element, so the abort fires on element boundaries — a 500,000-element set's pformat becomes O(100) lines instead of O(500,000). 2. ``_pprint_set`` now tries ``sorted(object)`` first and only falls back to ``sorted(object, key=_safe_key)`` on ``TypeError``. The ``_safe_key`` wrap exists for heterogeneous-type sets, but it allocates a wrapper per element — for 500,000 ints, plain ``sorted`` is ~16x faster (15 ms vs 252 ms). ``_compare_eq_iterable`` uses ``pformat_lines(max_lines=100)`` only when the truncator will be active downstream (``verbose < 2`` and not running under CI). At ``-vv`` or under CI the user expects the full diff and the truncator is disabled, so the cap is dropped. Benchmark — ``util.assertrepr_compare`` on two large sets with a small symmetric difference, measured through ``materialize_with_truncation``: n before after speedup 1,000 2.44 ms 0.44 ms ~6x 10,000 25.44 ms 1.00 ms ~25x 100,000 316 ms 8.41 ms ~38x 500,000 1993 ms 42.5 ms ~47x Scaling went from near-linear to sub-linear — the per-element work in pformat and ``difflib.ndiff`` no longer dominates. The remaining floor is the inherent cost of computing the set difference and the ``saferepr`` calls on the symmetric-diff items. A previous attempt to make ``pformat_lines`` non-bailing (just streaming the writes) was reverted because a pure-Python ``write`` handler can't compete with C ``StringIO`` + C ``str.splitlines``; the budget-aware variant only pays Python overhead until the abort fires, by which point it has already saved far more than it cost. --- changelog/14523.improvement.rst | 11 +++ src/_pytest/_io/pprint.py | 78 +++++++++++++++++++++- src/_pytest/assertion/_compare_sequence.py | 11 ++- 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/changelog/14523.improvement.rst b/changelog/14523.improvement.rst index e70147a3955..79fdcf3de35 100644 --- a/changelog/14523.improvement.rst +++ b/changelog/14523.improvement.rst @@ -19,3 +19,14 @@ instead of joining the full diff first. The streaming truncator can stop ``difflib.ndiff`` as soon as its budget is filled. As a side-effect, each diff line now starts with a redundant ``\x1b[0m`` reset (invisible to the terminal). + +When the truncator is going to be active (``verbose < 2`` outside +CI), ``_compare_eq_iterable`` now caps ``PrettyPrinter`` output at +~100 lines per side via a budget-aware stream that raises out of the +formatter once it has emitted enough lines. Combined with a fast +path for sorted set comparisons (try the natural ordering first, +fall back to the ``_safe_key`` wrap only on ``TypeError``), this +turns the diff for a 500,000-element set comparison from ~2,200 ms +down to ~42 ms (~50× speedup) while leaving the visible output +identical for the truncated display. ``-vv`` and CI runs still get +the full uncapped diff. diff --git a/src/_pytest/_io/pprint.py b/src/_pytest/_io/pprint.py index ec41b449ddf..98668c0b3e2 100644 --- a/src/_pytest/_io/pprint.py +++ b/src/_pytest/_io/pprint.py @@ -56,6 +56,51 @@ def _safe_tuple(t): return _safe_key(t[0]), _safe_key(t[1]) +class _LineBudgetExceeded(Exception): + """Internal: signals that ``_LineBudgetStream`` has reached its cap. + + Raised from inside ``stream.write`` so the formatter's recursion + unwinds at the next write; the caller catches it and uses whatever + output accumulated so far. + """ + + +class _LineBudgetStream: + r"""Stream that collects ``write`` calls into lines and bails out + once ``max_lines`` have been produced. + + Lets ``PrettyPrinter._format`` stop early on huge collections: the + formatter writes one ``\n``-terminated chunk per element, so the + budget check fires on element boundaries. + """ + + __slots__ = ("_lines", "_max", "_pending") + + def __init__(self, max_lines: int) -> None: + self._lines: list[str] = [] + self._pending: list[str] = [] + self._max = max_lines + + def write(self, s: str) -> None: + if "\n" not in s: + if s: + self._pending.append(s) + return + parts = s.split("\n") + self._pending.append(parts[0]) + self._lines.append("".join(self._pending)) + self._lines.extend(parts[1:-1]) + self._pending = [parts[-1]] if parts[-1] else [] + if len(self._lines) >= self._max: + raise _LineBudgetExceeded + + def finish(self) -> list[str]: + if self._pending: + self._lines.append("".join(self._pending)) + self._pending = [] + return self._lines + + class PrettyPrinter: def __init__( self, @@ -91,6 +136,32 @@ def pformat(self, object: Any) -> str: self._format(object, sio, 0, 0, set(), 0) return sio.getvalue() + def pformat_lines(self, object: Any, max_lines: int | None = None) -> list[str]: + """Pretty-print ``object`` and return its lines. + + With ``max_lines=None`` this is just ``self.pformat(object). + splitlines()`` — the fast C path through ``StringIO`` and + ``str.splitlines``. + + With ``max_lines`` set, the formatter is wired to a Python-level + stream that raises once that many lines have been produced; the + caller gets back whatever was emitted before the abort. For + huge collections this turns an O(N) pformat into O(``max_lines``) + — useful when a downstream truncator is going to drop everything + past a small budget anyway. + """ + if max_lines is None: + return self.pformat(object).splitlines() + stream = _LineBudgetStream(max_lines) + try: + # ``_format``'s ``IO[str]`` annotation is overly tight — it + # only ever calls ``stream.write(str)``, which is all this + # budget-aware stream implements. + self._format(object, stream, 0, 0, set(), 0) # type: ignore[arg-type] + except _LineBudgetExceeded: + pass + return stream.finish() + def _format( self, object: Any, @@ -236,7 +307,12 @@ def _pprint_set( else: stream.write(typ.__name__ + "({") endchar = "})" - object = sorted(object, key=_safe_key) + try: + object = sorted(object) + except TypeError: + # Heterogeneous element types — fall back to a key that + # tolerates unorderable pairs by string-comparing their types. + object = sorted(object, key=_safe_key) self._format_items(object, stream, indent, allowance, context, level) stream.write(endchar) diff --git a/src/_pytest/assertion/_compare_sequence.py b/src/_pytest/assertion/_compare_sequence.py index b129bd2b3d5..530f95f89a5 100644 --- a/src/_pytest/assertion/_compare_sequence.py +++ b/src/_pytest/assertion/_compare_sequence.py @@ -22,8 +22,15 @@ def _compare_eq_iterable( # dynamic import to speedup pytest import difflib - left_formatting = PrettyPrinter().pformat(left).splitlines() - right_formatting = PrettyPrinter().pformat(right).splitlines() + # At ``verbose >= 2`` (and under CI) the truncator is disabled and + # the user expects the full diff — pformat with no cap. Otherwise + # cap pformat at a generous line count: the truncator downstream + # will drop anything beyond ~10 lines, so spending O(N) building + # the rest is pure waste on huge collections. + pformat_cap = None if verbose >= 2 or running_on_ci() else 100 + pp = PrettyPrinter() + left_formatting = pp.pformat_lines(left, max_lines=pformat_cap) + right_formatting = pp.pformat_lines(right, max_lines=pformat_cap) yield "" yield "Full diff:" From dec2302cc24a7114c9b804f48530887895388747 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Sat, 30 May 2026 20:15:14 +0200 Subject: [PATCH 16/18] [refactor] Plumb ``truncation_limit_lines`` to ``_compare_eq_iterable``'s pformat cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit hardcoded a 100-line ``pformat_cap`` inside ``_compare_eq_iterable``. The dispatcher already knows the real budget — ``truncate._get_truncation_parameters(config)`` returns it — so thread that value through ``util.assertrepr_compare`` and ``_compare_eq_any`` instead. The cap is now ``truncation_limit_lines + 3`` (matching the truncator's own ``line_cap``: 2 lines for the truncation message it appends + 1 for overshoot detection), or ``None`` when the truncator is disabled. Users who bump ``truncation_limit_lines`` or run ``-vv`` get the diff at the budget they asked for, not a hardcoded one. The verbose/CI heuristic moves out of ``_compare_eq_iterable`` — ``pformat_cap=None`` from the dispatcher already encodes "truncator off, give me everything", so the helper just respects the cap. Full test suite: 4111 passed, 107 skipped, 12 xfailed, 1 xpassed. Cumulative benchmark for this branch's four-commit perf series (precision-drop + ndiff streaming + lazy PrettyPrinter + cap plumbing) — synthetic lazy generator with non-trivial per-yield work, upper bound of the streaming-truncation effect: n before after speedup 1,000 0.39 ms 0.04 ms ~10x 10,000 3.65 ms 0.04 ms ~90x 100,000 37.35 ms 0.04 ms ~930x 1,000,000 389.67 ms 0.04 ms ~9700x Before is O(n) in iterator length, after is O(1). Real ``util.assertrepr_compare`` on two large sets with a small symmetric difference (the realistic huge-diff case), measured through ``materialize_with_truncation``: n before after speedup 1,000 5.66 ms 0.23 ms ~25x 10,000 40.28 ms 1.02 ms ~40x 100,000 395.61 ms 11.53 ms ~34x 500,000 2422 ms 58.50 ms ~41x Scaling went from near-linear to sub-linear. The remaining floor is the inherent O(N) cost of the set difference and the ``saferepr`` calls on the symmetric-diff items themselves. --- changelog/14523.improvement.rst | 19 +++++++++++-------- src/_pytest/assertion/__init__.py | 15 +++++++++++++++ src/_pytest/assertion/_compare_any.py | 5 ++++- src/_pytest/assertion/_compare_sequence.py | 12 ++++++------ src/_pytest/assertion/util.py | 2 ++ 5 files changed, 38 insertions(+), 15 deletions(-) diff --git a/changelog/14523.improvement.rst b/changelog/14523.improvement.rst index 79fdcf3de35..b4c09cb3b71 100644 --- a/changelog/14523.improvement.rst +++ b/changelog/14523.improvement.rst @@ -22,11 +22,14 @@ reset (invisible to the terminal). When the truncator is going to be active (``verbose < 2`` outside CI), ``_compare_eq_iterable`` now caps ``PrettyPrinter`` output at -~100 lines per side via a budget-aware stream that raises out of the -formatter once it has emitted enough lines. Combined with a fast -path for sorted set comparisons (try the natural ordering first, -fall back to the ``_safe_key`` wrap only on ``TypeError``), this -turns the diff for a 500,000-element set comparison from ~2,200 ms -down to ~42 ms (~50× speedup) while leaving the visible output -identical for the truncated display. ``-vv`` and CI runs still get -the full uncapped diff. +the truncator's ``truncation_limit_lines`` budget (plus 3 lines — +matching the truncator's own ``line_cap``: 2 lines for the truncation +message it appends + 1 for overshoot detection) via a budget-aware +stream that raises out of the formatter +once it has emitted enough lines. Combined with a fast path for +sorted set comparisons (try the natural ordering first, fall back to +the ``_safe_key`` wrap only on ``TypeError``), this turns the diff +for a 500,000-element set comparison from ~2,200 ms down to ~43 ms +(~50× speedup) while leaving the visible output identical for the +truncated display. ``-vv`` and CI runs still get the full uncapped +diff. diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index 7968b056b02..847dbaa287a 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -239,6 +239,20 @@ def pytest_assertrepr_compare( else: # Keep it plaintext when not using terminalrepoterer (#14377). highlighter = util.dummy_highlighter + # When truncation is going to clip the explanation downstream, tell + # the comparison helpers to cap their pformat output at the same + # budget so they don't spend O(N) formatting lines we're about to + # drop. ``+ 3`` matches the truncator's own ``line_cap``: 2 lines + # for the truncation message it appends (blank + footer) plus 1 + # for overshoot detection. ``difflib.ndiff`` over two K-line + # pformat outputs produces at least K output lines (more when the + # sides differ), and the truncator pulls at most ``trunc_lines + + # 3`` lines from the whole explanation, so a per-side pformat + # budget of ``trunc_lines + 3`` covers the worst case. With + # truncation disabled the cap stays ``None`` and the user gets the + # full diff. + should_truncate, trunc_lines, _ = truncate._get_truncation_parameters(config) + pformat_cap = trunc_lines + 3 if should_truncate and trunc_lines > 0 else None lines = util.assertrepr_compare( op=op, left=left, @@ -246,5 +260,6 @@ def pytest_assertrepr_compare( verbose=config.get_verbosity(Config.VERBOSITY_ASSERTIONS), highlighter=highlighter, assertion_text_diff_style=util.get_assertion_text_diff_style(config), + pformat_cap=pformat_cap, ) return truncate.materialize_with_truncation(lines, config) or None diff --git a/src/_pytest/assertion/_compare_any.py b/src/_pytest/assertion/_compare_any.py index 9e577683736..73012abc416 100644 --- a/src/_pytest/assertion/_compare_any.py +++ b/src/_pytest/assertion/_compare_any.py @@ -28,6 +28,7 @@ def _compare_eq_any( highlighter: _HighlightFunc, verbose: int, assertion_text_diff_style: _AssertionTextDiffStyle, + pformat_cap: int | None = None, ) -> Iterator[str]: """Yield the per-line explanation for ``left == right`` (without summary). @@ -73,7 +74,9 @@ def _compare_eq_any( yield from _compare_eq_mapping(left, right, highlighter, verbose) if isiterable(left) and isiterable(right): - yield from _compare_eq_iterable(left, right, highlighter, verbose) + yield from _compare_eq_iterable( + left, right, highlighter, verbose, pformat_cap + ) def _compare_eq_cls( diff --git a/src/_pytest/assertion/_compare_sequence.py b/src/_pytest/assertion/_compare_sequence.py index 530f95f89a5..52edb136d6e 100644 --- a/src/_pytest/assertion/_compare_sequence.py +++ b/src/_pytest/assertion/_compare_sequence.py @@ -15,6 +15,7 @@ def _compare_eq_iterable( right: Iterable[object], highlighter: _HighlightFunc, verbose: int = 0, + pformat_cap: int | None = None, ) -> Iterator[str]: if verbose <= 0 and not running_on_ci(): yield "Use -v to get more diff" @@ -22,12 +23,11 @@ def _compare_eq_iterable( # dynamic import to speedup pytest import difflib - # At ``verbose >= 2`` (and under CI) the truncator is disabled and - # the user expects the full diff — pformat with no cap. Otherwise - # cap pformat at a generous line count: the truncator downstream - # will drop anything beyond ~10 lines, so spending O(N) building - # the rest is pure waste on huge collections. - pformat_cap = None if verbose >= 2 or running_on_ci() else 100 + # ``pformat_cap`` is computed by the dispatcher from the + # truncator's ``truncation_limit_lines``: when truncation is going + # to drop everything past that budget anyway, we don't bother + # formatting more. ``None`` means no cap (``-vv`` or CI: the user + # wants the full diff). pp = PrettyPrinter() left_formatting = pp.pformat_lines(left, max_lines=pformat_cap) right_formatting = pp.pformat_lines(right, max_lines=pformat_cap) diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index d4553ff922f..e53b290744e 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -140,6 +140,7 @@ def assertrepr_compare( verbose: int, highlighter: _HighlightFunc, assertion_text_diff_style: _AssertionTextDiffStyle, + pformat_cap: int | None = None, ) -> Iterator[str]: """Yield specialised explanations for some operators/operands. @@ -185,6 +186,7 @@ def assertrepr_compare( highlighter, verbose, assertion_text_diff_style, + pformat_cap, ) elif op == "not in" and istext(left) and istext(right): source = _notin_text(left, right, verbose) From 421669716b1f5b68307efb2cc0f109f0b24b6f75 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Sat, 30 May 2026 22:03:42 +0200 Subject: [PATCH 17/18] [perf] Fast-path ``pformat_lines`` for sized inputs that fit in the budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lazy-PrettyPrinter commit routed every ``pformat_lines(max_lines=K)`` call through the pure-Python ``_LineBudgetStream``, which is ~1.3x slower than C ``StringIO`` + C ``splitlines`` on tiny inputs (per- write overhead dominates). Most assertion diffs that hit ``_compare_eq_iterable`` are small lists/dicts, so the worst-case path was being taken even when there was nothing to bound. Skip the budget stream when ``len(object) <= max_lines``: one pformat line per element is the lower bound for any container, so this is a sufficient condition for "the budget cap will never fire" — the plain ``pformat().splitlines()`` path produces the same output more cheaply. Inputs without ``__len__`` (generators, etc.) still go through the budget stream. Doesn't touch the huge-set fast path: at ``len > max_lines`` we still take the budget-aware path that bounds work to O(max_lines). Realistic benchmark — pylint's own test suite (2,287 tests, 278 assertion failures, ~115 s per run), interleaved 4 rounds upstream vs HEAD to control for thermal/cache drift: metric upstream/main HEAD delta min 114.14 s 106.43 s -6.8% median 115.69 s 115.84 s +0.1% mean 115.95 s 114.20 s -1.5% Statistically indistinguishable — the macro workload doesn't exercise the streaming-truncation path enough to measure. (Earlier sequential benches showed a 5-9% "regression" that turned out to be a thermal artifact: when one side runs first and the other second, the second side runs hotter; interleaving cancels it.) Same 278/1747/2 outcome on every run, confirming no behavioral change. The micro-benchmark on huge-set comparisons stays ~40-50x faster — that's the case this perf series was designed for. --- src/_pytest/_io/pprint.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/_pytest/_io/pprint.py b/src/_pytest/_io/pprint.py index 98668c0b3e2..ae58c34c753 100644 --- a/src/_pytest/_io/pprint.py +++ b/src/_pytest/_io/pprint.py @@ -149,9 +149,25 @@ def pformat_lines(self, object: Any, max_lines: int | None = None) -> list[str]: huge collections this turns an O(N) pformat into O(``max_lines``) — useful when a downstream truncator is going to drop everything past a small budget anyway. + + Sized containers small enough that pformat will obviously fit + in the budget take the fast path too: the pure-Python budget + stream is ~1.3x slower than ``StringIO`` + ``splitlines`` on + tiny inputs (per-write overhead dominates), and paying that on + common-case small assertion diffs is a net loss. """ if max_lines is None: return self.pformat(object).splitlines() + # Sufficient condition for "the budget will never fire": one + # pformat line per element is the lower bound for any + # container, so ``len(object) <= max_lines`` guarantees the + # full pformat fits in the budget. + try: + size = len(object) + except TypeError: + size = -1 + if 0 <= size <= max_lines: + return self.pformat(object).splitlines() stream = _LineBudgetStream(max_lines) try: # ``_format``'s ``IO[str]`` annotation is overly tight — it From 411907315f01913b78ac330044bb5e0ba7287474 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Sat, 30 May 2026 22:33:37 +0200 Subject: [PATCH 18/18] [docs] Trim the streaming-truncation changelog Tighten the entry to two short paragraphs: a one-line summary of the change, the micro vs realistic-suite numbers side by side, and the user-visible footer / per-line reset side-effects. Drops the implementation walk-through that earlier drafts carried. --- changelog/14523.improvement.rst | 46 +++++++++------------------------ 1 file changed, 12 insertions(+), 34 deletions(-) diff --git a/changelog/14523.improvement.rst b/changelog/14523.improvement.rst index b4c09cb3b71..d53374c62df 100644 --- a/changelog/14523.improvement.rst +++ b/changelog/14523.improvement.rst @@ -1,35 +1,13 @@ -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. +Assertion explanations are now built lazily and the truncator stops +the comparison helpers as soon as it has enough output, so comparing +two large collections no longer builds the full diff in order to +discard it. A focused micro-benchmark the worst case scenario +(``set(range(500_000)) == set(range(1, 500_001))``) drops from ~2,200 ms +to ~43 ms; but realistic test suite with mostly small diffs should be +unchanged. -The truncation footer no longer reports the exact number of hidden -lines: ``...Full output truncated (N lines hidden), use '-vv' to -show`` is now ``...Full output truncated, use '-vv' to show``. -Dropping the count means the streaming truncator can return as soon -as it has reached the budget, without draining the rest of the -iterator just to count what it discarded. - -The "Full diff:" block for large iterables now passes each -``difflib.ndiff`` line through the syntax highlighter individually -instead of joining the full diff first. The streaming truncator can -stop ``difflib.ndiff`` as soon as its budget is filled. As a -side-effect, each diff line now starts with a redundant ``\x1b[0m`` -reset (invisible to the terminal). - -When the truncator is going to be active (``verbose < 2`` outside -CI), ``_compare_eq_iterable`` now caps ``PrettyPrinter`` output at -the truncator's ``truncation_limit_lines`` budget (plus 3 lines — -matching the truncator's own ``line_cap``: 2 lines for the truncation -message it appends + 1 for overshoot detection) via a budget-aware -stream that raises out of the formatter -once it has emitted enough lines. Combined with a fast path for -sorted set comparisons (try the natural ordering first, fall back to -the ``_safe_key`` wrap only on ``TypeError``), this turns the diff -for a 500,000-element set comparison from ~2,200 ms down to ~43 ms -(~50× speedup) while leaving the visible output identical for the -truncated display. ``-vv`` and CI runs still get the full uncapped -diff. +The truncation footer no longer reports the hidden-line count +(``...Full output truncated (N lines hidden), ...`` becomes +``...Full output truncated, ...``); diff lines now carry a redundant +``\x1b[0m`` reset prefix (invisible to terminals) so we can handle +line one by one.