Skip to content
4 changes: 4 additions & 0 deletions changelog/14523.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Large assertion comparison diffs are now built lazily and capped to the
truncation budget, so a huge diff is no longer formatted in full just to
be truncated. As a result the truncation footer no longer
reports the exact number of hidden lines.
2 changes: 1 addition & 1 deletion doc/en/example/reportingdemo.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 _________________
Expand Down
4 changes: 2 additions & 2 deletions doc/en/how-to/output.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 _____________________________
Expand All @@ -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 ____________________________
Expand Down
72 changes: 57 additions & 15 deletions src/_pytest/assertion/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from _pytest.assertion import rewrite
from _pytest.assertion import truncate
from _pytest.assertion import util
from _pytest.assertion._typing import NO_TRUNCATION_BUDGET
from _pytest.assertion._typing import TruncationBudget
from _pytest.assertion.rewrite import assertstate_key
from _pytest.config import Config
from _pytest.config import hookimpl
Expand Down Expand Up @@ -181,13 +183,21 @@ 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
# 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.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
Expand Down Expand Up @@ -218,19 +228,51 @@ 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
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),
# 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/chars we're about to drop. The cap is
# ``(max_lines, max_chars)`` per side, matching what the truncator will
# actually pull (the raw limit plus the footer slack — see
# ``truncate.TRUNCATION_FOOTER_LINES`` / ``TRUNCATION_FOOTER_CHARS``), so a
# side is never under-formatted.
#
# ``difflib.ndiff`` over two K-line/char pformat outputs produces at least
# K output lines/chars (more when the sides differ), and the truncator
# pulls at most that much, so a per-side budget covers the worst case. A
# dimension whose limit is 0 (disabled) stays ``None`` so it isn't bounded;
# with truncation off both stay ``None`` and the user gets the full diff.
should_truncate, trunc_lines, trunc_chars = truncate._get_truncation_parameters(
config
)
if should_truncate:
truncation_budget = TruncationBudget(
trunc_lines + truncate.TRUNCATION_FOOTER_LINES + 1
if trunc_lines > 0
else None,
trunc_chars + truncate.TRUNCATION_FOOTER_CHARS if trunc_chars > 0 else None,
)
else:
truncation_budget = NO_TRUNCATION_BUDGET
lines = 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),
truncation_budget=truncation_budget,
)
return explanation or None
return truncate.materialize_with_truncation(lines, config) or None
12 changes: 10 additions & 2 deletions src/_pytest/assertion/_compare_any.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
from _pytest.assertion._guards import istext
from _pytest.assertion._typing import _AssertionTextDiffStyle
from _pytest.assertion._typing import _HighlightFunc
from _pytest.assertion._typing import NO_TRUNCATION_BUDGET
from _pytest.assertion._typing import TruncationBudget
from _pytest.assertion.compare_text import _compare_eq_text


Expand All @@ -28,6 +30,7 @@ def _compare_eq_any(
highlighter: _HighlightFunc,
verbose: int,
assertion_text_diff_style: _AssertionTextDiffStyle,
truncation_budget: TruncationBudget = NO_TRUNCATION_BUDGET,
) -> Iterator[str]:
"""Yield the per-line explanation for ``left == right`` (without summary).

Expand All @@ -42,6 +45,7 @@ def _compare_eq_any(
highlighter,
verbose,
assertion_text_diff_style,
truncation_budget,
)
else:
from _pytest.approx import Approx
Expand Down Expand Up @@ -70,10 +74,14 @@ def _compare_eq_any(
elif isset(left) and isset(right):
yield from _compare_eq_set(left, right, highlighter, verbose)
elif ismapping(left) and ismapping(right):
yield from _compare_eq_mapping(left, right, highlighter, verbose)
yield from _compare_eq_mapping(
left, right, highlighter, verbose, truncation_budget
)

if isiterable(left) and isiterable(right):
yield from _compare_eq_iterable(left, right, highlighter, verbose)
yield from _compare_eq_iterable(
left, right, highlighter, verbose, truncation_budget
)


def _compare_eq_cls(
Expand Down
37 changes: 33 additions & 4 deletions src/_pytest/assertion/_compare_mapping.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
from __future__ import annotations

from collections.abc import Collection
from collections.abc import Iterator
from collections.abc import Mapping
import heapq
import pprint

from _pytest._io.pprint import _safe_key
from _pytest._io.saferepr import saferepr
from _pytest.assertion._typing import _HighlightFunc
from _pytest.assertion._typing import NO_TRUNCATION_BUDGET
from _pytest.assertion._typing import TruncationBudget


def _compare_eq_mapping(
left: Mapping[object, object],
right: Mapping[object, object],
highlighter: _HighlightFunc,
verbose: int = 0,
truncation_budget: TruncationBudget = NO_TRUNCATION_BUDGET,
) -> Iterator[str]:
max_lines = truncation_budget.max_lines
set_left = set(left)
set_right = set(right)
common = set_left.intersection(set_right)
Expand All @@ -36,13 +43,35 @@ def _compare_eq_mapping(
len_extra_left = len(extra_left)
if len_extra_left:
yield f"Left contains {len_extra_left} more item{'' if len_extra_left == 1 else 's'}:"
yield from highlighter(
pprint.pformat({k: left[k] for k in extra_left})
).splitlines()
yield from _format_extra_items(left, extra_left, highlighter, max_lines)
extra_right = set_right - set_left
len_extra_right = len(extra_right)
if len_extra_right:
yield f"Right contains {len_extra_right} more item{'' if len_extra_right == 1 else 's'}:"
yield from _format_extra_items(right, extra_right, highlighter, max_lines)


def _format_extra_items(
mapping: Mapping[object, object],
keys: Collection[object],
highlighter: _HighlightFunc,
max_lines: int | None,
) -> Iterator[str]:
"""Render the "X contains N more items" subdict.

