Skip to content

Commit 355ff09

Browse files
committed
config: add support for ini option aliases
Fix #13829.
1 parent 0877d57 commit 355ff09

File tree

6 files changed

+253
-33
lines changed

6 files changed

+253
-33
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: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1461,7 +1461,8 @@ def pytest_collection(self) -> Generator[None, object, object]:
14611461
def _checkversion(self) -> None:
14621462
import pytest
14631463

1464-
minver = self.inicfg.get("minversion", None)
1464+
minver_ini_value = self.inicfg.get("minversion", None)
1465+
minver = minver_ini_value.value if minver_ini_value is not None else None
14651466
if minver:
14661467
# Imported lazily to improve start-up time.
14671468
from packaging.version import Version
@@ -1519,9 +1520,9 @@ def _warn_or_fail_if_strict(self, message: str) -> None:
15191520

15201521
self.issue_config_time_warning(PytestConfigWarning(message), stacklevel=3)
15211522

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]
1523+
def _get_unknown_ini_keys(self) -> set[str]:
1524+
known_keys = self._parser._inidict.keys() | self._parser._ini_aliases.keys()
1525+
return self.inicfg.keys() - known_keys
15251526

15261527
def parse(self, args: list[str], addopts: bool = True) -> None:
15271528
# Parse given cmdline arguments into this config object.
@@ -1621,10 +1622,11 @@ def getini(self, name: str) -> Any:
16211622
:func:`parser.addini <pytest.Parser.addini>` call (usually from a
16221623
plugin), a ValueError is raised.
16231624
"""
1625+
canonical_name = self._parser._ini_aliases.get(name, name)
16241626
try:
1625-
return self._inicache[name]
1627+
return self._inicache[canonical_name]
16261628
except KeyError:
1627-
self._inicache[name] = val = self._getini(name)
1629+
self._inicache[canonical_name] = val = self._getini(canonical_name)
16281630
return val
16291631

16301632
# Meant for easy monkeypatching by legacypath plugin.
@@ -1636,14 +1638,32 @@ def _getini_unknown_type(self, name: str, type: str, value: object):
16361638
raise ValueError(msg) # pragma: no cover
16371639

16381640
def _getini(self, name: str):
1641+
# If this is an alias, resolve to canonical name.
1642+
canonical_name = self._parser._ini_aliases.get(name, name)
1643+
16391644
try:
1640-
_description, type, default = self._parser._inidict[name]
1645+
_description, type, default = self._parser._inidict[canonical_name]
16411646
except KeyError as e:
16421647
raise ValueError(f"unknown configuration value: {name!r}") from e
1643-
try:
1644-
value = self.inicfg[name]
1645-
except KeyError:
1648+
1649+
# Collect all possible values (canonical name + aliases) from inicfg.
1650+
# Each candidate is (IniValue, is_canonical).
1651+
candidates = []
1652+
if canonical_name in self.inicfg:
1653+
candidates.append((self.inicfg[canonical_name], True))
1654+
for alias, target in self._parser._ini_aliases.items():
1655+
if target == canonical_name and alias in self.inicfg:
1656+
candidates.append((self.inicfg[alias], False))
1657+
1658+
if not candidates:
16461659
return default
1660+
1661+
# Pick the best candidate based on precedence:
1662+
# 1. CLI override takes precedence over file, then
1663+
# 2. Canonical name takes precedence over alias.
1664+
ini_value = max(candidates, key=lambda x: (x[0].origin == "override", x[1]))[0]
1665+
value = ini_value.value
1666+
16471667
# Coerce the values based on types.
16481668
#
16491669
# 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[

src/_pytest/config/findpaths.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
from collections.abc import Iterable
44
from collections.abc import Sequence
5+
from dataclasses import dataclass
56
import os
67
from pathlib import Path
78
import sys
9+
from typing import Literal
810
from typing import TypeAlias
911

1012
import iniconfig
@@ -16,9 +18,23 @@
1618
from _pytest.pathlib import safe_exists
1719

1820

19-
# Even though TOML supports richer data types, all values are converted to str/list[str] during
20-
# parsing to maintain compatibility with the rest of the configuration system.
21-
ConfigDict: TypeAlias = dict[str, str | list[str]]
21+
@dataclass(frozen=True)
22+
class IniValue:
23+
"""Represents an ini configuration value with its origin.
24+
25+
This allows tracking whether a value came from a configuration file
26+
or from a CLI override (--override-ini), which is important for
27+
determining precedence when dealing with ini option aliases.
28+
"""
29+
30+
# Even though TOML supports richer data types, all values are converted to
31+
# str/list[str] during parsing to maintain compatibility with the rest of
32+
# the configuration system.
33+
value: str | list[str]
34+
origin: Literal["file", "override"]
35+
36+
37+
ConfigDict: TypeAlias = dict[str, IniValue]
2238

2339

2440
def _parse_ini_config(path: Path) -> iniconfig.IniConfig:
@@ -45,7 +61,7 @@ def load_config_dict_from_file(
4561
iniconfig = _parse_ini_config(filepath)
4662

4763
if "pytest" in iniconfig:
48-
return dict(iniconfig["pytest"].items())
64+
return {k: IniValue(v, "file") for k, v in iniconfig["pytest"].items()}
4965
else:
5066
# "pytest.ini" files are always the source of configuration, even if empty.
5167
if filepath.name == "pytest.ini":
@@ -56,7 +72,7 @@ def load_config_dict_from_file(
5672
iniconfig = _parse_ini_config(filepath)
5773

5874
if "tool:pytest" in iniconfig.sections:
59-
return dict(iniconfig["tool:pytest"].items())
75+
return {k: IniValue(v, "file") for k, v in iniconfig["tool:pytest"].items()}
6076
elif "pytest" in iniconfig.sections:
6177
# If a setup.cfg contains a "[pytest]" section, we raise a failure to indicate users that
6278
# plain "[pytest]" sections in setup.cfg files is no longer supported (#3086).
@@ -83,7 +99,7 @@ def load_config_dict_from_file(
8399
def make_scalar(v: object) -> str | list[str]:
84100
return v if isinstance(v, list) else str(v)
85101

86-
return {k: make_scalar(v) for k, v in result.items()}
102+
return {k: IniValue(make_scalar(v), "file") for k, v in result.items()}
87103

88104
return None
89105

@@ -181,7 +197,7 @@ def get_dir_from_path(path: Path) -> Path:
181197
return [get_dir_from_path(path) for path in possible_paths if safe_exists(path)]
182198

183199

184-
def parse_override_ini(override_ini: Sequence[str] | None) -> dict[str, str]:
200+
def parse_override_ini(override_ini: Sequence[str] | None) -> ConfigDict:
185201
"""Parse the -o/--override-ini command line arguments and return the overrides.
186202
187203
:raises UsageError:
@@ -199,7 +215,7 @@ def parse_override_ini(override_ini: Sequence[str] | None) -> dict[str, str]:
199215
f"-o/--override-ini expects option=value style (got: {ini_config!r})."
200216
) from e
201217
else:
202-
overrides[key] = user_ini_value
218+
overrides[key] = IniValue(user_ini_value, "override")
203219
return overrides
204220

205221

0 commit comments

Comments
 (0)