From 3304ed275ef7bc51b30a4a40f7c4444296c4cf91 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 21 Jul 2026 20:52:59 +0200 Subject: [PATCH 1/2] Accept int and string truncation limits in TOML config The truncation_limit_lines and truncation_limit_chars options were registered without a type (defaulting to 'string'), so integer values in native TOML config raised a TypeError (#14675). They are now registered with type=int | str, accepting both int and string values in TOML while keeping the string form working for backward compatibility. TruncationBudget.from_config reads the two options and owns default handling: None (option unset) falls back to the DEFAULT_MAX_* limits, which live next to the class; other values are coerced with int(). truncate_if_required short-circuits on verbosity/CI before reading the options at all. 0 still means 'unbounded'; None means 'use default'. Fixes #14675 Co-Authored-By: Claude Fable 5 --- changelog/14675.bugfix.rst | 1 + src/_pytest/assertion/__init__.py | 2 + src/_pytest/assertion/_typing.py | 31 +++++++++++++++- src/_pytest/assertion/truncate.py | 22 ++++------- testing/test_assertion.py | 62 +++++++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 16 deletions(-) create mode 100644 changelog/14675.bugfix.rst diff --git a/changelog/14675.bugfix.rst b/changelog/14675.bugfix.rst new file mode 100644 index 00000000000..234e7be6183 --- /dev/null +++ b/changelog/14675.bugfix.rst @@ -0,0 +1 @@ +The :confval:`truncation_limit_lines` and :confval:`truncation_limit_chars` configuration options now accept integer values in TOML configuration files, while still accepting strings for backward compatibility. diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index ad2454ab820..15eb5fd34ec 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -51,11 +51,13 @@ def pytest_addoption(parser: Parser) -> None: parser.addini( "truncation_limit_lines", + type=int | str, default=None, help="Set threshold of LINES after which truncation will take effect", ) parser.addini( "truncation_limit_chars", + type=int | str, default=None, help=("Set threshold of CHARS after which truncation will take effect"), ) diff --git a/src/_pytest/assertion/_typing.py b/src/_pytest/assertion/_typing.py index d9f0ad8e78c..02aab21352f 100644 --- a/src/_pytest/assertion/_typing.py +++ b/src/_pytest/assertion/_typing.py @@ -1,8 +1,14 @@ from __future__ import annotations from dataclasses import dataclass +from typing import ClassVar from typing import Literal from typing import Protocol +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from _pytest.config import Config _AssertionTextDiffStyle = Literal["ndiff", "block"] @@ -17,8 +23,29 @@ class TruncationBudget: dimension; ``0`` leaves it unbounded (the limit is disabled). """ - max_lines: int - max_chars: int + #: Default limits applied when the corresponding ini option is left unset. + DEFAULT_MAX_LINES: ClassVar[int] = 8 + DEFAULT_MAX_CHARS: ClassVar[int] = DEFAULT_MAX_LINES * 80 + + max_lines: int = DEFAULT_MAX_LINES + max_chars: int = DEFAULT_MAX_CHARS + + @classmethod + def from_config(cls, config: Config) -> TruncationBudget: + """Build a budget from the ``truncation_limit_*`` ini options. + + Both options are registered with ``type=int | str`` for + backward compatibility, so :meth:`~_pytest.config.Config.getini` may + return an ``int`` (native TOML value) or a ``str`` (INI files, ``-o`` + overrides); it returns ``None`` when the option is unset, which falls + back to the default limit. + """ + max_lines = config.getini("truncation_limit_lines") + max_chars = config.getini("truncation_limit_chars") + return cls( + max_lines=cls.DEFAULT_MAX_LINES if max_lines is None else int(max_lines), + max_chars=cls.DEFAULT_MAX_CHARS if max_chars is None else int(max_chars), + ) # Reusable "no cap" budget, used as a default argument (B008). diff --git a/src/_pytest/assertion/truncate.py b/src/_pytest/assertion/truncate.py index 4627b7c6fe1..dbac2d8162d 100644 --- a/src/_pytest/assertion/truncate.py +++ b/src/_pytest/assertion/truncate.py @@ -8,13 +8,12 @@ from collections.abc import Iterable +from _pytest.assertion._typing import NO_TRUNCATION_BUDGET from _pytest.assertion._typing import TruncationBudget from _pytest.compat import running_on_ci from _pytest.config import Config -DEFAULT_MAX_LINES = 8 -DEFAULT_MAX_CHARS = DEFAULT_MAX_LINES * 80 USAGE_MSG = "use '-vv' to show" TRUNCATION_MSG = f"...Full output truncated, {USAGE_MSG}" @@ -61,23 +60,18 @@ def materialize_with_truncation(lines: Iterable[str], config: Config) -> list[st def _get_truncation_parameters(config: Config) -> tuple[bool, TruncationBudget]: """Return the truncation parameters from the given config, as (should truncate, budget).""" - # We do not need to truncate if one of conditions is met: + # We do not need to truncate if one of these conditions is met: # 1. Verbosity level is 2 or more; # 2. Test is being run in CI environment; # 3. Both truncation_limit_lines and truncation_limit_chars - # .ini parameters are set to 0 explicitly. - max_lines = config.getini("truncation_limit_lines") - max_lines = int(max_lines if max_lines is not None else DEFAULT_MAX_LINES) - - max_chars = config.getini("truncation_limit_chars") - max_chars = int(max_chars if max_chars is not None else DEFAULT_MAX_CHARS) - + # are set to 0 explicitly. verbose = config.get_verbosity(Config.VERBOSITY_ASSERTIONS) + if verbose >= 2 or running_on_ci(): + return False, NO_TRUNCATION_BUDGET - should_truncate = verbose < 2 and not running_on_ci() - should_truncate = should_truncate and (max_lines > 0 or max_chars > 0) - - return should_truncate, TruncationBudget(max_lines=max_lines, max_chars=max_chars) + budget = TruncationBudget.from_config(config) + should_truncate = budget.max_lines > 0 or budget.max_chars > 0 + return should_truncate, budget def _truncate_explanation( diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 609c4b1a62f..97093118f2f 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -1840,6 +1840,68 @@ def test(): ] ) + @pytest.mark.parametrize( + "config", + [ + # Native [tool.pytest] uses TOML types, so an int is accepted (#14675). + pytest.param( + """ + [tool.pytest] + truncation_limit_lines = 3 + truncation_limit_chars = 0 + """, + id="native-toml-int", + ), + # A string in native [tool.pytest] must keep working (backward compat). + pytest.param( + """ + [tool.pytest] + truncation_limit_lines = "3" + truncation_limit_chars = "0" + """, + id="native-toml-string", + ), + # [tool.pytest.ini_options] keeps the string-based INI behaviour. + pytest.param( + """ + [tool.pytest.ini_options] + truncation_limit_lines = "3" + truncation_limit_chars = "0" + """, + id="ini-options-string", + ), + # ... and also accepts a bare int, coerced like the INI format does. + pytest.param( + """ + [tool.pytest.ini_options] + truncation_limit_lines = 3 + truncation_limit_chars = 0 + """, + id="ini-options-int", + ), + ], + ) + def test_truncation_limits_accept_int_and_string( + self, monkeypatch, pytester: Pytester, config: str + ) -> None: + """Truncation limits accept both int and string values in TOML (#14675).""" + pytester.makepyfile( + """\ + string_a = "123456789\\n23456789\\n3" + string_b = "123456789\\n23456789\\n4" + + def test(): + assert string_a == string_b + """ + ) + monkeypatch.delenv("CI", raising=False) + pytester.makepyprojecttoml(config) + + result = pytester.runpytest() + + result.stdout.no_fnmatch_line("*TypeError*") + result.stdout.fnmatch_lines(["*Full output truncated, use '-vv' to show*"]) + class TestMaterializeWithTruncation: """Tests for ``truncate.materialize_with_truncation``.""" From 6e02b69faa21cfe6ba584c7106175731f09fd357 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Mon, 27 Jul 2026 22:50:21 +0200 Subject: [PATCH 2/2] Document the int | str type for the truncation limit options The API reference claimed type int while the options were registered as string; the registration is now int | str, so state that in the confval entries and refresh the stale --help transcript accordingly. Co-Authored-By: Claude Fable 5 --- doc/en/reference/reference.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index 4f2e0b70850..912e937ceef 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -2664,7 +2664,7 @@ passed multiple times. The expected format is ``name=value``. For example:: .. confval:: truncation_limit_chars - :type: ``int`` + :type: ``int | str`` :default: ``640`` Controls maximum number of characters to truncate assertion message contents. @@ -2693,7 +2693,7 @@ passed multiple times. The expected format is ``name=value``. For example:: .. confval:: truncation_limit_lines - :type: ``int`` + :type: ``int | str`` :default: ``8`` Controls maximum number of lines to truncate assertion message contents. @@ -3720,10 +3720,10 @@ All the command-line flags can also be obtained by running ``pytest --help``:: enable_assertion_pass_hook (bool): Enables the pytest_assertion_pass hook. Make sure to delete any previously generated pyc cache files. - truncation_limit_lines (string): + truncation_limit_lines (int | string): Set threshold of LINES after which truncation will take effect - truncation_limit_chars (string): + truncation_limit_chars (int | string): Set threshold of CHARS after which truncation will take effect assertion_text_diff_style (string):