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/14751.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:func:`parser.addini <pytest.Parser.addini>` now also accepts type expressions for its ``type`` argument, in addition to the existing string tags: the plain Python types ``str``, ``bool``, ``int``, ``float``, a ``Literal`` of strings, and unions of these (for example ``int | str`` or ``int | Literal["auto"]``).
1 change: 1 addition & 0 deletions doc/en/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
("py:class", "_py_warnings.WarningMessage"),
# Undocumented type aliases
("py:class", "LEGACY_PATH"),
("py:class", "_IniTypeArg"),
("py:class", "_PluggyPlugin"),
# TypeVars
("py:class", "_pytest._code.code.E"),
Expand Down
43 changes: 43 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 All @@ -60,6 +61,8 @@
from _pytest.compat import assert_never
from _pytest.compat import deprecated
from _pytest.compat import NOTSET
from _pytest.config.argparsing import _ini_type_repr
from _pytest.config.argparsing import _IniLiteral
from _pytest.config.argparsing import Argument
from _pytest.config.argparsing import FILE_OR_DIR
from _pytest.config.argparsing import Parser
Expand Down Expand Up @@ -1781,6 +1784,46 @@ 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)

# Union: try each member; the first one that accepts the value wins.
for member in type:
try:
return self._getini_value(
mode, name, canonical_name, member, value, default
)
except (TypeError, ValueError):
pass
raise TypeError(
f"{self.inipath}: config option '{name}' expects one of "
f"{_ini_type_repr(type)}, got {builtins.type(value).__name__}: {value!r}"
)

def _getini_value(
self,
mode: Literal["ini", "toml"],
name: str,
canonical_name: str,
type: str | _IniLiteral,
value: object,
default: Any,
):
"""Convert a config value, read in the given mode, to the option's type."""
if isinstance(type, _IniLiteral):
# A Literal value is a plain string checked against the registered
# choices, without coercion, in both ini and toml modes.
if not isinstance(value, str):
raise TypeError(
f"{self.inipath}: config option '{name}' expects a string, "
f"got {builtins.type(value).__name__}: {value!r}"
)
if value not in type.choices:
raise ValueError(
f"{self.inipath}: config option '{name}' expects one of "
f"{_ini_type_repr(type)}, got {value!r}"
)
return value
if mode == "ini":
# In ini mode, values are always str | list[str].
assert isinstance(value, (str, list))
Expand Down
142 changes: 118 additions & 24 deletions src/_pytest/config/argparsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,102 @@
import argparse
from collections.abc import Callable
from collections.abc import Sequence
import dataclasses
import os
import sys
import textwrap
import types
from typing import Any
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 TYPE_CHECKING
from typing import TypeAlias
from typing import Union

from .exceptions import UsageError
import _pytest._io
from _pytest.compat import NOTSET
from _pytest.deprecated import check_ispytest


if TYPE_CHECKING:
from typing_extensions import TypeForm


FILE_OR_DIR = "file_or_dir"

#: The string tags accepted by :meth:`Parser.addini` for its ``type`` argument.
_IniTypeTag: TypeAlias = Literal[
"string", "paths", "pathlist", "args", "linelist", "bool", "int", "float"
]

if TYPE_CHECKING:
#: The forms accepted by :meth:`Parser.addini` for its ``type`` argument;
#: ``None`` means ``"string"``.
_IniTypeArg: TypeAlias = _IniTypeTag | TypeForm[bool | int | float | str] | None


@final
@dataclasses.dataclass(frozen=True)
class _IniLiteral:
"""The choices of an ini option registered with a ``Literal`` type."""

choices: tuple[str, ...]


#: An ini option type, as stored internally after normalization: a single tag,
#: the choices of a ``Literal`` type, or a tuple of members meaning "accept a
#: value of any of these" (e.g. ``("int", "string")``, normalized from
#: ``int | str``).
IniType: TypeAlias = _IniTypeTag | _IniLiteral | tuple[_IniTypeTag | _IniLiteral, ...]

#: Maps each string tag or plain Python type accepted by :meth:`Parser.addini`
#: for its ``type`` argument to the normalized string tag.
_INI_TYPES: dict[object, _IniTypeTag] = {tag: tag for tag in get_args(_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."""
try:
return _INI_TYPES[type_]
except (KeyError, TypeError): # TypeError: unhashable type_
raise ValueError(
f"invalid type for ini option {name!r}: {type_!r} (expected one of "
f"{', '.join(repr(tag) for tag in get_args(_IniTypeTag))}, one of "
"the types str, bool, int, float, a union of these types such as "
"`int | str`, or a `Literal` of strings)"
) from None


def _ini_type_to_member(name: str, type_: object) -> _IniTypeTag | _IniLiteral:
"""Normalize one member of an `addini(type=...)` argument."""
if get_origin(type_) is Literal:
choices = get_args(type_)
if not all(isinstance(choice, str) for choice in choices):
raise ValueError(
f"invalid type for ini option {name!r}: Literal choices "
f"must be strings, got {choices!r}"
)
return _IniLiteral(choices)
return _ini_type_to_tag(name, type_)


def _ini_type_repr(type: IniType) -> str:
"""Render an ini option type for --help output and error messages."""
if isinstance(type, _IniLiteral):
return " | ".join(repr(choice) for choice in type.choices)
if isinstance(type, tuple):
return " | ".join(_ini_type_repr(member) for member in type)
return type


def _get_argparse_dest(opts: Sequence[str]) -> str:
long_opts = [opt for opt in opts if opt.startswith("--")]
Expand Down Expand Up @@ -60,7 +140,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 @@ -188,10 +268,7 @@ def addini(
self,
name: str,
help: str,
type: Literal[
"string", "paths", "pathlist", "args", "linelist", "bool", "int", "float"
]
| None = None,
type: _IniTypeArg = None,
default: Any = NOTSET,
*,
aliases: Sequence[str] = (),
Expand All @@ -216,6 +293,25 @@ 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.

A ``Literal`` type of strings restricts the value to the given
choices, for example ``Literal["auto", "long", "short"]``, and may
also be a union member, for example ``int | Literal["auto"]``.
Since the choices have no unambiguous implicit default, an
explicit ``default`` must be passed.

.. versionadded:: 9.2

Passing a type expression such as ``int``, ``int | str``, or
a ``Literal`` of strings.

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 @@ -239,23 +335,25 @@ 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_member(name, member) for member in get_args(type)
)
else:
ini_type = _ini_type_to_member(name, type)
if default is NOTSET:
default = get_ini_default_for_type(type)
if isinstance(ini_type, (tuple, _IniLiteral)):
kind = "union" if isinstance(ini_type, tuple) else "Literal"
raise ValueError(
f"ini option {name!r} has a {kind} 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 @@ -267,11 +365,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 @@ -13,6 +13,7 @@
from _pytest.config import Config
from _pytest.config import ExitCode
from _pytest.config import PrintHelp
from _pytest.config.argparsing import _ini_type_repr
from _pytest.config.argparsing import Parser
from _pytest.terminal import TerminalReporter
import pytest
Expand Down Expand Up @@ -200,7 +201,7 @@ 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}):"
spec = f"{name} ({_ini_type_repr(type)}):"
tw.write(f" {spec}")
spec_len = len(spec)
if spec_len > (indent_len - 3):
Expand Down
Loading