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.
1 change: 1 addition & 0 deletions changelog/14675.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:func:`parser.addini <pytest.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.
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 | 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"),
)
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 | 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
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
114 changes: 90 additions & 24 deletions src/_pytest/config/argparsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,6 +25,57 @@

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.

#: 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, ...] = (

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",
)

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

Expand Down Expand Up @@ -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,
*,
Expand All @@ -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``).
Expand All @@ -233,23 +301,19 @@ 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",
)
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:
Expand All @@ -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
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
Loading