This commit is contained in:
Iliyan Angelov
2025-12-06 03:27:35 +02:00
parent 7667eb5eda
commit 5a8ca3c475
2211 changed files with 28086 additions and 37066 deletions

View File

@@ -1,83 +1,69 @@
# mypy: allow-untyped-defs
from __future__ import annotations
import argparse
from collections.abc import Callable
from collections.abc import Mapping
from collections.abc import Sequence
import os
import sys
import warnings
from gettext import gettext
from typing import Any
from typing import final
from typing import Literal
from typing import Callable
from typing import cast
from typing import Dict
from typing import List
from typing import Mapping
from typing import NoReturn
from typing import Optional
from typing import Sequence
from typing import Tuple
from typing import TYPE_CHECKING
from typing import Union
from .exceptions import UsageError
import _pytest._io
from _pytest.compat import final
from _pytest.config.exceptions import UsageError
from _pytest.deprecated import ARGUMENT_PERCENT_DEFAULT
from _pytest.deprecated import ARGUMENT_TYPE_STR
from _pytest.deprecated import ARGUMENT_TYPE_STR_CHOICE
from _pytest.deprecated import check_ispytest
if TYPE_CHECKING:
from typing_extensions import Literal
FILE_OR_DIR = "file_or_dir"
class NotSet:
def __repr__(self) -> str:
return "<notset>"
NOT_SET = NotSet()
@final
class Parser:
"""Parser for command line arguments and config-file values.
"""Parser for command line arguments and ini-file values.
:ivar extra_info: Dict of generic param -> value to display in case
there's an error processing the command line arguments.
"""
prog: Optional[str] = None
def __init__(
self,
usage: str | None = None,
processopt: Callable[[Argument], None] | None = None,
usage: Optional[str] = None,
processopt: Optional[Callable[["Argument"], None]] = None,
*,
_ispytest: bool = False,
) -> None:
check_ispytest(_ispytest)
from _pytest._argcomplete import filescompleter
self._anonymous = OptionGroup("Custom options", parser=self, _ispytest=True)
self._groups: List[OptionGroup] = []
self._processopt = processopt
self.extra_info: dict[str, Any] = {}
self.optparser = PytestArgumentParser(self, usage, self.extra_info)
anonymous_arggroup = self.optparser.add_argument_group("Custom options")
self._anonymous = OptionGroup(
anonymous_arggroup, "_anonymous", self, _ispytest=True
)
self._groups = [self._anonymous]
file_or_dir_arg = self.optparser.add_argument(FILE_OR_DIR, nargs="*")
file_or_dir_arg.completer = filescompleter # type: ignore
self._usage = usage
self._inidict: Dict[str, Tuple[str, Optional[str], Any]] = {}
self._ininames: List[str] = []
self.extra_info: Dict[str, Any] = {}
self._inidict: dict[str, tuple[str, str, Any]] = {}
# Maps alias -> canonical name.
self._ini_aliases: dict[str, str] = {}
@property
def prog(self) -> str:
return self.optparser.prog
@prog.setter
def prog(self, value: str) -> None:
self.optparser.prog = value
def processoption(self, option: Argument) -> None:
def processoption(self, option: "Argument") -> None:
if self._processopt:
if option.dest:
self._processopt(option)
def getgroup(
self, name: str, description: str = "", after: str | None = None
) -> OptionGroup:
self, name: str, description: str = "", after: Optional[str] = None
) -> "OptionGroup":
"""Get (or create) a named option Group.
:param name: Name of the option group.
@@ -93,17 +79,12 @@ class Parser:
for group in self._groups:
if group.name == name:
return group
arggroup = self.optparser.add_argument_group(description or name)
group = OptionGroup(arggroup, name, self, _ispytest=True)
group = OptionGroup(name, description, parser=self, _ispytest=True)
i = 0
for i, grp in enumerate(self._groups):
if grp.name == after:
break
self._groups.insert(i + 1, group)
# argparse doesn't provide a way to control `--help` order, so must
# access its internals ☹.
self.optparser._action_groups.insert(i + 1, self.optparser._action_groups.pop())
return group
def addoption(self, *opts: str, **attrs: Any) -> None:
@@ -112,7 +93,7 @@ class Parser:
:param opts:
Option names, can be short or long options.
:param attrs:
Same attributes as the argparse library's :meth:`add_argument()
Same attributes as the argparse library's :py:func:`add_argument()
<argparse.ArgumentParser.add_argument>` function accepts.
After command line parsing, options are available on the pytest config
@@ -124,32 +105,50 @@ class Parser:
def parse(
self,
args: Sequence[str | os.PathLike[str]],
namespace: argparse.Namespace | None = None,
args: Sequence[Union[str, "os.PathLike[str]"]],
namespace: Optional[argparse.Namespace] = None,
) -> argparse.Namespace:
"""Parse the arguments.
Unlike ``parse_known_args`` and ``parse_known_and_unknown_args``,
raises PrintHelp on `--help` and UsageError on unknown flags
:meta private:
"""
from _pytest._argcomplete import try_argcomplete
self.optparser = self._getparser()
try_argcomplete(self.optparser)
strargs = [os.fspath(x) for x in args]
if namespace is None:
namespace = argparse.Namespace()
try:
namespace._raise_print_help = True
return self.optparser.parse_intermixed_args(strargs, namespace=namespace)
finally:
del namespace._raise_print_help
return self.optparser.parse_args(strargs, namespace=namespace)
def _getparser(self) -> "MyOptionParser":
from _pytest._argcomplete import filescompleter
optparser = MyOptionParser(self, self.extra_info, prog=self.prog)
groups = self._groups + [self._anonymous]
for group in groups:
if group.options:
desc = group.description or group.name
arggroup = optparser.add_argument_group(desc)
for option in group.options:
n = option.names()
a = option.attrs()
arggroup.add_argument(*n, **a)
file_or_dir_arg = optparser.add_argument(FILE_OR_DIR, nargs="*")
# bash like autocompletion for dirs (appending '/')
# Type ignored because typeshed doesn't know about argcomplete.
file_or_dir_arg.completer = filescompleter # type: ignore
return optparser
def parse_setoption(
self,
args: Sequence[Union[str, "os.PathLike[str]"]],
option: argparse.Namespace,
namespace: Optional[argparse.Namespace] = None,
) -> List[str]:
parsedoption = self.parse(args, namespace=namespace)
for name, value in parsedoption.__dict__.items():
setattr(option, name, value)
return cast(List[str], getattr(parsedoption, FILE_OR_DIR))
def parse_known_args(
self,
args: Sequence[str | os.PathLike[str]],
namespace: argparse.Namespace | None = None,
args: Sequence[Union[str, "os.PathLike[str]"]],
namespace: Optional[argparse.Namespace] = None,
) -> argparse.Namespace:
"""Parse the known arguments at this point.
@@ -159,47 +158,35 @@ class Parser:
def parse_known_and_unknown_args(
self,
args: Sequence[str | os.PathLike[str]],
namespace: argparse.Namespace | None = None,
) -> tuple[argparse.Namespace, list[str]]:
args: Sequence[Union[str, "os.PathLike[str]"]],
namespace: Optional[argparse.Namespace] = None,
) -> Tuple[argparse.Namespace, List[str]]:
"""Parse the known arguments at this point, and also return the
remaining unknown flag arguments.
remaining unknown arguments.
:returns:
A tuple containing an argparse namespace object for the known
arguments, and a list of unknown flag arguments.
arguments, and a list of the unknown arguments.
"""
optparser = self._getparser()
strargs = [os.fspath(x) for x in args]
if sys.version_info < (3, 12, 8) or (3, 13) <= sys.version_info < (3, 13, 1):
# Older argparse have a bugged parse_known_intermixed_args.
namespace, unknown = self.optparser.parse_known_args(strargs, namespace)
assert namespace is not None
file_or_dir = getattr(namespace, FILE_OR_DIR)
unknown_flags: list[str] = []
for arg in unknown:
(unknown_flags if arg.startswith("-") else file_or_dir).append(arg)
return namespace, unknown_flags
else:
return self.optparser.parse_known_intermixed_args(strargs, namespace)
return optparser.parse_known_args(strargs, namespace=namespace)
def addini(
self,
name: str,
help: str,
type: Literal[
"string", "paths", "pathlist", "args", "linelist", "bool", "int", "float"
]
| None = None,
default: Any = NOT_SET,
*,
aliases: Sequence[str] = (),
type: Optional[
"Literal['string', 'paths', 'pathlist', 'args', 'linelist', 'bool']"
] = None,
default: Any = None,
) -> None:
"""Register a configuration file option.
"""Register an ini-file option.
:param name:
Name of the configuration.
Name of the ini-variable.
:param type:
Type of the configuration. Can be:
Type of the variable. Can be:
* ``string``: a string
* ``bool``: a boolean
@@ -207,90 +194,27 @@ class Parser:
* ``linelist``: a list of strings, separated by line breaks
* ``paths``: a list of :class:`pathlib.Path`, separated as in a shell
* ``pathlist``: a list of ``py.path``, separated as in a shell
* ``int``: an integer
* ``float``: a floating-point number
.. versionadded:: 8.4
The ``float`` and ``int`` types.
For ``paths`` and ``pathlist`` types, they are considered relative to the config-file.
In case the execution is happening without a config-file defined,
they will be considered relative to the current working directory (for example with ``--override-ini``).
.. versionadded:: 7.0
The ``paths`` variable type.
.. versionadded:: 8.1
Use the current working directory to resolve ``paths`` and ``pathlist`` in the absence of a config-file.
Defaults to ``string`` if ``None`` or not passed.
:param default:
Default value if no config-file option exists but is queried.
:param aliases:
Additional names by which this option can be referenced.
Aliases resolve to the canonical name.
Default value if no ini-file option exists but is queried.
.. versionadded:: 9.0
The ``aliases`` parameter.
The value of configuration keys can be retrieved via a call to
The value of ini-variables can be retrieved via a call to
:py:func:`config.getini(name) <pytest.Config.getini>`.
"""
assert type in (
None,
"string",
"paths",
"pathlist",
"args",
"linelist",
"bool",
"int",
"float",
)
if type is None:
type = "string"
if default is NOT_SET:
default = get_ini_default_for_type(type)
assert type in (None, "string", "paths", "pathlist", "args", "linelist", "bool")
self._inidict[name] = (help, type, default)
for alias in aliases:
if alias in self._inidict:
raise ValueError(
f"alias {alias!r} conflicts with existing configuration 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[
"string", "paths", "pathlist", "args", "linelist", "bool", "int", "float"
],
) -> Any:
"""
Used by addini to get the default value for a given config option type, when
default is not supplied.
"""
if type in ("paths", "pathlist", "args", "linelist"):
return []
elif type == "bool":
return False
elif type == "int":
return 0
elif type == "float":
return 0.0
else:
return ""
self._ininames.append(name)
class ArgumentError(Exception):
"""Raised if an Argument instance is created with invalid or
inconsistent arguments."""
def __init__(self, msg: str, option: Argument | str) -> None:
def __init__(self, msg: str, option: Union["Argument", str]) -> None:
self.msg = msg
self.option_id = str(option)
@@ -310,22 +234,46 @@ class Argument:
https://docs.python.org/3/library/optparse.html#optparse-standard-option-types
"""
_typ_map = {"int": int, "string": str, "float": float, "complex": complex}
def __init__(self, *names: str, **attrs: Any) -> None:
"""Store params in private vars for use in add_argument."""
self._attrs = attrs
self._short_opts: list[str] = []
self._long_opts: list[str] = []
self._short_opts: List[str] = []
self._long_opts: List[str] = []
if "%default" in (attrs.get("help") or ""):
warnings.warn(ARGUMENT_PERCENT_DEFAULT, stacklevel=3)
try:
self.type = attrs["type"]
typ = attrs["type"]
except KeyError:
pass
else:
# This might raise a keyerror as well, don't want to catch that.
if isinstance(typ, str):
if typ == "choice":
warnings.warn(
ARGUMENT_TYPE_STR_CHOICE.format(typ=typ, names=names),
stacklevel=4,
)
# argparse expects a type here take it from
# the type of the first element
attrs["type"] = type(attrs["choices"][0])
else:
warnings.warn(
ARGUMENT_TYPE_STR.format(typ=typ, names=names), stacklevel=4
)
attrs["type"] = Argument._typ_map[typ]
# Used in test_parseopt -> test_parse_defaultgetter.
self.type = attrs["type"]
else:
self.type = typ
try:
# Attribute existence is tested in Config._processopt.
self.default = attrs["default"]
except KeyError:
pass
self._set_opt_strings(names)
dest: str | None = attrs.get("dest")
dest: Optional[str] = attrs.get("dest")
if dest:
self.dest = dest
elif self._long_opts:
@@ -337,16 +285,23 @@ class Argument:
self.dest = "???" # Needed for the error repr.
raise ArgumentError("need a long or short option", self) from e
def names(self) -> list[str]:
def names(self) -> List[str]:
return self._short_opts + self._long_opts
def attrs(self) -> Mapping[str, Any]:
# Update any attributes set by processopt.
for attr in ("default", "dest", "help", self.dest):
attrs = "default dest help".split()
attrs.append(self.dest)
for attr in attrs:
try:
self._attrs[attr] = getattr(self, attr)
except AttributeError:
pass
if self._attrs.get("help"):
a = self._attrs["help"]
a = a.replace("%default", "%(default)s")
# a = a.replace('%prog', '%(prog)s')
self._attrs["help"] = a
return self._attrs
def _set_opt_strings(self, opts: Sequence[str]) -> None:
@@ -357,29 +312,29 @@ class Argument:
for opt in opts:
if len(opt) < 2:
raise ArgumentError(
f"invalid option string {opt!r}: "
"must be at least two characters long",
"invalid option string %r: "
"must be at least two characters long" % opt,
self,
)
elif len(opt) == 2:
if not (opt[0] == "-" and opt[1] != "-"):
raise ArgumentError(
f"invalid short option string {opt!r}: "
"must be of the form -x, (x any non-dash char)",
"invalid short option string %r: "
"must be of the form -x, (x any non-dash char)" % opt,
self,
)
self._short_opts.append(opt)
else:
if not (opt[0:2] == "--" and opt[2] != "-"):
raise ArgumentError(
f"invalid long option string {opt!r}: "
"must start with --, followed by non-dash",
"invalid long option string %r: "
"must start with --, followed by non-dash" % opt,
self,
)
self._long_opts.append(opt)
def __repr__(self) -> str:
args: list[str] = []
args: List[str] = []
if self._short_opts:
args += ["_short_opts: " + repr(self._short_opts)]
if self._long_opts:
@@ -397,15 +352,16 @@ class OptionGroup:
def __init__(
self,
arggroup: argparse._ArgumentGroup,
name: str,
parser: Parser | None,
description: str = "",
parser: Optional[Parser] = None,
*,
_ispytest: bool = False,
) -> None:
check_ispytest(_ispytest)
self._arggroup = arggroup
self.name = name
self.options: list[Argument] = []
self.description = description
self.options: List[Argument] = []
self.parser = parser
def addoption(self, *opts: str, **attrs: Any) -> None:
@@ -419,14 +375,14 @@ class OptionGroup:
:param opts:
Option names, can be short or long options.
:param attrs:
Same attributes as the argparse library's :meth:`add_argument()
Same attributes as the argparse library's :py:func:`add_argument()
<argparse.ArgumentParser.add_argument>` function accepts.
"""
conflict = set(opts).intersection(
name for opt in self.options for name in opt.names()
)
if conflict:
raise ValueError(f"option names {conflict} already added")
raise ValueError("option names %s already added" % conflict)
option = Argument(*opts, **attrs)
self._addoption_instance(option, shortupper=False)
@@ -434,47 +390,101 @@ class OptionGroup:
option = Argument(*opts, **attrs)
self._addoption_instance(option, shortupper=True)
def _addoption_instance(self, option: Argument, shortupper: bool = False) -> None:
def _addoption_instance(self, option: "Argument", shortupper: bool = False) -> None:
if not shortupper:
for opt in option._short_opts:
if opt[0] == "-" and opt[1].islower():
raise ValueError("lowercase shortoptions reserved")
if self.parser:
self.parser.processoption(option)
self._arggroup.add_argument(*option.names(), **option.attrs())
self.options.append(option)
class PytestArgumentParser(argparse.ArgumentParser):
class MyOptionParser(argparse.ArgumentParser):
def __init__(
self,
parser: Parser,
usage: str | None,
extra_info: dict[str, str],
extra_info: Optional[Dict[str, Any]] = None,
prog: Optional[str] = None,
) -> None:
self._parser = parser
super().__init__(
usage=usage,
prog=prog,
usage=parser._usage,
add_help=False,
formatter_class=DropShorterLongHelpFormatter,
allow_abbrev=False,
fromfile_prefix_chars="@",
)
# extra_info is a dict of (param -> value) to display if there's
# an usage error to provide more contextual information to the user.
self.extra_info = extra_info
self.extra_info = extra_info if extra_info else {}
def error(self, message: str) -> NoReturn:
"""Transform argparse error message into UsageError."""
msg = f"{self.prog}: error: {message}"
if self.extra_info:
msg += "\n" + "\n".join(
f" {k}: {v}" for k, v in sorted(self.extra_info.items())
)
if hasattr(self._parser, "_config_source_hint"):
# Type ignored because the attribute is set dynamically.
msg = f"{msg} ({self._parser._config_source_hint})" # type: ignore
raise UsageError(self.format_usage() + msg)
# Type ignored because typeshed has a very complex type in the superclass.
def parse_args( # type: ignore
self,
args: Optional[Sequence[str]] = None,
namespace: Optional[argparse.Namespace] = None,
) -> argparse.Namespace:
"""Allow splitting of positional arguments."""
parsed, unrecognized = self.parse_known_args(args, namespace)
if unrecognized:
for arg in unrecognized:
if arg and arg[0] == "-":
lines = ["unrecognized arguments: %s" % (" ".join(unrecognized))]
for k, v in sorted(self.extra_info.items()):
lines.append(f" {k}: {v}")
self.error("\n".join(lines))
getattr(parsed, FILE_OR_DIR).extend(unrecognized)
return parsed
if sys.version_info[:2] < (3, 9): # pragma: no cover
# Backport of https://github.com/python/cpython/pull/14316 so we can
# disable long --argument abbreviations without breaking short flags.
def _parse_optional(
self, arg_string: str
) -> Optional[Tuple[Optional[argparse.Action], str, Optional[str]]]:
if not arg_string:
return None
if not arg_string[0] in self.prefix_chars:
return None
if arg_string in self._option_string_actions:
action = self._option_string_actions[arg_string]
return action, arg_string, None
if len(arg_string) == 1:
return None
if "=" in arg_string:
option_string, explicit_arg = arg_string.split("=", 1)
if option_string in self._option_string_actions:
action = self._option_string_actions[option_string]
return action, option_string, explicit_arg
if self.allow_abbrev or not arg_string.startswith("--"):
option_tuples = self._get_option_tuples(arg_string)
if len(option_tuples) > 1:
msg = gettext(
"ambiguous option: %(option)s could match %(matches)s"
)
options = ", ".join(option for _, option, _ in option_tuples)
self.error(msg % {"option": arg_string, "matches": options})
elif len(option_tuples) == 1:
(option_tuple,) = option_tuples
return option_tuple
if self._negative_number_matcher.match(arg_string):
if not self._has_negative_number_optionals:
return None
if " " in arg_string:
return None
return None, arg_string, None
class DropShorterLongHelpFormatter(argparse.HelpFormatter):
"""Shorten help for long options that differ only in extra hyphens.
@@ -494,7 +504,7 @@ class DropShorterLongHelpFormatter(argparse.HelpFormatter):
orgstr = super()._format_action_invocation(action)
if orgstr and orgstr[0] != "-": # only optional arguments
return orgstr
res: str | None = getattr(action, "_formatted_action_invocation", None)
res: Optional[str] = getattr(action, "_formatted_action_invocation", None)
if res:
return res
options = orgstr.split(", ")
@@ -503,13 +513,13 @@ class DropShorterLongHelpFormatter(argparse.HelpFormatter):
action._formatted_action_invocation = orgstr # type: ignore
return orgstr
return_list = []
short_long: dict[str, str] = {}
short_long: Dict[str, str] = {}
for option in options:
if len(option) == 2 or option[2] == " ":
continue
if not option.startswith("--"):
raise ArgumentError(
f'long optional argument without "--": [{option}]', option
'long optional argument without "--": [%s]' % (option), option
)
xxoption = option[2:]
shortened = xxoption.replace("-", "")
@@ -539,40 +549,3 @@ class DropShorterLongHelpFormatter(argparse.HelpFormatter):
for line in text.splitlines():
lines.extend(textwrap.wrap(line.strip(), width))
return lines
class OverrideIniAction(argparse.Action):
"""Custom argparse action that makes a CLI flag equivalent to overriding an
option, in addition to behaving like `store_true`.
This can simplify things since code only needs to inspect the config option
and not consider the CLI flag.
"""
def __init__(
self,
option_strings: Sequence[str],
dest: str,
nargs: int | str | None = None,
*args,
ini_option: str,
ini_value: str,
**kwargs,
) -> None:
super().__init__(option_strings, dest, 0, *args, **kwargs)
self.ini_option = ini_option
self.ini_value = ini_value
def __call__(
self,
parser: argparse.ArgumentParser,
namespace: argparse.Namespace,
*args,
**kwargs,
) -> None:
setattr(namespace, self.dest, True)
current_overrides = getattr(namespace, "override_ini", None)
if current_overrides is None:
current_overrides = []
current_overrides.append(f"{self.ini_option}={self.ini_value}")
setattr(namespace, "override_ini", current_overrides)

View File

@@ -1,20 +1,15 @@
from __future__ import annotations
from collections.abc import Mapping
import functools
from pathlib import Path
from typing import Any
import warnings
import pluggy
from pathlib import Path
from typing import Optional
from ..compat import LEGACY_PATH
from ..compat import legacy_path
from ..deprecated import HOOK_LEGACY_PATH_ARG
from _pytest.nodes import _check_path
# hookname: (Path, LEGACY_PATH)
imply_paths_hooks: Mapping[str, tuple[str, str]] = {
imply_paths_hooks = {
"pytest_ignore_collect": ("collection_path", "path"),
"pytest_collect_file": ("file_path", "path"),
"pytest_pycollect_makemodule": ("module_path", "path"),
@@ -23,14 +18,6 @@ imply_paths_hooks: Mapping[str, tuple[str, str]] = {
}
def _check_path(path: Path, fspath: LEGACY_PATH) -> None:
if Path(fspath) != path:
raise ValueError(
f"Path({fspath!r}) != {path!r}\n"
"if both path and fspath are given they need to be equal"
)
class PathAwareHookProxy:
"""
this helper wraps around hook callers
@@ -40,24 +27,24 @@ class PathAwareHookProxy:
this may have to be changed later depending on bugs
"""
def __init__(self, hook_relay: pluggy.HookRelay) -> None:
self._hook_relay = hook_relay
def __init__(self, hook_caller):
self.__hook_caller = hook_caller
def __dir__(self) -> list[str]:
return dir(self._hook_relay)
def __dir__(self):
return dir(self.__hook_caller)
def __getattr__(self, key: str) -> pluggy.HookCaller:
hook: pluggy.HookCaller = getattr(self._hook_relay, key)
def __getattr__(self, key, _wraps=functools.wraps):
hook = getattr(self.__hook_caller, key)
if key not in imply_paths_hooks:
self.__dict__[key] = hook
return hook
else:
path_var, fspath_var = imply_paths_hooks[key]
@functools.wraps(hook)
def fixed_hook(**kw: Any) -> Any:
path_value: Path | None = kw.pop(path_var, None)
fspath_value: LEGACY_PATH | None = kw.pop(fspath_var, None)
@_wraps(hook)
def fixed_hook(**kw):
path_value: Optional[Path] = kw.pop(path_var, None)
fspath_value: Optional[LEGACY_PATH] = kw.pop(fspath_var, None)
if fspath_value is not None:
warnings.warn(
HOOK_LEGACY_PATH_ARG.format(
@@ -78,8 +65,6 @@ class PathAwareHookProxy:
kw[fspath_var] = fspath_value
return hook(**kw)
fixed_hook.name = hook.name # type: ignore[attr-defined]
fixed_hook.spec = hook.spec # type: ignore[attr-defined]
fixed_hook.__name__ = key
self.__dict__[key] = fixed_hook
return fixed_hook # type: ignore[return-value]
return fixed_hook

View File

@@ -1,14 +1,10 @@
from __future__ import annotations
from typing import final
from _pytest.compat import final
@final
class UsageError(Exception):
"""Error in pytest usage or invocation."""
__module__ = "pytest"
class PrintHelp(Exception):
"""Raised when pytest should print its help to skip the rest of the

View File

@@ -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: