From 3da7181452b8c13ebe4f68807025e45a5ddbc845 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Thu, 25 Jun 2026 18:18:28 +0200 Subject: [PATCH] [refactor] Introduce a TruncationBudget for assertion truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle the (max_lines, max_chars) pair that the truncation machinery passes around into a small frozen, keyword-only TruncationBudget. _get_truncation_parameters now returns (should_truncate, budget) and _truncate_explanation takes the budget directly. Keyword-only construction means the two limits can never be silently swapped at a call site. The budget carries the truncation_limit_lines / truncation_limit_chars ini values verbatim — positive bounds the dimension, 0 leaves it unbounded — so there is no extra sentinel translation. Pure refactor: truncation behaviour, footer wording and hidden-line count are unchanged. --- src/_pytest/assertion/_typing.py | 17 ++++++++++++ src/_pytest/assertion/truncate.py | 43 +++++++++++++++-------------- testing/test_assertion.py | 45 +++++++++++++++++++++++-------- 3 files changed, 74 insertions(+), 31 deletions(-) diff --git a/src/_pytest/assertion/_typing.py b/src/_pytest/assertion/_typing.py index f032f34e0bc..946ee4e1e12 100644 --- a/src/_pytest/assertion/_typing.py +++ b/src/_pytest/assertion/_typing.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Literal from typing import Protocol @@ -7,6 +8,22 @@ _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.""" diff --git a/src/_pytest/assertion/truncate.py b/src/_pytest/assertion/truncate.py index d62ca33cc4b..9550441a87b 100644 --- a/src/_pytest/assertion/truncate.py +++ b/src/_pytest/assertion/truncate.py @@ -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 @@ -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; @@ -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. @@ -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] += "..." diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 2802833beba..45bcf63925e 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -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 @@ -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 @@ -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] @@ -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, "...", @@ -1565,7 +1578,9 @@ 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( @@ -1573,12 +1588,16 @@ 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=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] @@ -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] @@ -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]