Skip to content

Commit de5d649

Browse files
committed
config: add support for ini option aliases
Fix #13829.
1 parent 44a7fc8 commit de5d649

File tree

4 files changed

+184
-9
lines changed

4 files changed

+184
-9
lines changed

changelog/13829.feature.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Added support for ini option aliases via the ``aliases`` parameter in :meth:`Parser.addini() <pytest.Parser.addini>`.
2+
3+
Plugins can now register alternative names for ini options,
4+
allowing for more flexibility in configuration naming and supporting backward compatibility when renaming options.
5+
The canonical name always takes precedence if both the canonical name and an alias are specified in the configuration file.

src/_pytest/config/__init__.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1519,9 +1519,9 @@ def _warn_or_fail_if_strict(self, message: str) -> None:
15191519

15201520
self.issue_config_time_warning(PytestConfigWarning(message), stacklevel=3)
15211521

1522-
def _get_unknown_ini_keys(self) -> list[str]:
1523-
parser_inicfg = self._parser._inidict
1524-
return [name for name in self.inicfg if name not in parser_inicfg]
1522+
def _get_unknown_ini_keys(self) -> set[str]:
1523+
known_keys = self._parser._inidict.keys() | self._parser._ini_aliases.keys()
1524+
return self.inicfg.keys() - known_keys
15251525

