Skip to content
Merged
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an unfortunate loss, though I understand why it's needed. I wonder whether there is a way to preserve it in some form.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could keep it for the already materialized list that we get from plugins (I invested time in this algo, i'm also sad to see it go :D )

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
49 changes: 34 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 @@ -182,12 +184,14 @@ def callbinrepr(op, left: object, right: object) -> str | None:
)
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
new_expl = truncate.materialize_with_truncation(new_expl, item.config)
# A truthy-but-empty iterable materialises to [], so re-check.
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 @@ -223,14 +227,29 @@ def pytest_assertrepr_compare(
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, cap the
# comparison helpers' formatting at what the truncator will actually pull
# (the raw limits plus the footer slack) so no effort is spent formatting
# lines/chars that would be dropped anyway.
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
32 changes: 28 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,31 @@ 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."""
max_lines = truncation_budget.max_lines
if max_lines == 0 or len(keys) <= max_lines:
# If no need to truncate, let pprint handle it.
yield from highlighter(
pprint.pformat({k: right[k] for k in extra_right})
pprint.pformat({k: mapping[k] for k in keys})
).splitlines()
else:
# To avoid spending effort on formatting entries that would be truncated,
# only format the needed entries, keeping the sorting that pprint would use.
for k in heapq.nsmallest(max_lines, keys, key=_safe_key):
yield highlighter(saferepr({k: mapping[k]}))
22 changes: 14 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,30 @@ 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()
pp = PrettyPrinter()
# ``pformat_lines`` spells an unbounded dimension ``None``; the budget spells it ``0``.
max_lines = truncation_budget.max_lines or None
max_chars = truncation_budget.max_chars or None
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: (-: missing in left side, +: extra in left side)"
# "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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If pygments/pygments#1043 is ever resolved, this won't be correct anymore. But I guess it's fine for now.

for line in difflib.ndiff(right_formatting, left_formatting):
yield highlighter(line.rstrip(), lexer="diff")


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


# 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:
"""Apply highlighting to the given source."""
46 changes: 38 additions & 8 deletions src/_pytest/assertion/compare_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from _pytest._io.saferepr import saferepr
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.highlight import dummy_highlighter
from _pytest.compat import assert_never

Expand All @@ -15,12 +17,13 @@ def _compare_eq_text(
highlighter: _HighlightFunc,
verbose: int,
assertion_text_diff_style: _AssertionTextDiffStyle,
truncation_budget: TruncationBudget = NO_TRUNCATION_BUDGET,
) -> Iterator[str]:
match assertion_text_diff_style:
case "block":
yield from _diff_text_block(left, right)
case "ndiff":
yield from _diff_text(left, right, highlighter, verbose)
yield from _diff_text(left, right, highlighter, verbose, truncation_budget)
case unreachable:
assert_never(unreachable)

Expand All @@ -39,12 +42,20 @@ def _format_text_block_lines(text: str) -> Iterator[str]:


def _diff_text(
left: str, right: str, highlighter: _HighlightFunc, verbose: int = 0
left: str,
right: str,
highlighter: _HighlightFunc,
verbose: int = 0,
truncation_budget: TruncationBudget = NO_TRUNCATION_BUDGET,
) -> Iterator[str]:
"""Yield the explanation for the diff between text.

Unless --verbose is used this will skip leading and trailing
characters which are identical to keep the diff minimal.

When a truncation budget is set, the inputs to ``ndiff`` are capped
first, so the truncated head may differ from the head of the
unbounded diff.
"""
from difflib import ndiff

Expand Down Expand Up @@ -75,23 +86,42 @@ def _diff_text(
left = repr(str(left))
right = repr(str(right))
yield "Strings contain only whitespace, escaping them using repr()"
left_lines = _cap_ndiff_input(left, keepends, truncation_budget)
right_lines = _cap_ndiff_input(right, keepends, truncation_budget)
# "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.strip("\n")
for line in ndiff(right.splitlines(keepends), left.splitlines(keepends))
),
"\n".join(line.strip("\n") for line in ndiff(right_lines, left_lines)),
lexer="diff",
).splitlines()


def _notin_text(term: str, text: str, verbose: int = 0) -> Iterator[str]:
def _cap_ndiff_input(text: str, keepends: bool, budget: TruncationBudget) -> list[str]:
"""Cap an ``ndiff`` input to the truncation budget, as split lines.

A char slice first (bounds a few huge lines, whose intraline diff is
O(len^2)), then a line slice (bounds many lines). A ``0`` limit leaves
that dimension unbounded.
"""
if budget.max_chars > 0:
text = text[: budget.max_chars]
lines = text.splitlines(keepends)
if budget.max_lines > 0:
lines = lines[: budget.max_lines]
return lines


def _notin_text(
term: str,
text: str,
verbose: int = 0,
truncation_budget: TruncationBudget = NO_TRUNCATION_BUDGET,
) -> Iterator[str]:
index = text.find(term)
head = text[:index]
tail = text[index + len(term) :]
correct_text = head + tail
diff = _diff_text(text, correct_text, dummy_highlighter, verbose)
diff = _diff_text(text, correct_text, dummy_highlighter, verbose, truncation_budget)
yield f"{saferepr(term, maxsize=42)} is contained here:"
for line in diff:
if line.startswith("Skipping"):
Expand Down
Loading