diff --git a/changelog/11381.improvement.rst b/changelog/11381.improvement.rst new file mode 100644 index 00000000000..74c080cc188 --- /dev/null +++ b/changelog/11381.improvement.rst @@ -0,0 +1,17 @@ +The ``type`` parameter of the ``parser.addini`` method now accepts `"int"` and ``"float"`` parameters, facilitating the parsing of configuration values in the configuration file. + +Example: + +.. code-block:: python + + def pytest_addoption(parser): + parser.addini("int_value", type="int", default=2, help="my int value") + parser.addini("float_value", type="float", default=4.2, help="my float value") + +The `pytest.ini` file: + +.. code-block:: ini + + [pytest] + int_value = 3 + float_value = 5.4 diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 9e5b192b335..02da5cf9229 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1587,6 +1587,8 @@ def getini(self, name: str): ``paths``, ``pathlist``, ``args`` and ``linelist`` : empty list ``[]`` ``bool`` : ``False`` ``string`` : empty string ``""`` + ``int`` : ``0`` + ``float`` : ``0.0`` If neither the ``default`` nor the ``type`` parameter is passed while registering the configuration through @@ -1605,9 +1607,11 @@ def getini(self, name: str): # Meant for easy monkeypatching by legacypath plugin. # Can be inlined back (with no cover removed) once legacypath is gone. - def _getini_unknown_type(self, name: str, type: str, value: str | list[str]): - msg = f"unknown configuration type: {type}" - raise ValueError(msg, value) # pragma: no cover + def _getini_unknown_type(self, name: str, type: str, value: object): + msg = ( + f"Option {name} has unknown configuration type {type} with value {value!r}" + ) + raise ValueError(msg) # pragma: no cover def _getini(self, name: str): try: @@ -1656,6 +1660,18 @@ def _getini(self, name: str): return _strtobool(str(value).strip()) elif type == "string": return value + elif type == "int": + if not isinstance(value, str): + raise TypeError( + f"Expected an int string for option {name} of type integer, but got: {value!r}" + ) from None + return int(value) + elif type == "float": + if not isinstance(value, str): + raise TypeError( + f"Expected a float string for option {name} of type float, but got: {value!r}" + ) from None + return float(value) elif type is None: return value else: diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index d535fe1096a..948dfe8a510 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -191,6 +191,12 @@ def addini( * ``linelist``: a list of strings, separated by line breaks * ``paths``: a list of :class:`pathlib.Path`, separated as in a shell * ``pathlist``: a list of ``py.path``, separated as in a shell + * ``int``: an integer + * ``float``: a floating-point number + + .. versionadded:: 8.4 + + The ``float`` and ``int`` types. For ``paths`` and ``pathlist`` types, they are considered relative to the ini-file. In case the execution is happening without an ini-file defined, @@ -209,7 +215,17 @@ def addini( The value of ini-variables can be retrieved via a call to :py:func:`config.getini(name) `. """ - assert type in (None, "string", "paths", "pathlist", "args", "linelist", "bool") + assert type in ( + None, + "string", + "paths", + "pathlist", + "args", + "linelist", + "bool", + "int", + "float", + ) if default is NOT_SET: default = get_ini_default_for_type(type) @@ -218,7 +234,10 @@ def addini( def get_ini_default_for_type( - type: Literal["string", "paths", "pathlist", "args", "linelist", "bool"] | None, + type: Literal[ + "string", "paths", "pathlist", "args", "linelist", "bool", "int", "float" + ] + | None, ) -> Any: """ Used by addini to get the default value for a given ini-option type, when @@ -230,6 +249,10 @@ def get_ini_default_for_type( return [] elif type == "bool": return False + elif type == "int": + return 0 + elif type == "float": + return 0.0 else: return "" diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index a7f45bf593e..15bfbb0613e 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -5,6 +5,7 @@ import os from pathlib import Path import sys +from typing import TYPE_CHECKING import iniconfig @@ -15,6 +16,16 @@ from _pytest.pathlib import safe_exists +if TYPE_CHECKING: + from typing import Union + + from typing_extensions import TypeAlias + + # Even though TOML supports richer data types, all values are converted to str/list[str] during + # parsing to maintain compatibility with the rest of the configuration system. + ConfigDict: TypeAlias = dict[str, Union[str, list[str]]] + + def _parse_ini_config(path: Path) -> iniconfig.IniConfig: """Parse the given generic '.ini' file using legacy IniConfig parser, returning the parsed object. @@ -29,7 +40,7 @@ def _parse_ini_config(path: Path) -> iniconfig.IniConfig: def load_config_dict_from_file( filepath: Path, -) -> dict[str, str | list[str]] | None: +) -> ConfigDict | None: """Load pytest configuration from the given file path, if supported. Return None if the file does not contain valid pytest configuration. @@ -85,7 +96,7 @@ def make_scalar(v: object) -> str | list[str]: def locate_config( invocation_dir: Path, args: Iterable[Path], -) -> tuple[Path | None, Path | None, dict[str, str | list[str]]]: +) -> tuple[Path | None, Path | None, ConfigDict]: """Search in the list of arguments for a valid ini-file for pytest, and return a tuple of (rootdir, inifile, cfg-dict).""" config_names = [ @@ -172,7 +183,7 @@ def determine_setup( args: Sequence[str], rootdir_cmd_arg: str | None, invocation_dir: Path, -) -> tuple[Path, Path | None, dict[str, str | list[str]]]: +) -> tuple[Path, Path | None, ConfigDict]: """Determine the rootdir, inifile and ini configuration values from the command line arguments. diff --git a/testing/test_config.py b/testing/test_config.py index 53f26269f09..de07141238c 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -848,6 +848,82 @@ def pytest_addoption(parser): config = pytester.parseconfig() assert config.getini("strip") is bool_val + @pytest.mark.parametrize("str_val, int_val", [("10", 10), ("no-ini", 2)]) + def test_addini_int(self, pytester: Pytester, str_val: str, int_val: bool) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("ini_param", "", type="int", default=2) + """ + ) + if str_val != "no-ini": + pytester.makeini( + f""" + [pytest] + ini_param={str_val} + """ + ) + config = pytester.parseconfig() + assert config.getini("ini_param") == int_val + + def test_addini_int_invalid(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("ini_param", "", type="int", default=2) + """ + ) + pytester.makepyprojecttoml( + """ + [tool.pytest.ini_options] + ini_param=["foo"] + """ + ) + config = pytester.parseconfig() + with pytest.raises( + TypeError, match="Expected an int string for option ini_param" + ): + _ = config.getini("ini_param") + + @pytest.mark.parametrize("str_val, float_val", [("10.5", 10.5), ("no-ini", 2.2)]) + def test_addini_float( + self, pytester: Pytester, str_val: str, float_val: bool + ) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("ini_param", "", type="float", default=2.2) + """ + ) + if str_val != "no-ini": + pytester.makeini( + f""" + [pytest] + ini_param={str_val} + """ + ) + config = pytester.parseconfig() + assert config.getini("ini_param") == float_val + + def test_addini_float_invalid(self, pytester: Pytester) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("ini_param", "", type="float", default=2.2) + """ + ) + pytester.makepyprojecttoml( + """ + [tool.pytest.ini_options] + ini_param=["foo"] + """ + ) + config = pytester.parseconfig() + with pytest.raises( + TypeError, match="Expected a float string for option ini_param" + ): + _ = config.getini("ini_param") + def test_addinivalue_line_existing(self, pytester: Pytester) -> None: pytester.makeconftest( """