updates
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import KW_ONLY
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Literal
|
||||
from typing import TypeAlias
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
from typing import Iterable
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Sequence
|
||||
from typing import Tuple
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Union
|
||||
|
||||
import iniconfig
|
||||
|
||||
@@ -18,29 +18,8 @@ from _pytest.pathlib import absolutepath
|
||||
from _pytest.pathlib import commonpath
|
||||
from _pytest.pathlib import safe_exists
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConfigValue:
|
||||
"""Represents a configuration value with its origin and parsing mode.
|
||||
|
||||
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.
|
||||
|
||||
The mode tracks the parsing mode/data model used for the value:
|
||||
- "ini": from INI files or [tool.pytest.ini_options], where the only
|
||||
supported value types are `str` or `list[str]`.
|
||||
- "toml": from TOML files (not in INI mode), where native TOML types
|
||||
are preserved.
|
||||
"""
|
||||
|
||||
value: object
|
||||
_: KW_ONLY
|
||||
origin: Literal["file", "override"]
|
||||
mode: Literal["ini", "toml"]
|
||||
|
||||
|
||||
ConfigDict: TypeAlias = dict[str, ConfigValue]
|
||||
if TYPE_CHECKING:
|
||||
from . import Config
|
||||
|
||||
|
||||
def _parse_ini_config(path: Path) -> iniconfig.IniConfig:
|
||||
@@ -57,23 +36,21 @@ def _parse_ini_config(path: Path) -> iniconfig.IniConfig:
|
||||
|
||||
def load_config_dict_from_file(
|
||||
filepath: Path,
|
||||
) -> ConfigDict | None:
|
||||
) -> Optional[Dict[str, Union[str, List[str]]]]:
|
||||
"""Load pytest configuration from the given file path, if supported.
|
||||
|
||||
Return None if the file does not contain valid pytest configuration.
|
||||
"""
|
||||
|
||||
# Configuration from ini files are obtained from the [pytest] section, if present.
|
||||
if filepath.suffix == ".ini":
|
||||
iniconfig = _parse_ini_config(filepath)
|
||||
|
||||
if "pytest" in iniconfig:
|
||||
return {
|
||||
k: ConfigValue(v, origin="file", mode="ini")
|
||||
for k, v in iniconfig["pytest"].items()
|
||||
}
|
||||
return dict(iniconfig["pytest"].items())
|
||||
else:
|
||||
# "pytest.ini" files are always the source of configuration, even if empty.
|
||||
if filepath.name in {"pytest.ini", ".pytest.ini"}:
|
||||
if filepath.name == "pytest.ini":
|
||||
return {}
|
||||
|
||||
# '.cfg' files are considered if they contain a "[tool:pytest]" section.
|
||||
@@ -81,18 +58,13 @@ def load_config_dict_from_file(
|
||||
iniconfig = _parse_ini_config(filepath)
|
||||
|
||||
if "tool:pytest" in iniconfig.sections:
|
||||
return {
|
||||
k: ConfigValue(v, origin="file", mode="ini")
|
||||
for k, v in iniconfig["tool:pytest"].items()
|
||||
}
|
||||
return dict(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).
|
||||
fail(CFG_PYTEST_SECTION.format(filename="setup.cfg"), pytrace=False)
|
||||
|
||||
# '.toml' files are considered if they contain a [tool.pytest] table (toml mode)
|
||||
# or [tool.pytest.ini_options] table (ini mode) for pyproject.toml,
|
||||
# or [pytest] table (toml mode) for pytest.toml/.pytest.toml.
|
||||
# '.toml' files are considered if they contain a [tool.pytest.ini_options] table.
|
||||
elif filepath.suffix == ".toml":
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
@@ -105,67 +77,25 @@ def load_config_dict_from_file(
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
raise UsageError(f"{filepath}: {exc}") from exc
|
||||
|
||||
# pytest.toml and .pytest.toml use [pytest] table directly.
|
||||
if filepath.name in ("pytest.toml", ".pytest.toml"):
|
||||
pytest_config = config.get("pytest", {})
|
||||
if pytest_config:
|
||||
# TOML mode - preserve native TOML types.
|
||||
return {
|
||||
k: ConfigValue(v, origin="file", mode="toml")
|
||||
for k, v in pytest_config.items()
|
||||
}
|
||||
# "pytest.toml" files are always the source of configuration, even if empty.
|
||||
return {}
|
||||
result = config.get("tool", {}).get("pytest", {}).get("ini_options", None)
|
||||
if result is not None:
|
||||
# TOML supports richer data types than ini files (strings, arrays, floats, ints, etc),
|
||||
# however we need to convert all scalar values to str for compatibility with the rest
|
||||
# of the configuration system, which expects strings only.
|
||||
def make_scalar(v: object) -> Union[str, List[str]]:
|
||||
return v if isinstance(v, list) else str(v)
|
||||
|
||||
# pyproject.toml uses [tool.pytest] or [tool.pytest.ini_options].
|
||||
else:
|
||||
tool_pytest = config.get("tool", {}).get("pytest", {})
|
||||
|
||||
# Check for toml mode config: [tool.pytest] with content outside of ini_options.
|
||||
toml_config = {k: v for k, v in tool_pytest.items() if k != "ini_options"}
|
||||
# Check for ini mode config: [tool.pytest.ini_options].
|
||||
ini_config = tool_pytest.get("ini_options", None)
|
||||
|
||||
if toml_config and ini_config:
|
||||
raise UsageError(
|
||||
f"{filepath}: Cannot use both [tool.pytest] (native TOML types) and "
|
||||
"[tool.pytest.ini_options] (string-based INI format) simultaneously. "
|
||||
"Please use [tool.pytest] with native TOML types (recommended) "
|
||||
"or [tool.pytest.ini_options] for backwards compatibility."
|
||||
)
|
||||
|
||||
if toml_config:
|
||||
# TOML mode - preserve native TOML types.
|
||||
return {
|
||||
k: ConfigValue(v, origin="file", mode="toml")
|
||||
for k, v in toml_config.items()
|
||||
}
|
||||
|
||||
elif ini_config is not None:
|
||||
# INI mode - TOML supports richer data types than INI files, but we need to
|
||||
# convert all scalar values to str for compatibility with the INI system.
|
||||
def make_scalar(v: object) -> str | list[str]:
|
||||
return v if isinstance(v, list) else str(v)
|
||||
|
||||
return {
|
||||
k: ConfigValue(make_scalar(v), origin="file", mode="ini")
|
||||
for k, v in ini_config.items()
|
||||
}
|
||||
return {k: make_scalar(v) for k, v in result.items()}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def locate_config(
|
||||
invocation_dir: Path,
|
||||
args: Iterable[Path],
|
||||
) -> tuple[Path | None, Path | None, ConfigDict, Sequence[str]]:
|
||||
) -> Tuple[Optional[Path], Optional[Path], Dict[str, Union[str, List[str]]]]:
|
||||
"""Search in the list of arguments for a valid ini-file for pytest,
|
||||
and return a tuple of (rootdir, inifile, cfg-dict, ignored-config-files), where
|
||||
ignored-config-files is a list of config basenames found that contain
|
||||
pytest configuration but were ignored."""
|
||||
and return a tuple of (rootdir, inifile, cfg-dict)."""
|
||||
config_names = [
|
||||
"pytest.toml",
|
||||
".pytest.toml",
|
||||
"pytest.ini",
|
||||
".pytest.ini",
|
||||
"pyproject.toml",
|
||||
@@ -174,39 +104,21 @@ def locate_config(
|
||||
]
|
||||
args = [x for x in args if not str(x).startswith("-")]
|
||||
if not args:
|
||||
args = [invocation_dir]
|
||||
found_pyproject_toml: Path | None = None
|
||||
ignored_config_files: list[str] = []
|
||||
|
||||
args = [Path.cwd()]
|
||||
for arg in args:
|
||||
argpath = absolutepath(arg)
|
||||
for base in (argpath, *argpath.parents):
|
||||
for config_name in config_names:
|
||||
p = base / config_name
|
||||
if p.is_file():
|
||||
if p.name == "pyproject.toml" and found_pyproject_toml is None:
|
||||
found_pyproject_toml = p
|
||||
ini_config = load_config_dict_from_file(p)
|
||||
if ini_config is not None:
|
||||
index = config_names.index(config_name)
|
||||
for remainder in config_names[index + 1 :]:
|
||||
p2 = base / remainder
|
||||
if (
|
||||
p2.is_file()
|
||||
and load_config_dict_from_file(p2) is not None
|
||||
):
|
||||
ignored_config_files.append(remainder)
|
||||
return base, p, ini_config, ignored_config_files
|
||||
if found_pyproject_toml is not None:
|
||||
return found_pyproject_toml.parent, found_pyproject_toml, {}, []
|
||||
return None, None, {}, []
|
||||
return base, p, ini_config
|
||||
return None, None, {}
|
||||
|
||||
|
||||
def get_common_ancestor(
|
||||
invocation_dir: Path,
|
||||
paths: Iterable[Path],
|
||||
) -> Path:
|
||||
common_ancestor: Path | None = None
|
||||
def get_common_ancestor(paths: Iterable[Path]) -> Path:
|
||||
common_ancestor: Optional[Path] = None
|
||||
for path in paths:
|
||||
if not path.exists():
|
||||
continue
|
||||
@@ -222,13 +134,13 @@ def get_common_ancestor(
|
||||
if shared is not None:
|
||||
common_ancestor = shared
|
||||
if common_ancestor is None:
|
||||
common_ancestor = invocation_dir
|
||||
common_ancestor = Path.cwd()
|
||||
elif common_ancestor.is_file():
|
||||
common_ancestor = common_ancestor.parent
|
||||
return common_ancestor
|
||||
|
||||
|
||||
def get_dirs_from_args(args: Iterable[str]) -> list[Path]:
|
||||
def get_dirs_from_args(args: Iterable[str]) -> List[Path]:
|
||||
def is_option(x: str) -> bool:
|
||||
return x.startswith("-")
|
||||
|
||||
@@ -250,70 +162,26 @@ def get_dirs_from_args(args: Iterable[str]) -> list[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) -> ConfigDict:
|
||||
"""Parse the -o/--override-ini command line arguments and return the overrides.
|
||||
|
||||
:raises UsageError:
|
||||
If one of the values is malformed.
|
||||
"""
|
||||
overrides = {}
|
||||
# override_ini is a list of "ini=value" options.
|
||||
# Always use the last item if multiple values are set for same ini-name,
|
||||
# e.g. -o foo=bar1 -o foo=bar2 will set foo to bar2.
|
||||
for ini_config in override_ini or ():
|
||||
try:
|
||||
key, user_ini_value = ini_config.split("=", 1)
|
||||
except ValueError as e:
|
||||
raise UsageError(
|
||||
f"-o/--override-ini expects option=value style (got: {ini_config!r})."
|
||||
) from e
|
||||
else:
|
||||
overrides[key] = ConfigValue(user_ini_value, origin="override", mode="ini")
|
||||
return overrides
|
||||
|
||||
|
||||
CFG_PYTEST_SECTION = "[pytest] section in {filename} files is no longer supported, change to [tool:pytest] instead."
|
||||
|
||||
|
||||
def determine_setup(
|
||||
*,
|
||||
inifile: str | None,
|
||||
override_ini: Sequence[str] | None,
|
||||
inifile: Optional[str],
|
||||
args: Sequence[str],
|
||||
rootdir_cmd_arg: str | None,
|
||||
invocation_dir: Path,
|
||||
) -> tuple[Path, Path | None, ConfigDict, Sequence[str]]:
|
||||
"""Determine the rootdir, inifile and ini configuration values from the
|
||||
command line arguments.
|
||||
|
||||
:param inifile:
|
||||
The `--inifile` command line argument, if given.
|
||||
:param override_ini:
|
||||
The -o/--override-ini command line arguments, if given.
|
||||
:param args:
|
||||
The free command line arguments.
|
||||
:param rootdir_cmd_arg:
|
||||
The `--rootdir` command line argument, if given.
|
||||
:param invocation_dir:
|
||||
The working directory when pytest was invoked.
|
||||
|
||||
:raises UsageError:
|
||||
"""
|
||||
rootdir_cmd_arg: Optional[str] = None,
|
||||
config: Optional["Config"] = None,
|
||||
) -> Tuple[Path, Optional[Path], Dict[str, Union[str, List[str]]]]:
|
||||
rootdir = None
|
||||
dirs = get_dirs_from_args(args)
|
||||
ignored_config_files: Sequence[str] = []
|
||||
|
||||
if inifile:
|
||||
inipath_ = absolutepath(inifile)
|
||||
inipath: Path | None = inipath_
|
||||
inipath: Optional[Path] = inipath_
|
||||
inicfg = load_config_dict_from_file(inipath_) or {}
|
||||
if rootdir_cmd_arg is None:
|
||||
rootdir = inipath_.parent
|
||||
else:
|
||||
ancestor = get_common_ancestor(invocation_dir, dirs)
|
||||
rootdir, inipath, inicfg, ignored_config_files = locate_config(
|
||||
invocation_dir, [ancestor]
|
||||
)
|
||||
ancestor = get_common_ancestor(dirs)
|
||||
rootdir, inipath, inicfg = locate_config([ancestor])
|
||||
if rootdir is None and rootdir_cmd_arg is None:
|
||||
for possible_rootdir in (ancestor, *ancestor.parents):
|
||||
if (possible_rootdir / "setup.py").is_file():
|
||||
@@ -321,25 +189,25 @@ def determine_setup(
|
||||
break
|
||||
else:
|
||||
if dirs != [ancestor]:
|
||||
rootdir, inipath, inicfg, _ = locate_config(invocation_dir, dirs)
|
||||
rootdir, inipath, inicfg = locate_config(dirs)
|
||||
if rootdir is None:
|
||||
rootdir = get_common_ancestor(
|
||||
invocation_dir, [invocation_dir, ancestor]
|
||||
)
|
||||
if config is not None:
|
||||
cwd = config.invocation_params.dir
|
||||
else:
|
||||
cwd = Path.cwd()
|
||||
rootdir = get_common_ancestor([cwd, ancestor])
|
||||
if is_fs_root(rootdir):
|
||||
rootdir = ancestor
|
||||
if rootdir_cmd_arg:
|
||||
rootdir = absolutepath(os.path.expandvars(rootdir_cmd_arg))
|
||||
if not rootdir.is_dir():
|
||||
raise UsageError(
|
||||
f"Directory '{rootdir}' not found. Check your '--rootdir' option."
|
||||
"Directory '{}' not found. Check your '--rootdir' option.".format(
|
||||
rootdir
|
||||
)
|
||||
)
|
||||
|
||||
ini_overrides = parse_override_ini(override_ini)
|
||||
inicfg.update(ini_overrides)
|
||||
|
||||
assert rootdir is not None
|
||||
return rootdir, inipath, inicfg, ignored_config_files
|
||||
return rootdir, inipath, inicfg or {}
|
||||
|
||||
|
||||
def is_fs_root(p: Path) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user