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

from dataclasses import dataclass
from typing import Literal
from typing import Protocol


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


@dataclass(frozen=True, kw_only=True, slots=True)
class TruncationBudget:
"""Per-explanation budget for truncating assertion output.

``max_lines`` / ``max_chars`` mirror the ``truncation_limit_lines`` /
``truncation_limit_chars`` ini values: a positive limit bounds that
dimension; ``0`` leaves it unbounded (the limit is disabled).

Constructed keyword-only so the two limits can never be silently
swapped at a call site.
"""

max_lines: int
max_chars: int


class _HighlightFunc(Protocol): # noqa: PYI046
def __call__(self, source: str, lexer: Literal["diff", "python"] = "python") -> str:
"""Apply highlighting to the given source."""
43 changes: 23 additions & 20 deletions src/_pytest/assertion/truncate.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from __future__ import annotations

from _pytest.assertion._typing import TruncationBudget
from _pytest.compat import running_on_ci
from _pytest.config import Config
from _pytest.nodes import Item
Expand All @@ -18,18 +19,18 @@

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)
should_truncate, budget = _get_truncation_parameters(item)
if should_truncate:
return _truncate_explanation(
explanation,
max_lines=max_lines,
max_chars=max_chars,
)
return _truncate_explanation(explanation, budget)
return explanation


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(item: Item) -> tuple[bool, TruncationBudget]:
"""Return the truncation parameters related to the given item, as (should truncate, budget).

The budget carries the ``truncation_limit_lines`` / ``truncation_limit_chars``
ini values verbatim, where ``0`` means the matching dimension is unbounded.
"""
# 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;
Expand All @@ -46,19 +47,18 @@ def _get_truncation_parameters(item: Item) -> tuple[bool, int, int]:
should_truncate = verbose < 2 and not running_on_ci()
should_truncate = should_truncate and (max_lines > 0 or max_chars > 0)

return should_truncate, max_lines, max_chars
return should_truncate, TruncationBudget(max_lines=max_lines, max_chars=max_chars)


