diff --git a/changelog/13829.feature.rst b/changelog/13829.feature.rst new file mode 100644 index 00000000000..5f80ca5ac2f --- /dev/null +++ b/changelog/13829.feature.rst @@ -0,0 +1,5 @@ +Added support for ini option aliases via the ``aliases`` parameter in :meth:`Parser.addini() `. + +Plugins can now register alternative names for ini options, +allowing for more flexibility in configuration naming and supporting backward compatibility when renaming options. +The canonical name always takes precedence if both the canonical name and an alias are specified in the configuration file. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index c26455e3d8b..2af60fa9c3c 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -1461,7 +1461,8 @@ def pytest_collection(self) -> Generator[None, object, object]: def _checkversion(self) -> None: import pytest - minver = self.inicfg.get("minversion", None) + minver_ini_value = self.inicfg.get("minversion", None) + minver = minver_ini_value.value if minver_ini_value is not None else None if minver: # Imported lazily to improve start-up time. from packaging.version import Version @@ -1519,9 +1520,9 @@ def _warn_or_fail_if_strict(self, message: str) -> None: self.issue_config_time_warning(PytestConfigWarning(message), stacklevel=3) - def _get_unknown_ini_keys(self) -> list[str]: - parser_inicfg = self._parser._inidict - return [name for name in self.inicfg if name not in parser_inicfg] + def _get_unknown_ini_keys(self) -> set[str]: + known_keys = self._parser._inidict.keys() | self._parser._ini_aliases.keys() + return self.inicfg.keys() - known_keys def parse(self, args: list[str], addopts: bool = True) -> None: # Parse given cmdline arguments into this config object. @@ -1621,10 +1622,11 @@ def getini(self, name: str) -> Any: :func:`parser.addini ` call (usually from a plugin), a ValueError is raised. """ + canonical_name = self._parser._ini_aliases.get(name, name) try: - return self._inicache[name] + return self._inicache[canonical_name] except KeyError: - self._inicache[name] = val = self._getini(name) + self._inicache[canonical_name] = val = self._getini(canonical_name) return val # Meant for easy monkeypatching by legacypath plugin. @@ -1636,14 +1638,32 @@ def _getini_unknown_type(self, name: str, type: str, value: object): raise ValueError(msg) # pragma: no cover def _getini(self, name: str): + # If this is an alias, resolve to canonical name. + canonical_name = self._parser._ini_aliases.get(name, name) + try: - _description, type, default = self._parser._inidict[name] + _description, type, default = self._parser._inidict[canonical_name] except KeyError as e: raise ValueError(f"unknown configuration value: {name!r}") from e - try: - value = self.inicfg[name] - except KeyError: + + # Collect all possible values (canonical name + aliases) from inicfg. + # Each candidate is (IniValue, is_canonical). + candidates = [] + if canonical_name in self.inicfg: + candidates.append((self.inicfg[canonical_name], True)) + for alias, target in self._parser._ini_aliases.items(): + if target == canonical_name and alias in self.inicfg: + candidates.append((self.inicfg[alias], False)) + + if not candidates: return default + + # Pick the best candidate based on precedence: + # 1. CLI override takes precedence over file, then + # 2. Canonical name takes precedence over alias. + ini_value = max(candidates, key=lambda x: (x[0].origin == "override", x[1]))[0] + value = ini_value.value + # Coerce the values based on types. # # Note: some coercions are only required if we are reading from .ini files, because diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 8d4ed823325..afd1892b222 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -52,6 +52,8 @@ def __init__( self._usage = usage self._inidict: dict[str, tuple[str, str | None, Any]] = {} self._ininames: list[str] = [] + # Maps alias -> canonical name. + self._ini_aliases: dict[str, str] = {} self.extra_info: dict[str, Any] = {} def processoption(self, option: Argument) -> None: @@ -179,6 +181,8 @@ def addini( ] | None = None, default: Any = NOT_SET, + *, + aliases: Sequence[str] = (), ) -> None: """Register an ini-file option. @@ -213,6 +217,12 @@ def addini( Defaults to ``string`` if ``None`` or not passed. :param default: Default value if no ini-file option exists but is queried. + :param aliases: + Additional names by which this option can be referenced. + Aliases resolve to the canonical name. + + .. versionadded:: 9.0 + The ``aliases`` parameter. The value of ini-variables can be retrieved via a call to :py:func:`config.getini(name) `. @@ -234,6 +244,13 @@ def addini( self._inidict[name] = (help, type, default) self._ininames.append(name) + for alias in aliases: + if alias in self._inidict: + raise ValueError(f"alias {alias!r} conflicts with existing ini option") + if (already := self._ini_aliases.get(alias)) is not None: + raise ValueError(f"{alias!r} is already an alias of {already!r}") + self._ini_aliases[alias] = name + def get_ini_default_for_type( type: Literal[ diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index 2dea16dd0d5..fcdcfa69f7d 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -2,9 +2,11 @@ from collections.abc import Iterable from collections.abc import Sequence +from dataclasses import dataclass import os from pathlib import Path import sys +from typing import Literal from typing import TypeAlias import iniconfig @@ -16,9 +18,23 @@ from _pytest.pathlib import safe_exists -# 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, str | list[str]] +@dataclass(frozen=True) +class IniValue: + """Represents an ini configuration value with its origin. + + This allows tracking whether a value came from a configuration file + or from a CLI override (--override-ini), which is important for + determining precedence when dealing with ini option aliases. + """ + + # 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. + value: str | list[str] + origin: Literal["file", "override"] + + +ConfigDict: TypeAlias = dict[str, IniValue] def _parse_ini_config(path: Path) -> iniconfig.IniConfig: @@ -45,7 +61,7 @@ def load_config_dict_from_file( iniconfig = _parse_ini_config(filepath) if "pytest" in iniconfig: - return dict(iniconfig["pytest"].items()) + return {k: IniValue(v, "file") for k, v in iniconfig["pytest"].items()} else: # "pytest.ini" files are always the source of configuration, even if empty. if filepath.name == "pytest.ini": @@ -56,7 +72,7 @@ def load_config_dict_from_file( iniconfig = _parse_ini_config(filepath) if "tool:pytest" in iniconfig.sections: - return dict(iniconfig["tool:pytest"].items()) + return {k: IniValue(v, "file") for k, v in iniconfig["tool:pytest"].items()} elif "pytest" in iniconfig.sections: # If a setup.cfg contains a "[pytest]" section, we raise a failure to indicate users that # plain "[pytest]" sections in setup.cfg files is no longer supported (#3086). @@ -83,7 +99,7 @@ def load_config_dict_from_file( def make_scalar(v: object) -> str | list[str]: return v if isinstance(v, list) else str(v) - return {k: make_scalar(v) for k, v in result.items()} + return {k: IniValue(make_scalar(v), "file") for k, v in result.items()} return None @@ -181,7 +197,7 @@ def get_dir_from_path(path: Path) -> Path: return [get_dir_from_path(path) for path in possible_paths if safe_exists(path)] -def parse_override_ini(override_ini: Sequence[str] | None) -> dict[str, str]: +def parse_override_ini(override_ini: Sequence[str] | None) -> ConfigDict: """Parse the -o/--override-ini command line arguments and return the overrides. :raises UsageError: @@ -199,7 +215,7 @@ def parse_override_ini(override_ini: Sequence[str] | None) -> dict[str, str]: f"-o/--override-ini expects option=value style (got: {ini_config!r})." ) from e else: - overrides[key] = user_ini_value + overrides[key] = IniValue(user_ini_value, "override") return overrides diff --git a/testing/test_config.py b/testing/test_config.py index d85e95046c1..773bd2c927d 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -25,6 +25,7 @@ from _pytest.config.exceptions import UsageError from _pytest.config.findpaths import determine_setup from _pytest.config.findpaths import get_common_ancestor +from _pytest.config.findpaths import IniValue from _pytest.config.findpaths import locate_config from _pytest.monkeypatch import MonkeyPatch from _pytest.pathlib import absolutepath @@ -57,9 +58,9 @@ def test_getcfg_and_config( encoding="utf-8", ) _, _, cfg, _ = locate_config(Path.cwd(), [sub]) - assert cfg["name"] == "value" + assert cfg["name"] == IniValue("value", "file") config = pytester.parseconfigure(str(sub)) - assert config.inicfg["name"] == "value" + assert config.inicfg["name"] == IniValue("value", "file") def test_setupcfg_uses_toolpytest_with_pytest(self, pytester: Pytester) -> None: p1 = pytester.makepyfile("def test(): pass") @@ -1005,6 +1006,166 @@ def pytest_addoption(parser): value = config.getini("no_type") assert value == "" + def test_addini_with_aliases(self, pytester: Pytester) -> None: + """Test that ini options can have aliases.""" + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("new_name", "my option", aliases=["old_name"]) + """ + ) + pytester.makeini( + """ + [pytest] + old_name = hello + """ + ) + config = pytester.parseconfig() + # Should be able to access via canonical name. + assert config.getini("new_name") == "hello" + # Should also be able to access via alias. + assert config.getini("old_name") == "hello" + + def test_addini_aliases_with_canonical_in_file(self, pytester: Pytester) -> None: + """Test that canonical name takes precedence over alias in ini file.""" + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("new_name", "my option", aliases=["old_name"]) + """ + ) + pytester.makeini( + """ + [pytest] + old_name = from_alias + new_name = from_canonical + """ + ) + config = pytester.parseconfig() + # Canonical name should take precedence. + assert config.getini("new_name") == "from_canonical" + assert config.getini("old_name") == "from_canonical" + + def test_addini_aliases_multiple(self, pytester: Pytester) -> None: + """Test that ini option can have multiple aliases.""" + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("current_name", "my option", aliases=["old_name", "legacy_name"]) + """ + ) + pytester.makeini( + """ + [pytest] + old_name = value1 + """ + ) + config = pytester.parseconfig() + assert config.getini("current_name") == "value1" + assert config.getini("old_name") == "value1" + assert config.getini("legacy_name") == "value1" + + def test_addini_aliases_with_override_of_old(self, pytester: Pytester) -> None: + """Test that aliases work with --override-ini -- ini sets old.""" + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("new_name", "my option", aliases=["old_name"]) + """ + ) + pytester.makeini( + """ + [pytest] + old_name = from_file + """ + ) + # Override using alias. + config = pytester.parseconfig("-o", "old_name=overridden") + assert config.getini("new_name") == "overridden" + assert config.getini("old_name") == "overridden" + + # Override using canonical name. + config = pytester.parseconfig("-o", "new_name=overridden2") + assert config.getini("new_name") == "overridden2" + + def test_addini_aliases_with_override_of_new(self, pytester: Pytester) -> None: + """Test that aliases work with --override-ini -- ini sets new.""" + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("new_name", "my option", aliases=["old_name"]) + """ + ) + pytester.makeini( + """ + [pytest] + new_name = from_file + """ + ) + # Override using alias. + config = pytester.parseconfig("-o", "old_name=overridden") + assert config.getini("new_name") == "overridden" + assert config.getini("old_name") == "overridden" + + # Override using canonical name. + config = pytester.parseconfig("-o", "new_name=overridden2") + assert config.getini("new_name") == "overridden2" + + def test_addini_aliases_with_types(self, pytester: Pytester) -> None: + """Test that aliases work with different types.""" + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("mylist", "list option", type="linelist", aliases=["oldlist"]) + parser.addini("mybool", "bool option", type="bool", aliases=["oldbool"]) + """ + ) + pytester.makeini( + """ + [pytest] + oldlist = line1 + line2 + oldbool = true + """ + ) + config = pytester.parseconfig() + assert config.getini("mylist") == ["line1", "line2"] + assert config.getini("oldlist") == ["line1", "line2"] + assert config.getini("mybool") is True + assert config.getini("oldbool") is True + + def test_addini_aliases_conflict_error(self, pytester: Pytester) -> None: + """Test that registering an alias that conflicts with an existing option raises an error.""" + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("existing", "first option") + + try: + parser.addini("new_option", "second option", aliases=["existing"]) + except ValueError as e: + assert "alias 'existing' conflicts with existing ini option" in str(e) + else: + assert False, "Should have raised ValueError" + """ + ) + pytester.parseconfig() + + def test_addini_aliases_duplicate_error(self, pytester: Pytester) -> None: + """Test that registering the same alias twice raises an error.""" + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini("option1", "first option", aliases=["shared_alias"]) + try: + parser.addini("option2", "second option", aliases=["shared_alias"]) + raise AssertionError("Should have raised ValueError") + except ValueError as e: + assert "'shared_alias' is already an alias of 'option1'" in str(e) + """ + ) + pytester.parseconfig() + @pytest.mark.parametrize( "type, expected", [ @@ -1153,7 +1314,7 @@ def test_inifilename(self, tmp_path: Path) -> None: # this indicates this is the file used for getting configuration values assert config.inipath == inipath - assert config.inicfg.get("name") == "value" + assert config.inicfg.get("name") == IniValue("value", "file") assert config.inicfg.get("should_not_be_set") is None @@ -1647,7 +1808,7 @@ def test_with_ini(self, tmp_path: Path, name: str, contents: str) -> None: ) assert rootpath == tmp_path assert parsed_inipath == inipath - assert ini_config == {"x": "10"} + assert ini_config["x"] == IniValue("10", "file") @pytest.mark.parametrize("name", ["setup.cfg", "tox.ini"]) def test_pytestini_overrides_empty_other(self, tmp_path: Path, name: str) -> None: @@ -1721,7 +1882,7 @@ def test_with_specific_inifile( ) assert rootpath == tmp_path assert inipath == p - assert ini_config == {"x": "10"} + assert ini_config["x"] == IniValue("10", "file") def test_explicit_config_file_sets_rootdir( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -1991,7 +2152,7 @@ def test_addopts_before_initini( monkeypatch.setenv("PYTEST_ADDOPTS", f"-o cache_dir={cache_dir}") config = _config_for_test config._preparse([], addopts=True) - assert config.inicfg.get("cache_dir") == cache_dir + assert config.inicfg.get("cache_dir") == IniValue(cache_dir, "override") def test_addopts_from_env_not_concatenated( self, monkeypatch: MonkeyPatch, _config_for_test @@ -2029,7 +2190,7 @@ def test_override_ini_does_not_contain_paths( """Check that -o no longer swallows all options after it (#3103)""" config = _config_for_test config._preparse(["-o", "cache_dir=/cache", "/some/test/path"]) - assert config.inicfg.get("cache_dir") == "/cache" + assert config.inicfg.get("cache_dir") == IniValue("/cache", "override") def test_multiple_override_ini_options(self, pytester: Pytester) -> None: """Ensure a file path following a '-o' option does not generate an error (#3103)""" diff --git a/testing/test_findpaths.py b/testing/test_findpaths.py index 9532f1eef75..fed8c9d4838 100644 --- a/testing/test_findpaths.py +++ b/testing/test_findpaths.py @@ -8,6 +8,7 @@ from _pytest.config import UsageError from _pytest.config.findpaths import get_common_ancestor from _pytest.config.findpaths import get_dirs_from_args +from _pytest.config.findpaths import IniValue from _pytest.config.findpaths import is_fs_root from _pytest.config.findpaths import load_config_dict_from_file import pytest @@ -24,13 +25,13 @@ def test_pytest_ini(self, tmp_path: Path) -> None: """[pytest] section in pytest.ini files is read correctly""" fn = tmp_path / "pytest.ini" fn.write_text("[pytest]\nx=1", encoding="utf-8") - assert load_config_dict_from_file(fn) == {"x": "1"} + assert load_config_dict_from_file(fn) == {"x": IniValue("1", "file")} def test_custom_ini(self, tmp_path: Path) -> None: """[pytest] section in any .ini file is read correctly""" fn = tmp_path / "custom.ini" fn.write_text("[pytest]\nx=1", encoding="utf-8") - assert load_config_dict_from_file(fn) == {"x": "1"} + assert load_config_dict_from_file(fn) == {"x": IniValue("1", "file")} def test_custom_ini_without_section(self, tmp_path: Path) -> None: """Custom .ini files without [pytest] section are not considered for configuration""" @@ -48,7 +49,7 @@ def test_valid_cfg_file(self, tmp_path: Path) -> None: """Custom .cfg files with [tool:pytest] section are read correctly""" fn = tmp_path / "custom.cfg" fn.write_text("[tool:pytest]\nx=1", encoding="utf-8") - assert load_config_dict_from_file(fn) == {"x": "1"} + assert load_config_dict_from_file(fn) == {"x": IniValue("1", "file")} def test_unsupported_pytest_section_in_cfg_file(self, tmp_path: Path) -> None: """.cfg files with [pytest] section are no longer supported and should fail to alert users""" @@ -96,11 +97,11 @@ def test_valid_toml_file(self, tmp_path: Path) -> None: encoding="utf-8", ) assert load_config_dict_from_file(fn) == { - "x": "1", - "y": "20.0", - "values": ["tests", "integration"], - "name": "foo", - "heterogeneous_array": [1, "str"], + "x": IniValue("1", "file"), + "y": IniValue("20.0", "file"), + "values": IniValue(["tests", "integration"], "file"), + "name": IniValue("foo", "file"), + "heterogeneous_array": IniValue([1, "str"], "file"), # type: ignore[list-item] }