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. diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index a4530192407..86082929cef 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -181,13 +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: + # 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. 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 + 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 @@ -218,12 +224,20 @@ def pytest_sessionfinish(session: Session) -> None: def pytest_assertrepr_compare( config: Config, op: str, left: Any, right: Any ) -> 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, @@ -231,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/_compare_any.py b/src/_pytest/assertion/_compare_any.py index 27556c2e8db..9e577683736 100644 --- a/src/_pytest/assertion/_compare_any.py +++ b/src/_pytest/assertion/_compare_any.py @@ -28,26 +28,29 @@ def _compare_eq_any( highlighter: _HighlightFunc, verbose: int, assertion_text_diff_style: _AssertionTextDiffStyle, -) -> list[str]: - explanation = [] +) -> Iterator[str]: + """Yield the per-line explanation for ``left == right`` (without summary). + + Yields nothing when no specialised explanation applies, so consumers + can stream the output and bail out early (e.g. for truncation) without + materialising the entire diff first. + """ if istext(left) and istext(right): - explanation = list( - _compare_eq_text( - left, - right, - highlighter, - verbose, - assertion_text_diff_style, - ) + yield from _compare_eq_text( + left, + right, + highlighter, + verbose, + assertion_text_diff_style, ) else: from _pytest.python_api import ApproxBase # Although the common order should be obtained == approx(...), allow both ways. if isinstance(right, ApproxBase): - explanation = right._repr_compare(left) + yield from right._repr_compare(left) elif isinstance(left, ApproxBase): - explanation = left._repr_compare(right) + yield from left._repr_compare(right) elif type(left) is type(right) and ( isdatacls(left) or isattrs(left) or isnamedtuple(left) ): @@ -55,27 +58,22 @@ def _compare_eq_any( # field values, not the type or field names. But this branch # intentionally only handles the same-type case, which was often # used in older code bases before dataclasses/attrs were available. - explanation = list( - _compare_eq_cls( - left, - right, - highlighter, - verbose, - assertion_text_diff_style, - ) + yield from _compare_eq_cls( + left, + right, + highlighter, + verbose, + assertion_text_diff_style, ) elif issequence(left) and issequence(right): - explanation = list(_compare_eq_sequence(left, right, highlighter, verbose)) + yield from _compare_eq_sequence(left, right, highlighter, verbose) elif isset(left) and isset(right): - explanation = _compare_eq_set(left, right, highlighter, verbose) + yield from _compare_eq_set(left, right, highlighter, verbose) elif ismapping(left) and ismapping(right): - explanation = list(_compare_eq_mapping(left, right, highlighter, verbose)) + yield from _compare_eq_mapping(left, right, highlighter, verbose) if isiterable(left) and isiterable(right): - expl = _compare_eq_iterable(left, right, highlighter, verbose) - explanation.extend(expl) - - return explanation + yield from _compare_eq_iterable(left, right, highlighter, verbose) def _compare_eq_cls( diff --git a/src/_pytest/assertion/_compare_set.py b/src/_pytest/assertion/_compare_set.py index 0fac608fe5c..2817133790d 100644 --- a/src/_pytest/assertion/_compare_set.py +++ b/src/_pytest/assertion/_compare_set.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +from collections.abc import Iterator from collections.abc import Set as AbstractSet from typing import TypeAlias @@ -13,14 +14,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,65 +27,73 @@ 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], + 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/truncate.py b/src/_pytest/assertion/truncate.py index d62ca33cc4b..a428b062ba1 100644 --- a/src/_pytest/assertion/truncate.py +++ b/src/_pytest/assertion/truncate.py @@ -6,9 +6,10 @@ 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 DEFAULT_MAX_LINES = 8 @@ -16,32 +17,77 @@ 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) - if should_truncate: - return _truncate_explanation( - explanation, - max_lines=max_lines, - max_chars=max_chars, - ) - return explanation +_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). + """ + 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) + + 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(item: Item) -> tuple[bool, int, int]: - """Return the truncation parameters related to the given item, as (should truncate, max lines, max chars).""" +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 +99,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 +110,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 +151,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, diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index f6fe2a7e8f8..d4553ff922f 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable +from collections.abc import Iterator from collections.abc import Sequence from typing import Literal from unicodedata import normalize @@ -139,8 +140,19 @@ def assertrepr_compare( verbose: int, highlighter: _HighlightFunc, assertion_text_diff_style: _AssertionTextDiffStyle, -) -> list[str] | None: - """Return specialised explanations for some operators/operands.""" +) -> Iterator[str]: + """Yield specialised explanations for some operators/operands. + + The first line yielded is always the summary (``left op right``); + subsequent lines are the per-line explanation. Yields nothing when no + specialised explanation applies, which lets consumers map an empty + iterator to "no explanation" without materialising anything. + + The iterator is lazy on purpose: a streaming consumer (e.g. the + truncator in ``pytest_assertrepr_compare``) can stop pulling lines as + soon as it has enough to show, so an enormous diff doesn't have to be + built in full just to be thrown away. + """ # Strings which normalize equal are often hard to distinguish when printed; use ascii() to make this easier. # See issue #3246. use_ascii = ( @@ -164,37 +176,39 @@ def assertrepr_compare( summary = f"{left_repr} {op} {right_repr}" - explanation = None + summary_yielded = False try: if op == "==": - explanation = _compare_eq_any( + source = _compare_eq_any( left, right, highlighter, verbose, assertion_text_diff_style, ) - elif op == "not in": - if istext(left) and istext(right): - explanation = list(_notin_text(left, right, verbose)) - elif op in {"!=", ">=", "<=", ">", "<"}: - if isset(left) and isset(right): - explanation = SET_COMPARISON_FUNCTIONS[op]( - left, right, highlighter, verbose - ) - + elif op == "not in" and istext(left) and istext(right): + source = _notin_text(left, right, verbose) + elif op in {"!=", ">=", "<=", ">", "<"} and isset(left) and isset(right): + source = 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..8878539965a 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -56,6 +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 + # 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 "0" raise KeyError(f"Not mocked out: {name}") return Config() @@ -1043,7 +1048,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__.)", @@ -1710,6 +1717,92 @@ 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_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( """ @@ -2203,6 +2296,128 @@ 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 not new_expl: 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_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_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") + == "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 + 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(