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/changelog/14675.improvement.rst b/changelog/14675.improvement.rst new file mode 100644 index 00000000000..4fe6274a708 --- /dev/null +++ b/changelog/14675.improvement.rst @@ -0,0 +1 @@ +:func:`parser.addini ` now also accepts plain Python types (``str``, ``bool``, ``int``, ``float``) and unions of them (for example ``int | str``) for its ``type`` argument, in addition to the existing string tags. A union accepts a value of any of its member types. diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index e33f8b29609..f11ae2934b9 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 | 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 d419e09aea6..fcd622d0071 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), + ) 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/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..9139027b8b2 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -7,10 +7,15 @@ import os import sys import textwrap +import types from typing import Any +from typing import cast from typing import final +from typing import get_args +from typing import get_origin from typing import Literal from typing import NoReturn +from typing import Union from .exceptions import UsageError import _pytest._io @@ -20,6 +25,57 @@ FILE_OR_DIR = "file_or_dir" +#: The string tags accepted by :meth:`Parser.addini` for its ``type`` argument. +#: Keep in sync with _INI_TYPE_TAGS. +_IniTypeTag = Literal[ + "string", "paths", "pathlist", "args", "linelist", "bool", "int", "float" +] + +#: An ini option type, as stored internally after normalization: either a +#: single tag, or a tuple of tags meaning "accept a value of any of these +#: types" (e.g. ``("int", "string")``, normalized from ``int | str``). +IniType = _IniTypeTag | tuple[_IniTypeTag, ...] + +#: Runtime list tags accepted by :meth:`Parser.addini` for its ``type`` argument. +#: Keep in sync with _IniTypeTag. +_INI_TYPE_TAGS: tuple[str, ...] = ( + "string", + "paths", + "pathlist", + "args", + "linelist", + "bool", + "int", + "float", +) + +#: Maps the plain Python types accepted by :meth:`Parser.addini` for its +#: ``type`` argument to the equivalent string tag. +_INI_TYPE_TO_TAG: dict[type, _IniTypeTag] = { + str: "string", + bool: "bool", + int: "int", + float: "float", +} + + +def _ini_type_to_tag(name: str, type_: object) -> _IniTypeTag: + """Normalize one member of an `addini(type=...)` argument to a string tag. + + Raise ValueError for anything that is neither a known tag nor a supported + plain Python type. + """ + if isinstance(type_, str): + if type_ in _INI_TYPE_TAGS: + return cast("_IniTypeTag", type_) + elif isinstance(type_, type) and type_ in _INI_TYPE_TO_TAG: + return _INI_TYPE_TO_TAG[type_] + raise ValueError( + f"invalid type for ini option {name!r}: {type_!r} (expected one of " + f"{', '.join(repr(tag) for tag in _INI_TYPE_TAGS)}, one of the types " + "str, bool, int, float, or a union of these types such as `int | str`)" + ) + @final class Parser: @@ -54,7 +110,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,9 +238,9 @@ def addini( self, name: str, help: str, - type: Literal[ - "string", "paths", "pathlist", "args", "linelist", "bool", "int", "float" - ] + type: _IniTypeTag + | type[bool | int | float | str] + | types.UnionType | None = None, default: Any = NOTSET, *, @@ -210,6 +266,18 @@ def addini( The ``float`` and ``int`` types. + For the scalar types, the plain Python type may be passed instead + of the string tag: ``str``, ``bool``, ``int`` and ``float`` (for + example ``type=int``). A union of these types accepts a value of + any of its members, for example ``int | str``. 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 type expression such as ``int`` or ``int | str``. + 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,23 +301,19 @@ 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", - ) + ini_type: IniType if type is None: - type = "string" + ini_type = "string" + elif get_origin(type) in (Union, types.UnionType): + ini_type = tuple( + _ini_type_to_tag(name, member) for member in get_args(type) + ) + else: + ini_type = _ini_type_to_tag(name, type) if default is NOTSET: - default = get_ini_default_for_type(type) + default = get_ini_default_for_type(name, ini_type) - self._inidict[name] = (help, type, default) + self._inidict[name] = (help, ini_type, default) for alias in aliases: if alias in self._inidict: @@ -261,16 +325,18 @@ 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(name: str, 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. + raise ValueError( + f"ini option {name!r} has a union type, which has no implicit " + "default; pass an explicit `default` to `addini`" + ) + 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_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( diff --git a/testing/test_config.py b/testing/test_config.py index 9583c9131fa..0f95fbe3e24 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1089,6 +1089,91 @@ def pytest_addoption(parser): ): _ = config.getini("ini_param") + @pytest.mark.parametrize( + "section, value, expected", + [ + # Native TOML: int and str are both accepted; the first union + # member that matches wins (int before str). + ("[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_union_type( + self, pytester: Pytester, section: str, value: str, expected: object + ) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("ini_param", "", type=int | str, 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_union_type_invalid_value(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("ini_param", "", type=int | str, 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_addini_plain_type(self, pytester: Pytester) -> None: + """A plain Python type is accepted as an alias of its string tag.""" + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("ini_param", "", type=int) + """ + ) + pytester.makepyprojecttoml( + """ + [tool.pytest] + ini_param = 7 + """ + ) + config = pytester.parseconfig() + assert config.getini("ini_param") == 7 + + def test_addini_invalid_type(self, pytester: Pytester) -> None: + parser = Parser(_ispytest=True) + with pytest.raises(ValueError, match="invalid type for ini option 'ini_param'"): + parser.addini("ini_param", "", type="integer") # type: ignore[arg-type] + with pytest.raises(ValueError, match="invalid type for ini option 'ini_param'"): + parser.addini("ini_param", "", type=dict) # type: ignore[arg-type] + with pytest.raises(ValueError, match="invalid type for ini option 'ini_param'"): + parser.addini("ini_param", "", type=int | dict) + + def test_addini_union_type_requires_default(self) -> None: + parser = Parser(_ispytest=True) + with pytest.raises( + ValueError, match="union type, which has no implicit default" + ): + parser.addini("ini_param", "", type=int | str) + def test_addinivalue_line_existing(self, pytester: Pytester) -> None: pytester.makeconftest( """ @@ -1348,7 +1433,13 @@ def pytest_addoption(parser): ], ) def test_get_ini_default_for_type(self, type: Any, expected: Any) -> None: - assert get_ini_default_for_type(type) == expected + assert get_ini_default_for_type("ini_param", type) == expected + + def test_get_ini_default_for_type_union(self) -> None: + with pytest.raises( + ValueError, match="union type, which has no implicit default" + ): + get_ini_default_for_type("ini_param", ("int", "string")) def test_confcutdir_check_isdir(self, pytester: Pytester) -> None: """Give an error if --confcutdir is not a valid directory (#2078)"""