15261526
def parse(self, args: list[str], addopts: bool = True) -> None:
15271527
# Parse given cmdline arguments into this config object.
@@ -1621,10 +1621,11 @@ def getini(self, name: str) -> Any:
16211621
:func:`parser.addini <pytest.Parser.addini>` call (usually from a
16221622
plugin), a ValueError is raised.
16231623
"""
1624+
canonical_name = self._parser._ini_aliases.get(name, name)
16241625
try:
1625-
return self._inicache[name]
1626+
return self._inicache[canonical_name]
16261627
except KeyError:
1627-
self._inicache[name] = val = self._getini(name)
1628+
self._inicache[canonical_name] = val = self._getini(canonical_name)
16281629
return val
16291630

16301631
# Meant for easy monkeypatching by legacypath plugin.
@@ -1636,14 +1637,29 @@ def _getini_unknown_type(self, name: str, type: str, value: object):
16361637
raise ValueError(msg) # pragma: no cover
16371638

16381639
def _getini(self, name: str):
1640+
# If this is an alias, resolve to canonical name.
1641+
canonical_name = self._parser._ini_aliases.get(name, name)
1642+
16391643
try:
1640-
_description, type, default = self._parser._inidict[name]
1644+
_description, type, default = self._parser._inidict[canonical_name]
16411645
except KeyError as e:
16421646
raise ValueError(f"unknown configuration value: {name!r}") from e
1643-
try:
1644-
value = self.inicfg[name]
1645-
except KeyError:
1647+
1648+
# Try to get value from inicfg, checking canonical name first, then aliases.
1649+
# Canonical name takes precedence over any aliases.
1650+
value = None
1651+
if canonical_name in self.inicfg:
1652+
value = self.inicfg[canonical_name]
1653+
else:
1654+
# Check if any alias for this canonical name exists in inicfg.
1655+
for alias, target in self._parser._ini_aliases.items():
1656+
if target == canonical_name and alias in self.inicfg:
1657+
value = self.inicfg[alias]
1658+
break
1659+
1660+
if value is None:
16461661
return default
1662+
16471663
# Coerce the values based on types.
16481664
#
16491665
# Note: some coercions are only required if we are reading from .ini files, because

src/_pytest/config/argparsing.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ def __init__(
5252
self._usage = usage
5353
self._inidict: dict[str, tuple[str, str | None, Any]] = {}
5454
self._ininames: list[str] = []
55+
# Maps alias -> canonical name.
56+
self._ini_aliases: dict[str, str] = {}
5557
self.extra_info: dict[str, Any] = {}
5658

5759
def processoption(self, option: Argument) -> None:
@@ -179,6 +181,8 @@ def addini(
179181
]
180182
| None = None,
181183
default: Any = NOT_SET,
184+
*,
185+
aliases: Sequence[str] = (),
182186
) -> None:
183187
"""Register an ini-file option.
184188
@@ -213,6 +217,12 @@ def addini(
213217
Defaults to ``string`` if ``None`` or not passed.
214218
:param default:
215219
Default value if no ini-file option exists but is queried.
220+
:param aliases:
221+
Additional names by which this option can be referenced.
222+
Aliases resolve to the canonical name.
223+
224+
.. versionadded:: 9.0
225+
The ``aliases`` parameter.
216226
217227
The value of ini-variables can be retrieved via a call to
218228
:py:func:`config.getini(name) <pytest.Config.getini>`.
@@ -234,6 +244,13 @@ def addini(
234244
self._inidict[name] = (help, type, default)
235245
self._ininames.append(name)
236246

247+
for alias in aliases:
248+
if alias in self._inidict:
249+
raise ValueError(f"alias {alias!r} conflicts with existing ini option")
250+
if (already := self._ini_aliases.get(alias)) is not None:
251+
raise ValueError(f"{alias!r} is already an alias of {already!r}")
252+
self._ini_aliases[alias] = name
253+
237254

238255
def get_ini_default_for_type(
239256
type: Literal[

testing/test_config.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,6 +1005,143 @@ def pytest_addoption(parser):
10051005
value = config.getini("no_type")
10061006
assert value == ""
10071007

1008+
def test_addini_with_aliases(self, pytester: Pytester) -> None:
1009+
"""Test that ini options can have aliases."""
1010+
pytester.makeconftest(
1011+
"""
1012+
def pytest_addoption(parser):
1013+
parser.addini("new_name", "my option", aliases=["old_name"])
1014+
"""
1015+
)
1016+
pytester.makeini(
1017+
"""
1018+
[pytest]
1019+
old_name = hello
1020+
"""
1021+
)
1022+
config = pytester.parseconfig()
1023+
# Should be able to access via canonical name.
1024+
assert config.getini("new_name") == "hello"
1025+
# Should also be able to access via alias.
1026+
assert config.getini("old_name") == "hello"
1027+
1028+
def test_addini_aliases_with_canonical_in_file(self, pytester: Pytester) -> None:
1029+
"""Test that canonical name takes precedence over alias in ini file."""
1030+
pytester.makeconftest(
1031+
"""
1032+
def pytest_addoption(parser):
1033+
parser.addini("new_name", "my option", aliases=["old_name"])
1034+
"""
1035+
)
1036+
pytester.makeini(
1037+
"""
1038+
[pytest]
1039+
old_name = from_alias
1040+
new_name = from_canonical
1041+
"""
1042+
)
1043+
config = pytester.parseconfig()
1044+
# Canonical name should take precedence.
1045+
assert config.getini("new_name") == "from_canonical"
1046+
assert config.getini("old_name") == "from_canonical"
1047+
1048+
def test_addini_aliases_multiple(self, pytester: Pytester) -> None:
1049+
"""Test that ini option can have multiple aliases."""
1050+
pytester.makeconftest(
1051+
"""
1052+
def pytest_addoption(parser):
1053+
parser.addini("current_name", "my option", aliases=["old_name", "legacy_name"])
1054+
"""
1055+
)
1056+
pytester.makeini(
1057+
"""
1058+
[pytest]
1059+
old_name = value1
1060+
"""
1061+
)
1062+
config = pytester.parseconfig()
1063+
assert config.getini("current_name") == "value1"
1064+
assert config.getini("old_name") == "value1"
1065+
assert config.getini("legacy_name") == "value1"
1066+
1067+
def test_addini_aliases_with_override(self, pytester: Pytester) -> None:
1068+
"""Test that aliases work with --override-ini."""
1069+
pytester.makeconftest(
1070+
"""
1071+
def pytest_addoption(parser):
1072+
parser.addini("new_name", "my option", aliases=["old_name"])
1073+
"""
1074+
)
1075+
pytester.makeini(
1076+
"""
1077+
[pytest]
1078+
old_name = from_file
1079+
"""
1080+
)
1081+
# Override using alias.
1082+
config = pytester.parseconfig("-o", "old_name=overridden")
1083+
assert config.getini("new_name") == "overridden"
1084+
assert config.getini("old_name") == "overridden"
1085+
1086+
# Override using canonical name.
1087+
config = pytester.parseconfig("-o", "new_name=overridden2")
1088+
assert config.getini("new_name") == "overridden2"
1089+
1090+
def test_addini_aliases_with_types(self, pytester: Pytester) -> None:
1091+
"""Test that aliases work with different types."""
1092+
pytester.makeconftest(
1093+
"""
1094+
def pytest_addoption(parser):
1095+
parser.addini("mylist", "list option", type="linelist", aliases=["oldlist"])
1096+
parser.addini("mybool", "bool option", type="bool", aliases=["oldbool"])
1097+
"""
1098+
)
1099+
pytester.makeini(
1100+
"""
1101+
[pytest]
1102+
oldlist = line1
1103+
line2
1104+
oldbool = true
1105+
"""
1106+
)
1107+
config = pytester.parseconfig()
1108+
assert config.getini("mylist") == ["line1", "line2"]
1109+
assert config.getini("oldlist") == ["line1", "line2"]
1110+
assert config.getini("mybool") is True
1111+
assert config.getini("oldbool") is True
1112+
1113+
def test_addini_aliases_conflict_error(self, pytester: Pytester) -> None:
1114+
"""Test that registering an alias that conflicts with an existing option raises an error."""
1115+
pytester.makeconftest(
1116+
"""
1117+
def pytest_addoption(parser):
1118+
parser.addini("existing", "first option")
1119+
1120+
try:
1121+
parser.addini("new_option", "second option", aliases=["existing"])
1122+
except ValueError as e:
1123+
assert "alias 'existing' conflicts with existing ini option" in str(e)
1124+
else:
1125+
assert False, "Should have raised ValueError"
1126+
"""
1127+
)
1128+
pytester.parseconfig()
1129+
1130+
def test_addini_aliases_duplicate_error(self, pytester: Pytester) -> None:
1131+
"""Test that registering the same alias twice raises an error."""
1132+
pytester.makeconftest(
1133+
"""
1134+
def pytest_addoption(parser):
1135+
parser.addini("option1", "first option", aliases=["shared_alias"])
1136+
try:
1137+
parser.addini("option2", "second option", aliases=["shared_alias"])
1138+
raise AssertionError("Should have raised ValueError")
1139+
except ValueError as e:
1140+
assert "'shared_alias' is already an alias of 'option1'" in str(e)
1141+
"""
1142+
)
1143+
pytester.parseconfig()
1144+
10081145
@pytest.mark.parametrize(
10091146
"type, expected",
10101147
[

0 commit comments

Comments
 (0)