Skip to content
Open
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 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 ``0`` so it isn't bounded;
# with truncation off both stay ``0`` and the user gets the full diff.
should_truncate, base_budget = truncate._get_truncation_parameters(config)
if should_truncate:
truncation_budget = TruncationBudget(
max_lines=base_budget.max_lines + truncate.TRUNCATION_FOOTER_LINES + 1
if base_budget.max_lines > 0
else 0,
max_chars=base_budget.max_chars + truncate.TRUNCATION_FOOTER_CHARS
if base_budget.max_chars > 0
else 0,
)
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
39 changes: 35 additions & 4 deletions src/_pytest/assertion/_compare_mapping.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
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]:
set_left = set(left)
set_right = set(right)
Expand All @@ -36,13 +42,38 @@ 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, truncation_budget)
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, truncation_budget
)


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

Small (or untruncated, ``max_lines == 0``) 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.)
"""
max_lines = truncation_budget.max_lines
if max_lines == 0 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]}))
24 changes: 16 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,32 @@ 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`` carries ``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.
pp = PrettyPrinter()
max_lines, max_chars = truncation_budget.as_pformat_bounds()
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()
# Highlight each ndiff line individually so the streaming truncator can
# stop pulling from ``difflib.ndiff`` once its budget is full; the diff
# lexer is line-oriented, so per-line highlighting is equivalent.
for line in difflib.ndiff(right_formatting, left_formatting):
yield highlighter(line.rstrip(), lexer="diff")


def _compare_eq_sequence(
Expand Down
20 changes: 20 additions & 0 deletions src/_pytest/assertion/_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,26 @@ class TruncationBudget:
max_lines: int
max_chars: int

def as_pformat_bounds(self) -> tuple[int | None, int | None]:
"""Return ``(max_lines, max_chars)`` for ``PrettyPrinter.pformat_lines``.

That API spells an unbounded dimension as ``None``; this budget
spells it ``0``.
"""
return self.max_lines or None, self.max_chars or None

def cap_chars(self, text: str) -> str:
"""Clip ``text`` to ``max_chars`` (``0`` leaves it unbounded)."""
return text[: self.max_chars] if self.max_chars > 0 else text

def cap_lines(self, lines: list[str]) -> list[str]:
"""Clip ``lines`` to ``max_lines`` (``0`` leaves it unbounded)."""
return lines[: self.max_lines] if self.max_lines > 0 else lines


# Reusable "no cap" budget, used as a default argument (B008).
NO_TRUNCATION_BUDGET = TruncationBudget(max_lines=0, max_chars=0)


class _HighlightFunc(Protocol): # noqa: PYI046
def __call__(self, source: str, lexer: Literal["diff", "python"] = "python") -> str:
Expand Down
Loading
Loading