diff --git a/changelog/14751.improvement.rst b/changelog/14751.improvement.rst new file mode 100644 index 00000000000..080c512a2f9 --- /dev/null +++ b/changelog/14751.improvement.rst @@ -0,0 +1 @@ +:func:`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"]``). diff --git a/doc/en/conf.py b/doc/en/conf.py index 04ba1cfc616..ecb95e0b9f1 100644 --- a/doc/en/conf.py +++ b/doc/en/conf.py @@ -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"), diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index e08fc41d3f5..688cc8a9054 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -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 @@ -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 @@ -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)) diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index d6a97c8928e..d435715785d 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -4,13 +4,20 @@ 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 @@ -18,8 +25,81 @@ 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("--")] @@ -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] = {} @@ -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] = (), @@ -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``). @@ -239,23 +335,25 @@ 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_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: @@ -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. diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index fdba02b35f4..1bceb05558c 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -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 @@ -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): diff --git a/testing/test_config.py b/testing/test_config.py index 14946dde164..5d19627bca7 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -10,6 +10,7 @@ import sys import textwrap from typing import Any +from typing import Literal import _pytest._code from _pytest.config import _get_plugin_specs_as_list @@ -1088,6 +1089,203 @@ def pytest_addoption(parser): ): _ = config.getini("ini_param") + UNION_CONFTEST = """ + def pytest_addoption(parser): + parser.addini("ini_param", "", type=int | str, default=None) + """ + + LITERAL_CONFTEST = """ + from typing import Literal + + def pytest_addoption(parser): + parser.addini( + "ini_param", "", type=Literal["auto", "long"], default="auto" + ) + """ + + @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(self.UNION_CONFTEST) + 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(self.UNION_CONFTEST) + 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 + + @pytest.mark.parametrize("bad_type", ["integer", dict, int | dict]) + def test_addini_invalid_type(self, bad_type: object) -> None: + parser = Parser(_ispytest=True) + with pytest.raises(ValueError, match="invalid type for ini option 'ini_param'"): + parser.addini("ini_param", "", type=bad_type) # type: ignore[arg-type] + + 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) + + @pytest.mark.parametrize( + "value, expected", + [('"long"', "long"), (None, "auto")], + ids=["value", "default"], + ) + def test_addini_literal_type( + self, pytester: Pytester, value: str | None, expected: str + ) -> None: + """A Literal of strings restricts the value to the given choices.""" + pytester.makeconftest(self.LITERAL_CONFTEST) + if value is not None: + pytester.makepyprojecttoml(f"[tool.pytest]\nini_param = {value}") + config = pytester.parseconfig() + assert config.getini("ini_param") == expected + + def test_addini_literal_type_ini_and_override(self, pytester: Pytester) -> None: + pytester.makeconftest(self.LITERAL_CONFTEST) + pytester.makeini( + """ + [pytest] + ini_param = long + """ + ) + assert pytester.parseconfig().getini("ini_param") == "long" + assert ( + pytester.parseconfig("-o", "ini_param=auto").getini("ini_param") == "auto" + ) + + @pytest.mark.parametrize( + "value, exc, match", + [ + ('"short"', ValueError, r"expects one of 'auto' \| 'long', got 'short'"), + ("5", TypeError, r"expects a string, got int: 5"), + ], + ids=["bad-choice", "bad-type"], + ) + def test_addini_literal_type_invalid_value( + self, pytester: Pytester, value: str, exc: type[Exception], match: str + ) -> None: + pytester.makeconftest(self.LITERAL_CONFTEST) + pytester.makepyprojecttoml(f"[tool.pytest]\nini_param = {value}") + config = pytester.parseconfig() + with pytest.raises(exc, match=f"config option 'ini_param' {match}"): + _ = config.getini("ini_param") + + UNION_LITERAL_CONFTEST = """ + from typing import Literal + + def pytest_addoption(parser): + parser.addini( + "ini_param", "", type=int | Literal["auto"], default="auto" + ) + """ + + @pytest.mark.parametrize( + "value, expected", + [("3", 3), ('"auto"', "auto"), (None, "auto")], + ids=["int", "literal", "default"], + ) + def test_addini_union_with_literal_toml( + self, pytester: Pytester, value: str | None, expected: object + ) -> None: + """A Literal of strings may be a union member, e.g. int | Literal["auto"].""" + pytester.makeconftest(self.UNION_LITERAL_CONFTEST) + if value is not None: + pytester.makepyprojecttoml(f"[tool.pytest]\nini_param = {value}") + assert pytester.parseconfig().getini("ini_param") == expected + + def test_addini_union_with_literal_ini_and_override( + self, pytester: Pytester + ) -> None: + pytester.makeconftest(self.UNION_LITERAL_CONFTEST) + pytester.makeini( + """ + [pytest] + ini_param = 3 + """ + ) + assert pytester.parseconfig().getini("ini_param") == 3 + assert ( + pytester.parseconfig("-o", "ini_param=auto").getini("ini_param") == "auto" + ) + + def test_addini_union_with_literal_invalid_value(self, pytester: Pytester) -> None: + pytester.makeconftest(self.UNION_LITERAL_CONFTEST) + pytester.makepyprojecttoml('[tool.pytest]\nini_param = "3"') + config = pytester.parseconfig() + with pytest.raises( + TypeError, + match=r"config option 'ini_param' expects one of int \| 'auto', " + r"got str: '3'", + ): + _ = config.getini("ini_param") + + def test_addini_union_with_literal_non_str_choice(self) -> None: + parser = Parser(_ispytest=True) + with pytest.raises(ValueError, match="Literal choices must be strings"): + parser.addini("ini_param", "", type=str | Literal[1], default="") + + def test_addini_literal_type_requires_default(self) -> None: + parser = Parser(_ispytest=True) + with pytest.raises( + ValueError, match="Literal type, which has no implicit default" + ): + parser.addini("ini_param", "", type=Literal["auto", "long"]) + + def test_addini_literal_type_non_str_choice(self) -> None: + parser = Parser(_ispytest=True) + with pytest.raises(ValueError, match="Literal choices must be strings"): + parser.addini("ini_param", "", type=Literal["auto", 1], default="auto") + def test_addinivalue_line_existing(self, pytester: Pytester) -> None: pytester.makeconftest( """ diff --git a/testing/test_helpconfig.py b/testing/test_helpconfig.py index 7c2cb49d87e..5a6d4d16b4a 100644 --- a/testing/test_helpconfig.py +++ b/testing/test_helpconfig.py @@ -51,6 +51,38 @@ def test_help(pytester: Pytester) -> None: ) +def test_help_ini_union_and_literal_types(pytester: Pytester) -> None: + """Union and Literal ini options display their members/choices in --help.""" + pytester.makeconftest( + """ + from typing import Literal + + def pytest_addoption(parser): + parser.addini("ini_union", "union help", type=int | str, default=None) + parser.addini( + "ini_literal", "literal help", type=Literal["auto", "long"], + default="auto", + ) + parser.addini( + "ini_mixed", "mixed help", type=int | Literal["auto"], + default="auto", + ) + """ + ) + result = pytester.runpytest("--help") + assert result.ret == ExitCode.OK + result.stdout.fnmatch_lines( + [ + "*ini_union (int | string):*", + "*union help*", + "*ini_literal ('auto' | 'long'):*", + "*literal help*", + "*ini_mixed (int | 'auto'):*", + "*mixed help*", + ] + ) + + def test_none_help_param_raises_exception(pytester: Pytester) -> None: """Test that a None help param raises a TypeError.""" pytester.makeconftest(