Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions changelog/13829.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Added support for ini option aliases via the ``aliases`` parameter in :meth:`Parser.addini() <pytest.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.
40 changes: 30 additions & 10 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1621,10 +1622,11 @@ def getini(self, name: str) -> Any:
:func:`parser.addini <pytest.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.
Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src/_pytest/config/argparsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -179,6 +181,8 @@ def addini(
]
| None = None,
default: Any = NOT_SET,
*,
aliases: Sequence[str] = (),
) -> None:
"""Register an ini-file option.

Expand Down Expand Up @@ -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) <pytest.Config.getini>`.
Expand All @@ -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[
Expand Down
32 changes: 24 additions & 8 deletions src/_pytest/config/findpaths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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":
Expand All @@ -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).
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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


Expand Down
Loading