From 19c35cf94481b1588773aa29a76ec4e69fc3fd09 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Fri, 10 Jul 2026 09:54:22 +0200 Subject: [PATCH 1/2] Allow a tuple of types in Parser.addini(type=...) addini now accepts a tuple of the existing type tags, e.g. type=("int", "string"), meaning the option accepts a value of any of those types. getini tries each tag in order and returns the first that accepts the value: in TOML config the native value may be of any member type, and string-based formats (INI files, -o overrides) coerce it to the first member that accepts it. This reuses the existing per-tag coercion, and single-tag registration is unchanged (its original error is re-raised as-is). --- src/_pytest/config/__init__.py | 31 +++++++++++----- src/_pytest/config/argparsing.py | 61 ++++++++++++++++++++------------ src/_pytest/helpconfig.py | 3 +- testing/test_config.py | 52 +++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 31 deletions(-) diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 96b3dd6a86a..bc2dcce27e9 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1747,14 +1747,29 @@ def _getini(self, name: str): value = selected.value mode = selected.mode - if mode == "ini": - # In ini mode, values are always str | list[str]. - assert isinstance(value, (str, list)) - return self._getini_ini(name, canonical_name, type, value, default) - elif mode == "toml": - return self._getini_toml(name, canonical_name, type, value, default) - else: - assert_never(mode) + # A tuple type means "accept any of these types"; try each in order and + # return the first that accepts the value. + tags = type if isinstance(type, tuple) else (type,) + errors = [] + for tag in tags: + try: + if mode == "ini": + # In ini mode, values are always str | list[str]. + assert isinstance(value, (str, list)) + return self._getini_ini(name, canonical_name, tag, value, default) + elif mode == "toml": + return self._getini_toml(name, canonical_name, tag, value, default) + else: + assert_never(mode) + except (TypeError, ValueError) as exc: + errors.append(exc) + if len(tags) == 1: + raise errors[0] + value_type = builtins.type(value).__name__ + raise TypeError( + f"{self.inipath}: config option '{name}' expects one of " + f"{' | '.join(tags)}, got {value_type}: {value!r}" + ) def _getini_ini( self, diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index f70e27614ef..e2320f2d41e 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -20,6 +20,26 @@ FILE_OR_DIR = "file_or_dir" +#: The string tags accepted by :meth:`Parser.addini` for its ``type`` argument. +_IniTypeTag = Literal[ + "string", "paths", "pathlist", "args", "linelist", "bool", "int", "float" +] + +#: An ini option type: either a single tag, or a tuple of tags meaning "accept +#: a value of any of these types" (e.g. ``("int", "string")``). +IniType = _IniTypeTag | tuple[_IniTypeTag, ...] + +_INI_TYPE_TAGS: tuple[str, ...] = ( + "string", + "paths", + "pathlist", + "args", + "linelist", + "bool", + "int", + "float", +) + @final class Parser: @@ -54,7 +74,7 @@ def __init__( file_or_dir_arg = self.optparser.add_argument(FILE_OR_DIR, nargs="*") file_or_dir_arg.completer = filescompleter # type: ignore - self._inidict: dict[str, tuple[str, str, Any]] = {} + self._inidict: dict[str, tuple[str, IniType, Any]] = {} # Maps alias -> canonical name. self._ini_aliases: dict[str, str] = {} @@ -182,10 +202,7 @@ def addini( self, name: str, help: str, - type: Literal[ - "string", "paths", "pathlist", "args", "linelist", "bool", "int", "float" - ] - | None = None, + type: _IniTypeTag | tuple[_IniTypeTag, ...] | None = None, default: Any = NOTSET, *, aliases: Sequence[str] = (), @@ -210,6 +227,16 @@ def addini( The ``float`` and ``int`` types. + A tuple of the above tags may also be passed to accept a value of + any of those types, for example ``("int", "string")``. In TOML + configuration files the value may then be any of the member types; + string-based formats (INI files, ``-o`` overrides) coerce it to the + first member that accepts it. + + .. versionadded:: 9.1 + + Passing a tuple of types. + For ``paths`` and ``pathlist`` types, they are considered relative to the config-file. In case the execution is happening without a config-file defined, they will be considered relative to the current working directory (for example with ``--override-ini``). @@ -233,19 +260,10 @@ def addini( The value of configuration keys can be retrieved via a call to :py:func:`config.getini(name) `. """ - assert type in ( - None, - "string", - "paths", - "pathlist", - "args", - "linelist", - "bool", - "int", - "float", - ) if type is None: type = "string" + tags = type if isinstance(type, tuple) else (type,) + assert tags and all(tag in _INI_TYPE_TAGS for tag in tags), type if default is NOTSET: default = get_ini_default_for_type(type) @@ -261,16 +279,15 @@ def addini( self._ini_aliases[alias] = name -def get_ini_default_for_type( - type: Literal[ - "string", "paths", "pathlist", "args", "linelist", "bool", "int", "float" - ], -) -> Any: +def get_ini_default_for_type(type: IniType) -> Any: """ Used by addini to get the default value for a given config option type, when default is not supplied. """ - if type in ("paths", "pathlist", "args", "linelist"): + if isinstance(type, tuple): + # A union has no unambiguous implicit default; require an explicit one. + return None + elif type in ("paths", "pathlist", "args", "linelist"): return [] elif type == "bool": return False diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index fdba02b35f4..7f5c333ba38 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -200,7 +200,8 @@ def showhelp(config: Config) -> None: help, type, _default = config._parser._inidict[name] if help is None: raise TypeError(f"help argument cannot be None for {name}") - spec = f"{name} ({type}):" + type_repr = " | ".join(type) if isinstance(type, tuple) else type + spec = f"{name} ({type_repr}):" tw.write(f" {spec}") spec_len = len(spec) if spec_len > (indent_len - 3): diff --git a/testing/test_config.py b/testing/test_config.py index 9583c9131fa..78a9b9f52cf 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1089,6 +1089,58 @@ def pytest_addoption(parser): ): _ = config.getini("ini_param") + @pytest.mark.parametrize( + "section, value, expected", + [ + # Native TOML: int and str are both accepted; the first tuple + # member that matches wins (int before string). + ("[tool.pytest]", '"7"', "7"), + ("[tool.pytest]", "7", 7), + # ini_options mode stringifies, then coerces to the first member. + ("[tool.pytest.ini_options]", '"7"', 7), + ("[tool.pytest.ini_options]", "7", 7), + ], + ids=["native-str", "native-int", "ini-options-str", "ini-options-int"], + ) + def test_addini_tuple_type( + self, pytester: Pytester, section: str, value: str, expected: object + ) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("ini_param", "", type=("int", "string"), default=None) + """ + ) + pytester.makepyprojecttoml( + f""" + {section} + ini_param = {value} + """ + ) + config = pytester.parseconfig() + result = config.getini("ini_param") + assert result == expected + assert type(result) is type(expected) + + def test_addini_tuple_type_invalid(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("ini_param", "", type=("int", "string"), default=None) + """ + ) + pytester.makepyprojecttoml( + """ + [tool.pytest] + ini_param = [1, 2] + """ + ) + config = pytester.parseconfig() + with pytest.raises( + TypeError, match=r"config option 'ini_param' expects one of int \| string" + ): + _ = config.getini("ini_param") + def test_addinivalue_line_existing(self, pytester: Pytester) -> None: pytester.makeconftest( """ From 007af70cb12fb9b01b5f405e6830083717e11cfb Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Fri, 10 Jul 2026 09:54:32 +0200 Subject: [PATCH 2/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', 'string'), 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 --- changelog/14675.bugfix.rst | 1 + src/_pytest/assertion/__init__.py | 2 + src/_pytest/assertion/_typing.py | 31 +++++++++++++++- src/_pytest/assertion/truncate.py | 31 +++++----------- testing/test_assertion.py | 62 +++++++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 24 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 e33f8b29609..199f480b038 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -49,11 +49,13 @@ def pytest_addoption(parser: Parser) -> None: parser.addini( "truncation_limit_lines", + type=("int", "string"), default=None, help="Set threshold of LINES after which truncation will take effect", ) parser.addini( "truncation_limit_chars", + type=("int", "string"), 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 d419e09aea6..30c5e63fd6a 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", "string")`` 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), + ) class _HighlightFunc(Protocol): # noqa: PYI046 diff --git a/src/_pytest/assertion/truncate.py b/src/_pytest/assertion/truncate.py index 30cff154770..b13482e60bb 100644 --- a/src/_pytest/assertion/truncate.py +++ b/src/_pytest/assertion/truncate.py @@ -12,38 +12,25 @@ from _pytest.nodes import Item -DEFAULT_MAX_LINES = 8 -DEFAULT_MAX_CHARS = DEFAULT_MAX_LINES * 80 USAGE_MSG = "use '-vv' to show" def truncate_if_required(explanation: list[str], item: Item) -> list[str]: """Truncate this assertion explanation if the given test item is eligible.""" - should_truncate, budget = _get_truncation_parameters(item) - if should_truncate: - return _truncate_explanation(explanation, budget) - return explanation - - -def _get_truncation_parameters(item: Item) -> tuple[bool, TruncationBudget]: - """Return the truncation parameters related to the given item, 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 = item.config.getini("truncation_limit_lines") - max_lines = int(max_lines if max_lines is not None else DEFAULT_MAX_LINES) - - max_chars = item.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 = item.config.get_verbosity(Config.VERBOSITY_ASSERTIONS) + if verbose >= 2 or running_on_ci(): + return explanation - should_truncate = verbose < 2 and not running_on_ci() - should_truncate = should_truncate and (max_lines > 0 or max_chars > 0) + budget = TruncationBudget.from_config(item.config) + if budget.max_lines <= 0 and budget.max_chars <= 0: + return explanation - return should_truncate, TruncationBudget(max_lines=max_lines, max_chars=max_chars) + return _truncate_explanation(explanation, budget) def _truncate_explanation( @@ -60,7 +47,7 @@ def _truncate_explanation( If max_lines=0, no truncation by line count is performed. When this function is launched we know max_lines > 0 or max_chars > 0 - because _get_truncation_parameters was called first. + because truncate_if_required checked it before calling. """ # The length of the truncation explanation depends on the number of lines # removed but is at least 68 characters: diff --git a/testing/test_assertion.py b/testing/test_assertion.py index c64d8da0a09..f8deb018128 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -1742,6 +1742,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(["*truncated (3 lines hidden)*"]) + def test_python25_compile_issue257(pytester: Pytester) -> None: pytester.makepyfile(