def _truncate_explanation(
input_lines: list[str],
max_lines: int,
max_chars: int,
budget: TruncationBudget,
) -> list[str]:
"""Truncate given list of strings that makes up the assertion explanation.

Truncates to either max_lines, or max_chars - whichever the input reaches
first, taking the truncation explanation into account. The remaining lines
will be replaced by a usage message.
Truncates to either ``budget.max_lines`` or ``budget.max_chars`` -
whichever the input reaches first, taking the truncation explanation into
account. The remaining lines will be replaced by a usage message.

If max_chars=0, no truncation by character count is performed.
If max_lines=0, no truncation by line count is performed.
Expand All @@ -78,24 +78,27 @@ def _truncate_explanation(
# But if there's more than 100 lines it's very likely that we're going to
# truncate, so we don't need the exact value using log10.
tolerable_max_chars = (
max_chars + 70 # 64 + 1 (for plural) + 2 (for '99') + 3 for '...'
budget.max_chars + 70 # 64 + 1 (for plural) + 2 (for '99') + 3 for '...'
)
# The truncation explanation add two lines to the output
if max_lines == 0 or len(input_lines) <= max_lines + 2:
if max_chars == 0 or sum(len(s) for s in input_lines) <= tolerable_max_chars:
if budget.max_lines == 0 or len(input_lines) <= budget.max_lines + 2:
if (
budget.max_chars == 0
or sum(len(s) for s in input_lines) <= tolerable_max_chars
):
return input_lines
truncated_explanation = input_lines
else:
# Truncate first to max_lines, and then truncate to max_chars if necessary
truncated_explanation = input_lines[:max_lines]
truncated_explanation = input_lines[: budget.max_lines]
# We reevaluate the need to truncate chars following removal of some lines
need_to_truncate_char = (
max_chars > 0
budget.max_chars > 0
and sum(len(e) for e in truncated_explanation) > tolerable_max_chars
)
if need_to_truncate_char:
truncated_explanation = _truncate_by_char_count(
truncated_explanation, max_chars
truncated_explanation, budget.max_chars
)
# Something was truncated, adding '...' at the end to show that
truncated_explanation[-1] += "..."
Expand Down
45 changes: 34 additions & 11 deletions testing/test_assertion.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from _pytest.assertion import truncate
from _pytest.assertion import util
from _pytest.assertion._compare_any import _compare_eq_cls
from _pytest.assertion._typing import TruncationBudget
from _pytest.assertion.compare_text import _compare_eq_text
from _pytest.config import Config as _Config
from _pytest.monkeypatch import MonkeyPatch
Expand Down Expand Up @@ -1512,17 +1513,23 @@ class TestTruncateExplanation:

def test_doesnt_truncate_when_input_is_empty_list(self) -> None:
expl: list[str] = []
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=100)
result = truncate._truncate_explanation(
expl, TruncationBudget(max_lines=8, max_chars=100)
)
assert result == expl

def test_doesnt_truncate_at_when_input_is_5_lines_and_LT_max_chars(self) -> None:
expl = ["a" * 100 for x in range(5)]
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=8 * 80)
result = truncate._truncate_explanation(
expl, TruncationBudget(max_lines=8, max_chars=8 * 80)
)
assert result == expl

def test_truncates_at_8_lines_when_given_list_of_empty_strings(self) -> None:
expl = ["" for x in range(50)]
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=100)
result = truncate._truncate_explanation(
expl, TruncationBudget(max_lines=8, max_chars=100)
)
assert len(result) != len(expl)
assert result != expl
assert len(result) == 8 + self.LINES_IN_TRUNCATION_MSG
Expand All @@ -1534,7 +1541,9 @@ def test_truncates_at_8_lines_when_given_list_of_empty_strings(self) -> None:
def test_truncates_at_8_lines_when_first_8_lines_are_LT_max_chars(self) -> None:
total_lines = 100
expl = ["a" for x in range(total_lines)]
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=8 * 80)
result = truncate._truncate_explanation(
expl, TruncationBudget(max_lines=8, max_chars=8 * 80)
)
assert result != expl
assert len(result) == 8 + self.LINES_IN_TRUNCATION_MSG
assert "Full output truncated" in result[-1]
Expand All @@ -1545,14 +1554,18 @@ def test_truncates_at_8_lines_when_first_8_lines_are_LT_max_chars(self) -> None:
def test_truncates_at_8_lines_when_there_is_one_line_to_remove(self) -> None:
"""The number of line in the result is 9, the same number as if we truncated."""
expl = ["a" for x in range(9)]
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=8 * 80)
result = truncate._truncate_explanation(
expl, TruncationBudget(max_lines=8, max_chars=8 * 80)
)
assert result == expl
assert "truncated" not in result[-1]

def test_truncates_full_line_because_of_max_chars(self) -> None:
"""A line is fully truncated because of the max_chars value."""
expl = ["a" * 10, "b" * 71]
result = truncate._truncate_explanation(expl, max_lines=10, max_chars=10)
result = truncate._truncate_explanation(
expl, TruncationBudget(max_lines=10, max_chars=10)
)
assert result == [
"a" * 10,
"...",
Expand All @@ -1565,20 +1578,26 @@ def test_truncates_edgecase_when_truncation_message_makes_the_result_longer_for_
) -> None:
line = "a" * 10
expl = [line, line]
result = truncate._truncate_explanation(expl, max_lines=10, max_chars=10)
result = truncate._truncate_explanation(
expl, TruncationBudget(max_lines=10, max_chars=10)
)
assert result == [line, line]

def test_truncates_edgecase_when_truncation_message_makes_the_result_longer_for_lines(
self,
) -> None:
line = "a" * 10
expl = [line, line]
result = truncate._truncate_explanation(expl, max_lines=1, max_chars=100)
result = truncate._truncate_explanation(
expl, TruncationBudget(max_lines=1, max_chars=100)
)
assert result == [line, line]

def test_truncates_at_8_lines_when_first_8_lines_are_EQ_max_chars(self) -> None:
expl = [chr(97 + x) * 80 for x in range(16)]
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=8 * 80)
result = truncate._truncate_explanation(
expl, TruncationBudget(max_lines=8, max_chars=8 * 80)
)
assert result != expl
assert len(result) == 16 - 8 + self.LINES_IN_TRUNCATION_MSG
assert "Full output truncated" in result[-1]
Expand All @@ -1588,7 +1607,9 @@ def test_truncates_at_8_lines_when_first_8_lines_are_EQ_max_chars(self) -> None:

def test_truncates_at_4_lines_when_first_4_lines_are_GT_max_chars(self) -> None:
expl = ["a" * 250 for x in range(10)]
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=999)
result = truncate._truncate_explanation(
expl, TruncationBudget(max_lines=8, max_chars=999)
)
assert result != expl
assert len(result) == 4 + self.LINES_IN_TRUNCATION_MSG
assert "Full output truncated" in result[-1]
Expand All @@ -1598,7 +1619,9 @@ def test_truncates_at_4_lines_when_first_4_lines_are_GT_max_chars(self) -> None:

def test_truncates_at_1_line_when_first_line_is_GT_max_chars(self) -> None:
expl = ["a" * 250 for x in range(1000)]
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=100)
result = truncate._truncate_explanation(
expl, TruncationBudget(max_lines=8, max_chars=100)
)
assert result != expl
assert len(result) == 1 + self.LINES_IN_TRUNCATION_MSG
assert "Full output truncated" in result[-1]
Expand Down
Loading