From 9f042aeb20ff6673af0c33c6dfda3262cd7667ab Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Thu, 23 Jul 2026 16:23:56 +0200 Subject: [PATCH 1/8] Extract _IniTypeTag and _IniTypeArg type aliases The Literal of ini option type tags was repeated inline in Parser.addini and get_ini_default_for_type. Name it, and name the full form the type argument accepts, ahead of extending it. Co-Authored-By: Claude Fable 5 --- src/_pytest/config/argparsing.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index d6a97c8928e..4e713ead967 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -11,6 +11,7 @@ from typing import final from typing import Literal from typing import NoReturn +from typing import TypeAlias from .exceptions import UsageError import _pytest._io @@ -20,6 +21,15 @@ 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" +] + +#: The forms accepted by :meth:`Parser.addini` for its ``type`` argument; +#: ``None`` means ``"string"``. +_IniTypeArg: TypeAlias = _IniTypeTag | None + def _get_argparse_dest(opts: Sequence[str]) -> str: long_opts = [opt for opt in opts if opt.startswith("--")] @@ -188,10 +198,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] = (), @@ -267,11 +274,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. From 5ab4b6dff2af25891866cd474b22775da54a85e0 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Tue, 21 Jul 2026 21:07:46 +0200 Subject: [PATCH 2/8] Accept type expressions in Parser.addini(type=...) addini now accepts plain Python types (str, bool, int, float) and unions of them (e.g. `int | str`) for its `type` argument, in addition to the existing string tags, as proposed by bluetech in #14675. A union means the option accepts a value of any of its member types: getini tries each member in order and returns the first that accepts the value. In TOML config the native value may be of any member type, and string-based formats (INI files, -o overrides) coerce it to the first member that accepts it. Internally union types are normalized to a tuple of the existing string tags, reusing the per-tag coercion; single-tag registration and lookup are unchanged. An invalid `type` argument raises ValueError instead of asserting, since it is user (plugin) provided, and registering a union type without an explicit default also raises ValueError instead of silently defaulting to None. Co-Authored-By: Claude Fable 5 --- changelog/14675.improvement.rst | 1 + src/_pytest/config/__init__.py | 29 +++++++++++ src/_pytest/config/argparsing.py | 87 ++++++++++++++++++++++++++------ src/_pytest/helpconfig.py | 3 +- testing/test_config.py | 85 +++++++++++++++++++++++++++++++ 5 files changed, 188 insertions(+), 17 deletions(-) create mode 100644 changelog/14675.improvement.rst diff --git a/changelog/14675.improvement.rst b/changelog/14675.improvement.rst new file mode 100644 index 00000000000..4fe6274a708 --- /dev/null +++ b/changelog/14675.improvement.rst @@ -0,0 +1 @@ +:func:`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. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index e08fc41d3f5..bdba8b6c160 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 @@ -1781,6 +1782,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)) diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 4e713ead967..8ff7b993403 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -7,11 +7,16 @@ 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 TypeAlias +from typing import Union from .exceptions import UsageError import _pytest._io @@ -28,7 +33,42 @@ #: The forms accepted by :meth:`Parser.addini` for its ``type`` argument; #: ``None`` means ``"string"``. -_IniTypeArg: TypeAlias = _IniTypeTag | None +_IniTypeArg: TypeAlias = ( + _IniTypeTag | type[bool | int | float | str] | types.UnionType | None +) + +#: 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_) + 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`)" + ) def _get_argparse_dest(opts: Sequence[str]) -> str: @@ -70,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] = {} @@ -223,6 +263,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``). @@ -246,23 +298,26 @@ 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_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 + # 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: diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index fdba02b35f4..7f5c333ba38 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -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): diff --git a/testing/test_config.py b/testing/test_config.py index 14946dde164..46d3eaf2a8f 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1088,6 +1088,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( """ From 47ed430d5adc942df99dbe4c404756666f008b07 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Thu, 23 Jul 2026 06:34:33 +0200 Subject: [PATCH 3/8] Address review comments - Annotate the ini type aliases with TypeAlias - Type addini(type=...) with TypeForm instead of type[...] | UnionType, which statically rejects unions of unsupported types - Drop the unneeded string quoting in cast("_IniTypeTag", ...) - Remove a comment redundant with its exception message Co-Authored-By: Claude Fable 5 --- src/_pytest/config/argparsing.py | 20 +++++++++++--------- testing/test_config.py | 2 +- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 8ff7b993403..14b3130aa5a 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -15,6 +15,7 @@ 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 @@ -24,6 +25,10 @@ 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. @@ -31,16 +36,15 @@ "string", "paths", "pathlist", "args", "linelist", "bool", "int", "float" ] -#: The forms accepted by :meth:`Parser.addini` for its ``type`` argument; -#: ``None`` means ``"string"``. -_IniTypeArg: TypeAlias = ( - _IniTypeTag | type[bool | int | float | str] | types.UnionType | None -) +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 #: 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, ...] +IniType: TypeAlias = _IniTypeTag | tuple[_IniTypeTag, ...] _INI_TYPE_TAGS: tuple[str, ...] = get_args(_IniTypeTag) @@ -61,7 +65,7 @@ def _ini_type_to_tag(name: str, type_: object) -> _IniTypeTag: plain Python type. """ if isinstance(type_, str) and type_ in _INI_TYPE_TAGS: - return cast("_IniTypeTag", type_) + return cast(_IniTypeTag, type_) if isinstance(type_, type) and type_ in _INI_TYPE_TO_TAG: return _INI_TYPE_TO_TAG[type_] raise ValueError( @@ -309,8 +313,6 @@ def addini( ini_type = _ini_type_to_tag(name, type) if default is NOTSET: if isinstance(ini_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`" diff --git a/testing/test_config.py b/testing/test_config.py index 46d3eaf2a8f..9fe7a5ec198 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1164,7 +1164,7 @@ def test_addini_invalid_type(self, pytester: Pytester) -> None: 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) + parser.addini("ini_param", "", type=int | dict) # type: ignore[arg-type] def test_addini_union_type_requires_default(self) -> None: parser = Parser(_ispytest=True) From c34679432e73ba90f694ac2f3a9e4c5ce8e7ef27 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Thu, 23 Jul 2026 07:03:43 +0200 Subject: [PATCH 4/8] Accept Literal type expressions in Parser.addini(type=...) A Literal of strings restricts an ini option's value to the given choices, for example type=Literal["auto", "long"]. The choices are shown in the --help output. Since the choices have no unambiguous implicit default, an explicit default is required, as for unions. Suggested by bluetech in the review of #14751. Co-Authored-By: Claude Fable 5 --- changelog/14675.improvement.rst | 1 - changelog/14751.improvement.rst | 1 + src/_pytest/config/__init__.py | 18 +++++ src/_pytest/config/argparsing.py | 45 ++++++++++-- src/_pytest/helpconfig.py | 8 ++- testing/test_config.py | 118 +++++++++++++++++++++++++++++++ testing/test_helpconfig.py | 30 ++++++++ 7 files changed, 212 insertions(+), 9 deletions(-) delete mode 100644 changelog/14675.improvement.rst create mode 100644 changelog/14751.improvement.rst diff --git a/changelog/14675.improvement.rst b/changelog/14675.improvement.rst deleted file mode 100644 index 4fe6274a708..00000000000 --- a/changelog/14675.improvement.rst +++ /dev/null @@ -1 +0,0 @@ -:func:`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. diff --git a/changelog/14751.improvement.rst b/changelog/14751.improvement.rst new file mode 100644 index 00000000000..c48482b1a04 --- /dev/null +++ b/changelog/14751.improvement.rst @@ -0,0 +1 @@ +:func:`parser.addini ` now also accepts plain Python types (``str``, ``bool``, ``int``, ``float``), unions of them (for example ``int | str``), and a ``Literal`` of strings (for example ``Literal["auto", "long"]``) for its ``type`` argument, in addition to the existing string tags. A union accepts a value of any of its member types; a ``Literal`` restricts the value to the given choices. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index bdba8b6c160..46ca7dc1862 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -61,6 +61,7 @@ from _pytest.compat import assert_never from _pytest.compat import deprecated from _pytest.compat import NOTSET +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 @@ -1782,6 +1783,23 @@ def _getini(self, name: str): value = selected.value mode = selected.mode + if isinstance(type, _IniLiteral): + # In both ini and toml modes, a Literal value is a plain string + # checked against the registered choices, without coercion. + if not isinstance(value, str): + value_type = builtins.type(value).__name__ + raise TypeError( + f"{self.inipath}: config option '{name}' expects a string, " + f"got {value_type}: {value!r}" + ) + if value not in type.choices: + choices = ", ".join(repr(choice) for choice in type.choices) + raise ValueError( + f"{self.inipath}: config option '{name}' expects one of " + f"{choices}, got {value!r}" + ) + return value + if not isinstance(type, tuple): return self._getini_value(mode, name, canonical_name, type, value, default) diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 14b3130aa5a..ed7294aa30b 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -4,6 +4,7 @@ import argparse from collections.abc import Callable from collections.abc import Sequence +import dataclasses import os import sys import textwrap @@ -41,10 +42,20 @@ #: ``None`` means ``"string"``. _IniTypeArg: TypeAlias = _IniTypeTag | TypeForm[bool | int | float | str] | None -#: 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: TypeAlias = _IniTypeTag | tuple[_IniTypeTag, ...] + +@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, +#: a tuple of tags meaning "accept a value of these types" (e.g. +#: ``("int", "string")``, normalized from ``int | str``), or the choices of a +#: ``Literal`` type. +IniType: TypeAlias = _IniTypeTag | tuple[_IniTypeTag, ...] | _IniLiteral _INI_TYPE_TAGS: tuple[str, ...] = get_args(_IniTypeTag) @@ -71,7 +82,8 @@ def _ini_type_to_tag(name: str, type_: object) -> _IniTypeTag: 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`)" + "str, bool, int, float, a union of these types such as `int | str`, " + "or a `Literal` of strings)" ) @@ -279,6 +291,15 @@ def addini( Passing a type expression such as ``int`` or ``int | str``. + A ``Literal`` type of strings restricts the value to the given + choices, for example ``Literal["auto", "long", "short"]``. Since + the choices have no unambiguous implicit default, an explicit + ``default`` must be passed. + + .. versionadded:: 9.1 + + Passing a ``Literal`` type. + 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``). @@ -305,6 +326,15 @@ def addini( ini_type: IniType if type is None: ini_type = "string" + elif get_origin(type) is Literal: + choices = get_args(type) + for choice in choices: + if not isinstance(choice, str): + raise ValueError( + f"invalid type for ini option {name!r}: Literal " + f"choices must be strings, got {choice!r}" + ) + ini_type = _IniLiteral(choices) elif get_origin(type) in (Union, types.UnionType): ini_type = tuple( _ini_type_to_tag(name, member) for member in get_args(type) @@ -312,9 +342,10 @@ def addini( else: ini_type = _ini_type_to_tag(name, type) if default is NOTSET: - if isinstance(ini_type, tuple): + if isinstance(ini_type, (tuple, _IniLiteral)): + kind = "union" if isinstance(ini_type, tuple) else "Literal" raise ValueError( - f"ini option {name!r} has a union type, which has no " + 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) diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index 7f5c333ba38..3e7b954ad50 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 _IniLiteral from _pytest.config.argparsing import Parser from _pytest.terminal import TerminalReporter import pytest @@ -200,7 +201,12 @@ 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}") - type_repr = " | ".join(type) if isinstance(type, tuple) else type + if isinstance(type, _IniLiteral): + type_repr = " | ".join(repr(choice) for choice in type.choices) + elif isinstance(type, tuple): + type_repr = " | ".join(type) + else: + type_repr = type spec = f"{name} ({type_repr}):" tw.write(f" {spec}") spec_len = len(spec) diff --git a/testing/test_config.py b/testing/test_config.py index 9fe7a5ec198..f0a4ebff83c 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 @@ -1173,6 +1174,123 @@ def test_addini_union_type_requires_default(self) -> None: ): parser.addini("ini_param", "", type=int | str) + def test_addini_literal_type_toml(self, pytester: Pytester) -> None: + """A Literal of strings restricts the value to the given choices.""" + pytester.makeconftest( + """ + from typing import Literal + + def pytest_addoption(parser): + parser.addini( + "ini_param", "", type=Literal["auto", "long"], default="auto" + ) + """ + ) + pytester.makepyprojecttoml( + """ + [tool.pytest] + ini_param = "long" + """ + ) + config = pytester.parseconfig() + assert config.getini("ini_param") == "long" + + def test_addini_literal_type_ini_and_override(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + from typing import Literal + + def pytest_addoption(parser): + parser.addini( + "ini_param", "", type=Literal["auto", "long"], default="auto" + ) + """ + ) + pytester.makeini( + """ + [pytest] + ini_param = long + """ + ) + config = pytester.parseconfig() + assert config.getini("ini_param") == "long" + config = pytester.parseconfig("-o", "ini_param=auto") + assert config.getini("ini_param") == "auto" + + def test_addini_literal_type_default(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + from typing import Literal + + def pytest_addoption(parser): + parser.addini( + "ini_param", "", type=Literal["auto", "long"], default="auto" + ) + """ + ) + config = pytester.parseconfig() + assert config.getini("ini_param") == "auto" + + def test_addini_literal_type_invalid_value(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + from typing import Literal + + def pytest_addoption(parser): + parser.addini( + "ini_param", "", type=Literal["auto", "long"], default="auto" + ) + """ + ) + pytester.makepyprojecttoml( + """ + [tool.pytest] + ini_param = "short" + """ + ) + config = pytester.parseconfig() + with pytest.raises( + ValueError, + match=r"config option 'ini_param' expects one of 'auto', 'long', " + r"got 'short'", + ): + _ = config.getini("ini_param") + + def test_addini_literal_type_wrong_value_type(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + from typing import Literal + + def pytest_addoption(parser): + parser.addini( + "ini_param", "", type=Literal["auto", "long"], default="auto" + ) + """ + ) + pytester.makepyprojecttoml( + """ + [tool.pytest] + ini_param = 5 + """ + ) + config = pytester.parseconfig() + with pytest.raises( + TypeError, match=r"config option 'ini_param' expects a string, got int: 5" + ): + _ = config.getini("ini_param") + + 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..8b9689eb1dc 100644 --- a/testing/test_helpconfig.py +++ b/testing/test_helpconfig.py @@ -51,6 +51,36 @@ def test_help(pytester: Pytester) -> None: ) +def test_help_ini_union_type(pytester: Pytester) -> None: + """Union ini options display their member types in --help.""" + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("ini_param", "help text", type=int | str, default=None) + """ + ) + result = pytester.runpytest("--help") + assert result.ret == ExitCode.OK + result.stdout.fnmatch_lines(["*ini_param (int | string):*", "*help text*"]) + + +def test_help_ini_literal_type(pytester: Pytester) -> None: + """Literal ini options display their choices in --help.""" + pytester.makeconftest( + """ + from typing import Literal + + def pytest_addoption(parser): + parser.addini( + "ini_param", "help text", type=Literal["auto", "long"], default="auto" + ) + """ + ) + result = pytester.runpytest("--help") + assert result.ret == ExitCode.OK + result.stdout.fnmatch_lines(["*ini_param ('auto' | 'long'):*", "*help text*"]) + + def test_none_help_param_raises_exception(pytester: Pytester) -> None: """Test that a None help param raises a TypeError.""" pytester.makeconftest( From 78aaf09796a6e4b9ba2e6c787740397f4b3e57d8 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Thu, 23 Jul 2026 15:23:32 +0200 Subject: [PATCH 5/8] Next version is 9.2 now Co-authored-by: Bruno Oliveira --- src/_pytest/config/argparsing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index ed7294aa30b..95b64f8e89d 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -287,7 +287,7 @@ def addini( string-based formats (INI files, ``-o`` overrides) coerce it to the first member that accepts it. - .. versionadded:: 9.1 + .. versionadded:: 9.2 Passing a type expression such as ``int`` or ``int | str``. @@ -296,7 +296,7 @@ def addini( the choices have no unambiguous implicit default, an explicit ``default`` must be passed. - .. versionadded:: 9.1 + .. versionadded:: 9.2 Passing a ``Literal`` type. From 7f54d20cf30ee43c6eb3c7398ec3d1e00856b5b4 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Thu, 23 Jul 2026 15:58:33 +0200 Subject: [PATCH 6/8] Simplify addini type handling Apply simplification review findings: - Merge the two tag lookup tables in _ini_type_to_tag into one dict and a try/except, dropping the isinstance guards and the cast - Compute get_origin(type) once in addini - Add _ini_type_repr and use it for --help output and the getini error messages, instead of three inline formatting variants (Literal choices are now consistently rendered 'auto' | 'long') - Move the Literal branch from _getini into _getini_value so type interpretation happens in one layer - Merge the two versionadded 9.2 blocks in the addini docstring and drop the redundant second changelog sentence - Tests: hoist the duplicated conftests to class constants, parametrize the invalid-type and Literal cases, and merge the two --help tests into one Co-Authored-By: Claude Fable 5 --- changelog/14751.improvement.rst | 2 +- src/_pytest/config/__init__.py | 40 ++++---- src/_pytest/config/argparsing.py | 66 +++++++------ src/_pytest/helpconfig.py | 10 +- testing/test_config.py | 157 ++++++++++--------------------- testing/test_helpconfig.py | 30 +++--- 6 files changed, 115 insertions(+), 190 deletions(-) diff --git a/changelog/14751.improvement.rst b/changelog/14751.improvement.rst index c48482b1a04..fd95d93b606 100644 --- a/changelog/14751.improvement.rst +++ b/changelog/14751.improvement.rst @@ -1 +1 @@ -:func:`parser.addini ` now also accepts plain Python types (``str``, ``bool``, ``int``, ``float``), unions of them (for example ``int | str``), and a ``Literal`` of strings (for example ``Literal["auto", "long"]``) for its ``type`` argument, in addition to the existing string tags. A union accepts a value of any of its member types; a ``Literal`` restricts the value to the given choices. +:func:`parser.addini ` now also accepts plain Python types (``str``, ``bool``, ``int``, ``float``), unions of them (for example ``int | str``), and a ``Literal`` of strings (for example ``Literal["auto", "long"]``) for its ``type`` argument, in addition to the existing string tags. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 46ca7dc1862..6605aef85ee 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -61,6 +61,7 @@ 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 @@ -1783,28 +1784,10 @@ def _getini(self, name: str): value = selected.value mode = selected.mode - if isinstance(type, _IniLiteral): - # In both ini and toml modes, a Literal value is a plain string - # checked against the registered choices, without coercion. - if not isinstance(value, str): - value_type = builtins.type(value).__name__ - raise TypeError( - f"{self.inipath}: config option '{name}' expects a string, " - f"got {value_type}: {value!r}" - ) - if value not in type.choices: - choices = ", ".join(repr(choice) for choice in type.choices) - raise ValueError( - f"{self.inipath}: config option '{name}' expects one of " - f"{choices}, got {value!r}" - ) - return value - 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. + # Union: try each member; the first tag that accepts the value wins. for tag in type: try: return self._getini_value( @@ -1812,10 +1795,9 @@ def _getini(self, name: str): ) 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}" + f"{_ini_type_repr(type)}, got {builtins.type(value).__name__}: {value!r}" ) def _getini_value( @@ -1823,11 +1805,25 @@ def _getini_value( mode: Literal["ini", "toml"], name: str, canonical_name: str, - type: 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 95b64f8e89d..4c3f3f3e07d 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -10,7 +10,6 @@ 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 @@ -57,11 +56,9 @@ class _IniLiteral: #: ``Literal`` type. IniType: TypeAlias = _IniTypeTag | tuple[_IniTypeTag, ...] | _IniLiteral -_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] = { +#: 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", @@ -70,21 +67,25 @@ class _IniLiteral: 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_) - 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, a union of these types such as `int | str`, " - "or a `Literal` of strings)" - ) + """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_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(type) + return type def _get_argparse_dest(opts: Sequence[str]) -> str: @@ -287,10 +288,6 @@ def addini( string-based formats (INI files, ``-o`` overrides) coerce it to the first member that accepts it. - .. versionadded:: 9.2 - - Passing a type expression such as ``int`` or ``int | str``. - A ``Literal`` type of strings restricts the value to the given choices, for example ``Literal["auto", "long", "short"]``. Since the choices have no unambiguous implicit default, an explicit @@ -298,7 +295,8 @@ def addini( .. versionadded:: 9.2 - Passing a ``Literal`` type. + 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, @@ -324,18 +322,18 @@ def addini( :py:func:`config.getini(name) `. """ ini_type: IniType + origin = get_origin(type) if type is None: ini_type = "string" - elif get_origin(type) is Literal: + elif origin is Literal: choices = get_args(type) - for choice in choices: - if not isinstance(choice, str): - raise ValueError( - f"invalid type for ini option {name!r}: Literal " - f"choices must be strings, got {choice!r}" - ) + 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}" + ) ini_type = _IniLiteral(choices) - elif get_origin(type) in (Union, types.UnionType): + elif origin in (Union, types.UnionType): ini_type = tuple( _ini_type_to_tag(name, member) for member in get_args(type) ) diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index 3e7b954ad50..1bceb05558c 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -13,7 +13,7 @@ from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import PrintHelp -from _pytest.config.argparsing import _IniLiteral +from _pytest.config.argparsing import _ini_type_repr from _pytest.config.argparsing import Parser from _pytest.terminal import TerminalReporter import pytest @@ -201,13 +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}") - if isinstance(type, _IniLiteral): - type_repr = " | ".join(repr(choice) for choice in type.choices) - elif isinstance(type, tuple): - type_repr = " | ".join(type) - else: - type_repr = type - spec = f"{name} ({type_repr}):" + 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 f0a4ebff83c..7df62826a93 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1089,6 +1089,20 @@ 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", [ @@ -1105,12 +1119,7 @@ def pytest_addoption(parser): 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.makeconftest(self.UNION_CONFTEST) pytester.makepyprojecttoml( f""" {section} @@ -1123,12 +1132,7 @@ def pytest_addoption(parser): 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.makeconftest(self.UNION_CONFTEST) pytester.makepyprojecttoml( """ [tool.pytest] @@ -1158,14 +1162,11 @@ def pytest_addoption(parser): config = pytester.parseconfig() assert config.getini("ini_param") == 7 - def test_addini_invalid_type(self, pytester: Pytester) -> None: + @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="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) # type: ignore[arg-type] + parser.addini("ini_param", "", type=bad_type) # type: ignore[arg-type] def test_addini_union_type_requires_default(self) -> None: parser = Parser(_ispytest=True) @@ -1174,109 +1175,49 @@ def test_addini_union_type_requires_default(self) -> None: ): parser.addini("ini_param", "", type=int | str) - def test_addini_literal_type_toml(self, pytester: Pytester) -> None: + @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( - """ - from typing import Literal - - def pytest_addoption(parser): - parser.addini( - "ini_param", "", type=Literal["auto", "long"], default="auto" - ) - """ - ) - pytester.makepyprojecttoml( - """ - [tool.pytest] - ini_param = "long" - """ - ) + 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") == "long" + assert config.getini("ini_param") == expected def test_addini_literal_type_ini_and_override(self, pytester: Pytester) -> None: - pytester.makeconftest( - """ - from typing import Literal - - def pytest_addoption(parser): - parser.addini( - "ini_param", "", type=Literal["auto", "long"], default="auto" - ) - """ - ) + pytester.makeconftest(self.LITERAL_CONFTEST) pytester.makeini( """ [pytest] ini_param = long """ ) - config = pytester.parseconfig() - assert config.getini("ini_param") == "long" - config = pytester.parseconfig("-o", "ini_param=auto") - assert config.getini("ini_param") == "auto" - - def test_addini_literal_type_default(self, pytester: Pytester) -> None: - pytester.makeconftest( - """ - from typing import Literal - - def pytest_addoption(parser): - parser.addini( - "ini_param", "", type=Literal["auto", "long"], default="auto" - ) - """ - ) - config = pytester.parseconfig() - assert config.getini("ini_param") == "auto" - - def test_addini_literal_type_invalid_value(self, pytester: Pytester) -> None: - pytester.makeconftest( - """ - from typing import Literal - - def pytest_addoption(parser): - parser.addini( - "ini_param", "", type=Literal["auto", "long"], default="auto" - ) - """ - ) - pytester.makepyprojecttoml( - """ - [tool.pytest] - ini_param = "short" - """ + assert pytester.parseconfig().getini("ini_param") == "long" + assert ( + pytester.parseconfig("-o", "ini_param=auto").getini("ini_param") == "auto" ) - config = pytester.parseconfig() - with pytest.raises( - ValueError, - match=r"config option 'ini_param' expects one of 'auto', 'long', " - r"got 'short'", - ): - _ = config.getini("ini_param") - - def test_addini_literal_type_wrong_value_type(self, pytester: Pytester) -> None: - pytester.makeconftest( - """ - from typing import Literal - def pytest_addoption(parser): - parser.addini( - "ini_param", "", type=Literal["auto", "long"], default="auto" - ) - """ - ) - pytester.makepyprojecttoml( - """ - [tool.pytest] - ini_param = 5 - """ - ) + @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( - TypeError, match=r"config option 'ini_param' expects a string, got int: 5" - ): + with pytest.raises(exc, match=f"config option 'ini_param' {match}"): _ = config.getini("ini_param") def test_addini_literal_type_requires_default(self) -> None: diff --git a/testing/test_helpconfig.py b/testing/test_helpconfig.py index 8b9689eb1dc..f68468d2f95 100644 --- a/testing/test_helpconfig.py +++ b/testing/test_helpconfig.py @@ -51,34 +51,30 @@ def test_help(pytester: Pytester) -> None: ) -def test_help_ini_union_type(pytester: Pytester) -> None: - """Union ini options display their member types in --help.""" - pytester.makeconftest( - """ - def pytest_addoption(parser): - parser.addini("ini_param", "help text", type=int | str, default=None) - """ - ) - result = pytester.runpytest("--help") - assert result.ret == ExitCode.OK - result.stdout.fnmatch_lines(["*ini_param (int | string):*", "*help text*"]) - - -def test_help_ini_literal_type(pytester: Pytester) -> None: - """Literal ini options display their choices in --help.""" +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_param", "help text", type=Literal["auto", "long"], default="auto" + "ini_literal", "literal help", type=Literal["auto", "long"], + default="auto", ) """ ) result = pytester.runpytest("--help") assert result.ret == ExitCode.OK - result.stdout.fnmatch_lines(["*ini_param ('auto' | 'long'):*", "*help text*"]) + result.stdout.fnmatch_lines( + [ + "*ini_union (int | string):*", + "*union help*", + "*ini_literal ('auto' | 'long'):*", + "*literal help*", + ] + ) def test_none_help_param_raises_exception(pytester: Pytester) -> None: From 9fffdcd40189e8027191a82790a6491b5ba8bd63 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Thu, 23 Jul 2026 17:14:07 +0200 Subject: [PATCH 7/8] doc: Ignore private _IniTypeArg alias in nitpick list The alias only exists under TYPE_CHECKING, so Sphinx cannot resolve the reference in the rendered Parser.addini signature, failing the docs build (-W with nitpicky). Co-Authored-By: Claude Fable 5 --- doc/en/conf.py | 1 + 1 file changed, 1 insertion(+) 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"), From cdfd3f5094bb6125a88a84d4bb6a5dbba8002ae3 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Thu, 23 Jul 2026 19:17:02 +0200 Subject: [PATCH 8/8] Accept Literal choices as union members in Parser.addini(type=...) Allows types such as int | Literal["auto"], where a value is accepted if any member accepts it. This is the shape of several existing options that take a number or a special string sentinel. Co-Authored-By: Claude Fable 5 --- changelog/14751.improvement.rst | 2 +- src/_pytest/config/__init__.py | 6 ++-- src/_pytest/config/argparsing.py | 45 ++++++++++++++------------ testing/test_config.py | 54 ++++++++++++++++++++++++++++++++ testing/test_helpconfig.py | 6 ++++ 5 files changed, 89 insertions(+), 24 deletions(-) diff --git a/changelog/14751.improvement.rst b/changelog/14751.improvement.rst index fd95d93b606..080c512a2f9 100644 --- a/changelog/14751.improvement.rst +++ b/changelog/14751.improvement.rst @@ -1 +1 @@ -:func:`parser.addini ` now also accepts plain Python types (``str``, ``bool``, ``int``, ``float``), unions of them (for example ``int | str``), and a ``Literal`` of strings (for example ``Literal["auto", "long"]``) for its ``type`` argument, in addition to the existing string tags. +: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/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 6605aef85ee..688cc8a9054 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1787,11 +1787,11 @@ def _getini(self, name: str): if not isinstance(type, tuple): return self._getini_value(mode, name, canonical_name, type, value, default) - # Union: try each member; the first tag that accepts the value wins. - for tag in type: + # 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, tag, value, default + mode, name, canonical_name, member, value, default ) except (TypeError, ValueError): pass diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 4c3f3f3e07d..d435715785d 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -51,10 +51,10 @@ class _IniLiteral: #: An ini option type, as stored internally after normalization: a single tag, -#: a tuple of tags meaning "accept a value of these types" (e.g. -#: ``("int", "string")``, normalized from ``int | str``), or the choices of a -#: ``Literal`` type. -IniType: TypeAlias = _IniTypeTag | tuple[_IniTypeTag, ...] | _IniLiteral +#: 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. @@ -79,12 +79,25 @@ def _ini_type_to_tag(name: str, type_: object) -> _IniTypeTag: ) 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(type) + return " | ".join(_ini_type_repr(member) for member in type) return type @@ -289,9 +302,10 @@ def addini( first member that accepts it. A ``Literal`` type of strings restricts the value to the given - choices, for example ``Literal["auto", "long", "short"]``. Since - the choices have no unambiguous implicit default, an explicit - ``default`` must be passed. + 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 @@ -322,23 +336,14 @@ def addini( :py:func:`config.getini(name) `. """ ini_type: IniType - origin = get_origin(type) if type is None: ini_type = "string" - elif origin 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}" - ) - ini_type = _IniLiteral(choices) - elif origin in (Union, types.UnionType): + elif get_origin(type) in (Union, types.UnionType): ini_type = tuple( - _ini_type_to_tag(name, member) for member in get_args(type) + _ini_type_to_member(name, member) for member in get_args(type) ) else: - ini_type = _ini_type_to_tag(name, type) + ini_type = _ini_type_to_member(name, type) if default is NOTSET: if isinstance(ini_type, (tuple, _IniLiteral)): kind = "union" if isinstance(ini_type, tuple) else "Literal" diff --git a/testing/test_config.py b/testing/test_config.py index 7df62826a93..5d19627bca7 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1220,6 +1220,60 @@ def test_addini_literal_type_invalid_value( 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( diff --git a/testing/test_helpconfig.py b/testing/test_helpconfig.py index f68468d2f95..5a6d4d16b4a 100644 --- a/testing/test_helpconfig.py +++ b/testing/test_helpconfig.py @@ -63,6 +63,10 @@ def pytest_addoption(parser): "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") @@ -73,6 +77,8 @@ def pytest_addoption(parser): "*union help*", "*ini_literal ('auto' | 'long'):*", "*literal help*", + "*ini_mixed (int | 'auto'):*", + "*mixed help*", ] )