Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 ``==`` mismatch 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
74 changes: 59 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,53 @@ 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, mirroring
# the truncator's own slack so a side is never under-formatted:
#
# * ``trunc_lines + 3``: 2 lines for the truncation footer it appends
# (blank + message) plus 1 for overshoot detection.
# * ``trunc_chars + 70``: the truncator's own ``tolerable_max_chars``
# slack (footer length).
#
# ``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 from the whole explanation, 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 + 3 if trunc_lines > 0 else None,
trunc_chars + 70 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.python_api import ApproxBase
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