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.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.
29 changes: 29 additions & 0 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from typing import Final
from typing import final
from typing import IO
from typing import Literal
from typing import TextIO
from typing import TYPE_CHECKING
import warnings
Expand Down Expand Up @@ -1747,6 +1748,34 @@ def _getini(self, name: str):
value = selected.value
mode = selected.mode

if not isinstance(type, tuple):
return self._getini_value(mode, name, canonical_name, type, value, default)

# A tuple type means "accept any of these types"; try each in order and
# return the first that accepts the value.
for tag in type:
try:
return self._getini_value(
mode, name, canonical_name, tag, value, default
)
except (TypeError, ValueError):
pass
value_type = builtins.type(value).__name__
raise TypeError(
f"{self.inipath}: config option '{name}' expects one of "
f"{' | '.join(type)}, got {value_type}: {value!r}"
)

def _getini_value(
self,
mode: Literal["ini", "toml"],
name: str,
canonical_name: str,
type: str,
value: object,
default: Any,
):
"""Convert a config value, read in the given mode, to the option's type."""
if mode == "ini":
# In ini mode, values are always str | list[str].
assert isinstance(value, (str, list))
Expand Down
100 changes: 77 additions & 23 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,44 @@

FILE_OR_DIR = "file_or_dir"

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

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.

Let's add : TypeAlias to these, then it will be automatically upgraded to type statement once that's available.

"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, ...]

_INI_TYPE_TAGS: tuple[str, ...] = get_args(_IniTypeTag)

#: 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) and type_ in _INI_TYPE_TAGS:
return cast("_IniTypeTag", 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.

Is the string type needed here (i.e. does replacing with _IniTypeTag not work)?

if 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 +97,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 +225,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
Comment on lines +228 to +230

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.

I think this can be written more type-safely as

type: _IniTypeTag | TypeForm[bool | int | float | str] | None = None,

where TypeForm is imported from typing_extensions under if TYPE_CHECKING (added in Python 3.15). It seems to work as intended in mypy at least.

| None = None,
default: Any = NOTSET,
*,
Expand All @@ -210,6 +253,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 +288,26 @@ 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)
if isinstance(ini_type, tuple):
# A union has no unambiguous implicit default; require an

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.

I think the exception message is clear enough that the comment is redundant.

# explicit one.
raise ValueError(
f"ini option {name!r} has a union type, which has no "
"implicit default; pass an explicit `default` to `addini`"
)
default = get_ini_default_for_type(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,11 +319,7 @@ 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: _IniTypeTag) -> Any:
"""
Used by addini to get the default value for a given config option type, when
default is not supplied.
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
85 changes: 85 additions & 0 deletions testing/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"""
Expand Down