| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350 |
- 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
- import iniconfig
- from .exceptions import UsageError
- from _pytest.outcomes import fail
- 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]
- def _parse_ini_config(path: Path) -> iniconfig.IniConfig:
- """Parse the given generic '.ini' file using legacy IniConfig parser, returning
- the parsed object.
- Raise UsageError if the file cannot be parsed.
- """
- try:
- return iniconfig.IniConfig(str(path))
- except iniconfig.ParseError as exc:
- raise UsageError(str(exc)) from exc
- def load_config_dict_from_file(
- filepath: Path,
- ) -> ConfigDict | None:
- """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()
- }
- else:
- # "pytest.ini" files are always the source of configuration, even if empty.
- if filepath.name in {"pytest.ini", ".pytest.ini"}:
- return {}
- # '.cfg' files are considered if they contain a "[tool:pytest]" section.
- elif filepath.suffix == ".cfg":
- 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()
- }
- 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.
- elif filepath.suffix == ".toml":
- if sys.version_info >= (3, 11):
- import tomllib
- else:
- import tomli as tomllib
- toml_text = filepath.read_text(encoding="utf-8")
- try:
- config = tomllib.loads(toml_text)
- 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 {}
- # 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 None
- def locate_config(
- invocation_dir: Path,
- args: Iterable[Path],
- ) -> tuple[Path | None, Path | None, ConfigDict, Sequence[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."""
- config_names = [
- "pytest.toml",
- ".pytest.toml",
- "pytest.ini",
- ".pytest.ini",
- "pyproject.toml",
- "tox.ini",
- "setup.cfg",
- ]
- 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] = []
- 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, {}, []
- def get_common_ancestor(
- invocation_dir: Path,
- paths: Iterable[Path],
- ) -> Path:
- common_ancestor: Path | None = None
- for path in paths:
- if not path.exists():
- continue
- if common_ancestor is None:
- common_ancestor = path
- else:
- if common_ancestor in path.parents or path == common_ancestor:
- continue
- elif path in common_ancestor.parents:
- common_ancestor = path
- else:
- shared = commonpath(path, common_ancestor)
- if shared is not None:
- common_ancestor = shared
- if common_ancestor is None:
- common_ancestor = invocation_dir
- elif common_ancestor.is_file():
- common_ancestor = common_ancestor.parent
- return common_ancestor
- def get_dirs_from_args(args: Iterable[str]) -> list[Path]:
- def is_option(x: str) -> bool:
- return x.startswith("-")
- def get_file_part_from_node_id(x: str) -> str:
- return x.split("::")[0]
- def get_dir_from_path(path: Path) -> Path:
- if path.is_dir():
- return path
- return path.parent
- # These look like paths but may not exist
- possible_paths = (
- absolutepath(get_file_part_from_node_id(arg))
- for arg in args
- if not is_option(arg)
- )
- 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,
- 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 = None
- dirs = get_dirs_from_args(args)
- ignored_config_files: Sequence[str] = []
- if inifile:
- inipath_ = absolutepath(inifile)
- inipath: Path | None = 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]
- )
- if rootdir is None and rootdir_cmd_arg is None:
- for possible_rootdir in (ancestor, *ancestor.parents):
- if (possible_rootdir / "setup.py").is_file():
- rootdir = possible_rootdir
- break
- else:
- if dirs != [ancestor]:
- rootdir, inipath, inicfg, _ = locate_config(invocation_dir, dirs)
- if rootdir is None:
- rootdir = get_common_ancestor(
- invocation_dir, [invocation_dir, 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."
- )
- ini_overrides = parse_override_ini(override_ini)
- inicfg.update(ini_overrides)
- assert rootdir is not None
- return rootdir, inipath, inicfg, ignored_config_files
- def is_fs_root(p: Path) -> bool:
- r"""
- Return True if the given path is pointing to the root of the
- file system ("/" on Unix and "C:\\" on Windows for example).
- """
- return os.path.splitdrive(str(p))[1] == os.sep
|