Small (or untruncated, ``max_lines is None``) output keeps the compact,
key-sorted ``pprint`` block. When there are more extra keys than the
truncation budget, ``pprint.pformat`` would format the whole subdict
just to have all but the first few lines dropped, so instead emit only
the smallest ``max_lines`` keys, one per line — deterministic via the
same safe sort ``pprint`` uses, char-bounded via ``saferepr``. (This
differs from the ``pprint`` block, but only in the truncated tail; the
smallest keys shown are the same ones ``pprint`` would have led with.)
"""
if max_lines is None or len(keys) <= max_lines:
yield from highlighter(
pprint.pformat({k: right[k] for k in extra_right})
pprint.pformat({k: mapping[k] for k in keys})
).splitlines()
return
for k in heapq.nsmallest(max_lines, keys, key=_safe_key):
yield highlighter(saferepr({k: mapping[k]}))
30 changes: 22 additions & 8 deletions src/_pytest/assertion/_compare_sequence.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from _pytest._io.pprint import PrettyPrinter
from _pytest._io.saferepr import saferepr
from _pytest.assertion._typing import _HighlightFunc
from _pytest.assertion._typing import NO_TRUNCATION_BUDGET
from _pytest.assertion._typing import TruncationBudget
from _pytest.compat import running_on_ci


Expand All @@ -15,26 +17,38 @@ def _compare_eq_iterable(
right: Iterable[object],
highlighter: _HighlightFunc,
verbose: int = 0,
truncation_budget: TruncationBudget = NO_TRUNCATION_BUDGET,
) -> Iterator[str]:
if verbose <= 0 and not running_on_ci():
yield "Use -v to get more diff"
return
# dynamic import to speedup pytest
import difflib

left_formatting = PrettyPrinter().pformat(left).splitlines()
right_formatting = PrettyPrinter().pformat(right).splitlines()
# ``truncation_budget`` is ``(max_lines, max_chars)``, computed by the
# dispatcher from the truncator's ``truncation_limit_lines`` /
# ``truncation_limit_chars``: when truncation is going to drop
# everything past those budgets anyway, we don't bother formatting
# more. ``(None, None)`` means no cap (``-vv`` or CI: the user wants
# the full diff).
pp = PrettyPrinter()
max_lines, max_chars = truncation_budget
left_formatting = pp.pformat_lines(left, max_lines=max_lines, max_chars=max_chars)
right_formatting = pp.pformat_lines(right, max_lines=max_lines, max_chars=max_chars)

yield ""
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(
Expand Down
19 changes: 19 additions & 0 deletions src/_pytest/assertion/_typing.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,31 @@
from __future__ import annotations

from typing import Literal
from typing import NamedTuple
from typing import Protocol


_AssertionTextDiffStyle = Literal["ndiff", "block"]


class TruncationBudget(NamedTuple):
"""Per-side budget for capping diff formatting before truncation.

``max_lines`` / ``max_chars`` bound how much of each operand is
formatted when the explanation is going to be truncated anyway. A
``None`` dimension is left unbounded (``-vv`` / CI: the full diff is
wanted).
"""

max_lines: int | None = None
max_chars: int | None = None


# Module-level singleton for "no cap" (the full diff is formatted), used as a
# default argument so we do not build a fresh instance on every call (B008).
NO_TRUNCATION_BUDGET = TruncationBudget()


class _HighlightFunc(Protocol): # noqa: PYI046
def __call__(self, source: str, lexer: Literal["diff", "python"] = "python") -> str:
"""Apply highlighting to the given source."""
Loading
Loading