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
1 change: 1 addition & 0 deletions changelog/14675.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions src/_pytest/assertion/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)
Expand Down
31 changes: 29 additions & 2 deletions src/_pytest/assertion/_typing.py
Original file line number Diff line number Diff line change
@@ -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"]
Expand All @@ -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
Expand Down
31 changes: 9 additions & 22 deletions src/_pytest/assertion/truncate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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:
Expand Down
31 changes: 23 additions & 8 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
61 changes: 39 additions & 22 deletions src/_pytest/config/argparsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,26 @@

FILE_OR_DIR = "file_or_dir"

#: The string tags accepted by :meth:`Parser.addini` for its ``type`` argument.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#: The string tags accepted by :meth:`Parser.addini` for its ``type`` argument.
#: 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: 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, ...] = (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
_INI_TYPE_TAGS: tuple[str, ...] = (
#: 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",
)


@final
class Parser:
Expand Down Expand Up @@ -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] = {}

Expand Down Expand Up @@ -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] = (),
Expand All @@ -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``).
Expand All @@ -233,19 +260,10 @@ def addini(
The value of configuration keys can be retrieved via a call to
:py:func:`config.getini(name) <pytest.Config.getini>`.
"""
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably should use an exception here, given this might be an user-provided value.

asserts should be used for internal checks/consistency only.

if default is NOTSET:
default = get_ini_default_for_type(type)

Expand All @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC, None will be used as default, contrary to the comment. Perhaps raise an exception instead?

elif type in ("paths", "pathlist", "args", "linelist"):
return []
elif type == "bool":
return False
Expand Down
3 changes: 2 additions & 1 deletion src/_pytest/helpconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
62 changes: 62 additions & 0 deletions testing/test_assertion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading