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,52 +1,47 @@
# mypy: allow-untyped-defs
from __future__ import annotations
import abc
from collections.abc import Callable
from collections.abc import Iterable
from collections.abc import Iterator
from collections.abc import MutableMapping
from functools import cached_property
from functools import lru_cache
import os
import pathlib
import warnings
from inspect import signature
from pathlib import Path
from typing import Any
from typing import Callable
from typing import cast
from typing import NoReturn
from typing import Iterable
from typing import Iterator
from typing import List
from typing import MutableMapping
from typing import Optional
from typing import overload
from typing import Set
from typing import Tuple
from typing import Type
from typing import TYPE_CHECKING
from typing import TypeVar
import warnings
import pluggy
from typing import Union
import _pytest._code
from _pytest._code import getfslineno
from _pytest._code.code import ExceptionInfo
from _pytest._code.code import TerminalRepr
from _pytest._code.code import Traceback
from _pytest._code.code import TracebackStyle
from _pytest.compat import cached_property
from _pytest.compat import LEGACY_PATH
from _pytest.compat import signature
from _pytest.config import Config
from _pytest.config import ConftestImportFailure
from _pytest.config.compat import _check_path
from _pytest.deprecated import FSCOLLECTOR_GETHOOKPROXY_ISINITPATH
from _pytest.deprecated import NODE_CTOR_FSPATH_ARG
from _pytest.mark.structures import Mark
from _pytest.mark.structures import MarkDecorator
from _pytest.mark.structures import NodeKeywords
from _pytest.outcomes import fail
from _pytest.pathlib import absolutepath
from _pytest.pathlib import commonpath
from _pytest.stash import Stash
from _pytest.warning_types import PytestWarning
if TYPE_CHECKING:
from typing_extensions import Self
# Imported here due to circular import.
from _pytest.main import Session
from _pytest._code.code import _TracebackStyle
SEP = "/"
@@ -54,13 +49,63 @@ SEP = "/"
tracebackcutdir = Path(_pytest.__file__).parent
_T = TypeVar("_T")
def iterparentnodeids(nodeid: str) -> Iterator[str]:
"""Return the parent node IDs of a given node ID, inclusive.
For the node ID
"testing/code/test_excinfo.py::TestFormattedExcinfo::test_repr_source"
the result would be
""
"testing"
"testing/code"
"testing/code/test_excinfo.py"
"testing/code/test_excinfo.py::TestFormattedExcinfo"
"testing/code/test_excinfo.py::TestFormattedExcinfo::test_repr_source"
Note that / components are only considered until the first ::.
"""
pos = 0
first_colons: Optional[int] = nodeid.find("::")
if first_colons == -1:
first_colons = None
# The root Session node - always present.
yield ""
# Eagerly consume SEP parts until first colons.
while True:
at = nodeid.find(SEP, pos, first_colons)
if at == -1:
break
if at > 0:
yield nodeid[:at]
pos = at + len(SEP)
# Eagerly consume :: parts.
while True:
at = nodeid.find("::", pos)
if at == -1:
break
if at > 0:
yield nodeid[:at]
pos = at + len("::")
# The node ID itself.
if nodeid:
yield nodeid
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"
)
def _imply_path(
node_type: type[Node],
path: Path | None,
fspath: LEGACY_PATH | None,
node_type: Type["Node"],
path: Optional[Path],
fspath: Optional[LEGACY_PATH],
) -> Path:
if fspath is not None:
warnings.warn(
@@ -81,51 +126,37 @@ def _imply_path(
_NodeType = TypeVar("_NodeType", bound="Node")
class NodeMeta(abc.ABCMeta):
"""Metaclass used by :class:`Node` to enforce that direct construction raises
:class:`Failed`.
This behaviour supports the indirection introduced with :meth:`Node.from_parent`,
the named constructor to be used instead of direct construction. The design
decision to enforce indirection with :class:`NodeMeta` was made as a
temporary aid for refactoring the collection tree, which was diagnosed to
have :class:`Node` objects whose creational patterns were overly entangled.
Once the refactoring is complete, this metaclass can be removed.
See https://github.com/pytest-dev/pytest/projects/3 for an overview of the
progress on detangling the :class:`Node` classes.
"""
def __call__(cls, *k, **kw) -> NoReturn:
class NodeMeta(type):
def __call__(self, *k, **kw):
msg = (
"Direct construction of {name} has been deprecated, please use {name}.from_parent.\n"
"See "
"https://docs.pytest.org/en/stable/deprecations.html#node-construction-changed-to-node-from-parent"
" for more details."
).format(name=f"{cls.__module__}.{cls.__name__}")
).format(name=f"{self.__module__}.{self.__name__}")
fail(msg, pytrace=False)
def _create(cls: type[_T], *k, **kw) -> _T:
def _create(self, *k, **kw):
try:
return super().__call__(*k, **kw) # type: ignore[no-any-return,misc]
return super().__call__(*k, **kw)
except TypeError:
sig = signature(getattr(cls, "__init__"))
sig = signature(getattr(self, "__init__"))
known_kw = {k: v for k, v in kw.items() if k in sig.parameters}
from .warning_types import PytestDeprecationWarning
warnings.warn(
PytestDeprecationWarning(
f"{cls} is not using a cooperative constructor and only takes {set(known_kw)}.\n"
f"{self} is not using a cooperative constructor and only takes {set(known_kw)}.\n"
"See https://docs.pytest.org/en/stable/deprecations.html"
"#constructors-of-custom-pytest-node-subclasses-should-take-kwargs "
"for more details."
)
)
return super().__call__(*k, **known_kw) # type: ignore[no-any-return,misc]
return super().__call__(*k, **known_kw)
class Node(abc.ABC, metaclass=NodeMeta):
class Node(metaclass=NodeMeta):
r"""Base class of :class:`Collector` and :class:`Item`, the components of
the test collection tree.
@@ -136,32 +167,32 @@ class Node(abc.ABC, metaclass=NodeMeta):
# Implemented in the legacypath plugin.
#: A ``LEGACY_PATH`` copy of the :attr:`path` attribute. Intended for usage
#: for methods not migrated to ``pathlib.Path`` yet, such as
#: :meth:`Item.reportinfo <pytest.Item.reportinfo>`. Will be deprecated in
#: a future release, prefer using :attr:`path` instead.
#: :meth:`Item.reportinfo`. Will be deprecated in a future release, prefer
#: using :attr:`path` instead.
fspath: LEGACY_PATH
# Use __slots__ to make attribute access faster.
# Note that __dict__ is still available.
__slots__ = (
"__dict__",
"_nodeid",
"_store",
"config",
"name",
"parent",
"path",
"config",
"session",
"path",
"_nodeid",
"_store",
"__dict__",
)
def __init__(
self,
name: str,
parent: Node | None = None,
config: Config | None = None,
session: Session | None = None,
fspath: LEGACY_PATH | None = None,
path: Path | None = None,
nodeid: str | None = None,
parent: "Optional[Node]" = None,
config: Optional[Config] = None,
session: "Optional[Session]" = None,
fspath: Optional[LEGACY_PATH] = None,
path: Optional[Path] = None,
nodeid: Optional[str] = None,
) -> None:
#: A unique name within the scope of the parent node.
self.name: str = name
@@ -188,17 +219,17 @@ class Node(abc.ABC, metaclass=NodeMeta):
if path is None and fspath is None:
path = getattr(parent, "path", None)
#: Filesystem path where this node was collected from (can be None).
self.path: pathlib.Path = _imply_path(type(self), path, fspath=fspath)
self.path: Path = _imply_path(type(self), path, fspath=fspath)
# The explicit annotation is to avoid publicly exposing NodeKeywords.
#: Keywords/markers collected from all scopes.
self.keywords: MutableMapping[str, Any] = NodeKeywords(self)
#: The marker objects belonging to this node.
self.own_markers: list[Mark] = []
self.own_markers: List[Mark] = []
#: Allow adding of extra keywords to use for matching.
self.extra_keyword_matches: set[str] = set()
self.extra_keyword_matches: Set[str] = set()
if nodeid is not None:
assert "::()" not in nodeid
@@ -215,7 +246,7 @@ class Node(abc.ABC, metaclass=NodeMeta):
self._store = self.stash
@classmethod
def from_parent(cls, parent: Node, **kw) -> Self:
def from_parent(cls, parent: "Node", **kw):
"""Public constructor for Nodes.
This indirection got introduced in order to enable removing
@@ -233,7 +264,7 @@ class Node(abc.ABC, metaclass=NodeMeta):
return cls._create(parent=parent, **kw)
@property
def ihook(self) -> pluggy.HookRelay:
def ihook(self):
"""fspath-sensitive hook proxy used to call pytest hooks."""
return self.session.gethookproxy(self.path)
@@ -264,7 +295,9 @@ class Node(abc.ABC, metaclass=NodeMeta):
# enforce type checks here to avoid getting a generic type error later otherwise.
if not isinstance(warning, Warning):
raise ValueError(
f"warning must be an instance of Warning or subclass, got {warning!r}"
"warning must be an instance of Warning or subclass, got {!r}".format(
warning
)
)
path, lineno = get_fslocation_from_item(self)
assert lineno is not None
@@ -291,29 +324,23 @@ class Node(abc.ABC, metaclass=NodeMeta):
def teardown(self) -> None:
pass
def iter_parents(self) -> Iterator[Node]:
"""Iterate over all parent collectors starting from and including self
up to the root of the collection tree.
def listchain(self) -> List["Node"]:
"""Return list of all parent collectors up to self, starting from
the root of collection tree.
.. versionadded:: 8.1
:returns: The nodes.
"""
parent: Node | None = self
while parent is not None:
yield parent
parent = parent.parent
def listchain(self) -> list[Node]:
"""Return a list of all parent collectors starting from the root of the
collection tree down to and including self."""
chain = []
item: Node | None = self
item: Optional[Node] = self
while item is not None:
chain.append(item)
item = item.parent
chain.reverse()
return chain
def add_marker(self, marker: str | MarkDecorator, append: bool = True) -> None:
def add_marker(
self, marker: Union[str, MarkDecorator], append: bool = True
) -> None:
"""Dynamically add a marker object to the node.
:param marker:
@@ -335,7 +362,7 @@ class Node(abc.ABC, metaclass=NodeMeta):
else:
self.own_markers.insert(0, marker_.mark)
def iter_markers(self, name: str | None = None) -> Iterator[Mark]:
def iter_markers(self, name: Optional[str] = None) -> Iterator[Mark]:
"""Iterate over all markers of the node.
:param name: If given, filter the results by the name attribute.
@@ -344,25 +371,29 @@ class Node(abc.ABC, metaclass=NodeMeta):
return (x[1] for x in self.iter_markers_with_node(name=name))
def iter_markers_with_node(
self, name: str | None = None
) -> Iterator[tuple[Node, Mark]]:
self, name: Optional[str] = None
) -> Iterator[Tuple["Node", Mark]]:
"""Iterate over all markers of the node.
:param name: If given, filter the results by the name attribute.
:returns: An iterator of (node, mark) tuples.
"""
for node in self.iter_parents():
for node in reversed(self.listchain()):
for mark in node.own_markers:
if name is None or getattr(mark, "name", None) == name:
yield node, mark
@overload
def get_closest_marker(self, name: str) -> Mark | None: ...
def get_closest_marker(self, name: str) -> Optional[Mark]:
...
@overload
def get_closest_marker(self, name: str, default: Mark) -> Mark: ...
def get_closest_marker(self, name: str, default: Mark) -> Mark:
...
def get_closest_marker(self, name: str, default: Mark | None = None) -> Mark | None:
def get_closest_marker(
self, name: str, default: Optional[Mark] = None
) -> Optional[Mark]:
"""Return the first marker matching the name, from closest (for
example function) to farther level (for example module level).
@@ -371,14 +402,14 @@ class Node(abc.ABC, metaclass=NodeMeta):
"""
return next(self.iter_markers(name=name), default)
def listextrakeywords(self) -> set[str]:
def listextrakeywords(self) -> Set[str]:
"""Return a set of all extra keywords in self and any parents."""
extra_keywords: set[str] = set()
extra_keywords: Set[str] = set()
for item in self.listchain():
extra_keywords.update(item.extra_keyword_matches)
return extra_keywords
def listnames(self) -> list[str]:
def listnames(self) -> List[str]:
return [x.name for x in self.listchain()]
def addfinalizer(self, fin: Callable[[], object]) -> None:
@@ -390,17 +421,18 @@ class Node(abc.ABC, metaclass=NodeMeta):
"""
self.session._setupstate.addfinalizer(fin, self)
def getparent(self, cls: type[_NodeType]) -> _NodeType | None:
"""Get the closest parent node (including self) which is an instance of
def getparent(self, cls: Type[_NodeType]) -> Optional[_NodeType]:
"""Get the next parent node (including self) which is an instance of
the given class.
:param cls: The node class to search for.
:returns: The node, if found.
"""
for node in self.iter_parents():
if isinstance(node, cls):
return node
return None
current: Optional[Node] = self
while current and not isinstance(current, cls):
current = current.parent
assert current is None or isinstance(current, cls)
return current
def _traceback_filter(self, excinfo: ExceptionInfo[BaseException]) -> Traceback:
return excinfo.traceback
@@ -408,19 +440,19 @@ class Node(abc.ABC, metaclass=NodeMeta):
def _repr_failure_py(
self,
excinfo: ExceptionInfo[BaseException],
style: TracebackStyle | None = None,
style: "Optional[_TracebackStyle]" = None,
) -> TerminalRepr:
from _pytest.fixtures import FixtureLookupError
if isinstance(excinfo.value, ConftestImportFailure):
excinfo = ExceptionInfo.from_exception(excinfo.value.cause)
excinfo = ExceptionInfo.from_exc_info(excinfo.value.excinfo)
if isinstance(excinfo.value, fail.Exception):
if not excinfo.value.pytrace:
style = "value"
if isinstance(excinfo.value, FixtureLookupError):
return excinfo.value.formatrepr()
tbfilter: bool | Callable[[ExceptionInfo[BaseException]], Traceback]
tbfilter: Union[bool, Callable[[ExceptionInfo[BaseException]], Traceback]]
if self.config.getoption("fulltrace", False):
style = "long"
tbfilter = False
@@ -435,13 +467,11 @@ class Node(abc.ABC, metaclass=NodeMeta):
else:
style = "long"
if self.config.get_verbosity() > 1:
if self.config.getoption("verbose", 0) > 1:
truncate_locals = False
else:
truncate_locals = True
truncate_args = False if self.config.get_verbosity() > 2 else True
# excinfo.getrepr() formats paths relative to the CWD if `abspath` is False.
# It is possible for a fixture/test to change the CWD while this code runs, which
# would then result in the user seeing confusing paths in the failure message.
@@ -460,14 +490,13 @@ class Node(abc.ABC, metaclass=NodeMeta):
style=style,
tbfilter=tbfilter,
truncate_locals=truncate_locals,
truncate_args=truncate_args,
)
def repr_failure(
self,
excinfo: ExceptionInfo[BaseException],
style: TracebackStyle | None = None,
) -> str | TerminalRepr:
style: "Optional[_TracebackStyle]" = None,
) -> Union[str, TerminalRepr]:
"""Return a representation of a collection or test failure.
.. seealso:: :ref:`non-python tests`
@@ -477,26 +506,26 @@ class Node(abc.ABC, metaclass=NodeMeta):
return self._repr_failure_py(excinfo, style)
def get_fslocation_from_item(node: Node) -> tuple[str | Path, int | None]:
def get_fslocation_from_item(node: "Node") -> Tuple[Union[str, Path], Optional[int]]:
"""Try to extract the actual location from a node, depending on available attributes:
* "location": a pair (path, lineno)
* "obj": a Python object that the node wraps.
* "path": just a path
* "fspath": just a path
:rtype: A tuple of (str|Path, int) with filename and 0-based line number.
"""
# See Item.location.
location: tuple[str, int | None, str] | None = getattr(node, "location", None)
location: Optional[Tuple[str, Optional[int], str]] = getattr(node, "location", None)
if location is not None:
return location[:2]
obj = getattr(node, "obj", None)
if obj is not None:
return getfslineno(obj)
return getattr(node, "path", "unknown location"), -1
return getattr(node, "fspath", "unknown location"), -1
class Collector(Node, abc.ABC):
class Collector(Node):
"""Base class of all collectors.
Collector create children through `collect()` and thus iteratively build
@@ -506,15 +535,14 @@ class Collector(Node, abc.ABC):
class CollectError(Exception):
"""An error during collection, contains a custom message."""
@abc.abstractmethod
def collect(self) -> Iterable[Item | Collector]:
def collect(self) -> Iterable[Union["Item", "Collector"]]:
"""Collect children (items and collectors) for this collector."""
raise NotImplementedError("abstract")
# TODO: This omits the style= parameter which breaks Liskov Substitution.
def repr_failure( # type: ignore[override]
self, excinfo: ExceptionInfo[BaseException]
) -> str | TerminalRepr:
) -> Union[str, TerminalRepr]:
"""Return a representation of a collection failure.
:param excinfo: Exception information for the failure.
@@ -539,37 +567,31 @@ class Collector(Node, abc.ABC):
ntraceback = traceback.cut(path=self.path)
if ntraceback == traceback:
ntraceback = ntraceback.cut(excludepath=tracebackcutdir)
return ntraceback.filter(excinfo)
return excinfo.traceback.filter(excinfo)
return excinfo.traceback
@lru_cache(maxsize=1000)
def _check_initialpaths_for_relpath(
initial_paths: frozenset[Path], path: Path
) -> str | None:
if path in initial_paths:
return ""
for parent in path.parents:
if parent in initial_paths:
return str(path.relative_to(parent))
def _check_initialpaths_for_relpath(session: "Session", path: Path) -> Optional[str]:
for initial_path in session._initialpaths:
if commonpath(path, initial_path) == initial_path:
rel = str(path.relative_to(initial_path))
return "" if rel == "." else rel
return None
class FSCollector(Collector, abc.ABC):
class FSCollector(Collector):
"""Base class for filesystem collectors."""
def __init__(
self,
fspath: LEGACY_PATH | None = None,
path_or_parent: Path | Node | None = None,
path: Path | None = None,
name: str | None = None,
parent: Node | None = None,
config: Config | None = None,
session: Session | None = None,
nodeid: str | None = None,
fspath: Optional[LEGACY_PATH] = None,
path_or_parent: Optional[Union[Path, Node]] = None,
path: Optional[Path] = None,
name: Optional[str] = None,
parent: Optional[Node] = None,
config: Optional[Config] = None,
session: Optional["Session"] = None,
nodeid: Optional[str] = None,
) -> None:
if path_or_parent:
if isinstance(path_or_parent, Node):
@@ -600,7 +622,7 @@ class FSCollector(Collector, abc.ABC):
try:
nodeid = str(self.path.relative_to(session.config.rootpath))
except ValueError:
nodeid = _check_initialpaths_for_relpath(session._initialpaths, path)
nodeid = _check_initialpaths_for_relpath(session, path)
if nodeid and os.sep != SEP:
nodeid = nodeid.replace(os.sep, SEP)
@@ -619,40 +641,30 @@ class FSCollector(Collector, abc.ABC):
cls,
parent,
*,
fspath: LEGACY_PATH | None = None,
path: Path | None = None,
fspath: Optional[LEGACY_PATH] = None,
path: Optional[Path] = None,
**kw,
) -> Self:
):
"""The public constructor."""
return super().from_parent(parent=parent, fspath=fspath, path=path, **kw)
def gethookproxy(self, fspath: "os.PathLike[str]"):
warnings.warn(FSCOLLECTOR_GETHOOKPROXY_ISINITPATH, stacklevel=2)
return self.session.gethookproxy(fspath)
class File(FSCollector, abc.ABC):
def isinitpath(self, path: Union[str, "os.PathLike[str]"]) -> bool:
warnings.warn(FSCOLLECTOR_GETHOOKPROXY_ISINITPATH, stacklevel=2)
return self.session.isinitpath(path)
class File(FSCollector):
"""Base class for collecting tests from a file.
:ref:`non-python tests`.
"""
class Directory(FSCollector, abc.ABC):
"""Base class for collecting files from a directory.
A basic directory collector does the following: goes over the files and
sub-directories in the directory and creates collectors for them by calling
the hooks :hook:`pytest_collect_directory` and :hook:`pytest_collect_file`,
after checking that they are not ignored using
:hook:`pytest_ignore_collect`.
The default directory collectors are :class:`~pytest.Dir` and
:class:`~pytest.Package`.
.. versionadded:: 8.0
:ref:`custom directory collectors`.
"""
class Item(Node, abc.ABC):
class Item(Node):
"""Base class of all test invocation items.
Note that for a single function there might be multiple test invocation items.
@@ -664,9 +676,9 @@ class Item(Node, abc.ABC):
self,
name,
parent=None,
config: Config | None = None,
session: Session | None = None,
nodeid: str | None = None,
config: Optional[Config] = None,
session: Optional["Session"] = None,
nodeid: Optional[str] = None,
**kw,
) -> None:
# The first two arguments are intentionally passed positionally,
@@ -681,11 +693,11 @@ class Item(Node, abc.ABC):
nodeid=nodeid,
**kw,
)
self._report_sections: list[tuple[str, str, str]] = []
self._report_sections: List[Tuple[str, str, str]] = []
#: A list of tuples (name, value) that holds user defined properties
#: for this test.
self.user_properties: list[tuple[str, object]] = []
self.user_properties: List[Tuple[str, object]] = []
self._check_item_and_collector_diamond_inheritance()
@@ -718,7 +730,6 @@ class Item(Node, abc.ABC):
PytestWarning,
)
@abc.abstractmethod
def runtest(self) -> None:
"""Run the test case for this item.
@@ -745,7 +756,7 @@ class Item(Node, abc.ABC):
if content:
self._report_sections.append((when, key, content))
def reportinfo(self) -> tuple[os.PathLike[str] | str, int | None, str]:
def reportinfo(self) -> Tuple[Union["os.PathLike[str]", str], Optional[int], str]:
"""Get location information for this item for test reports.
Returns a tuple with three elements:
@@ -759,14 +770,14 @@ class Item(Node, abc.ABC):
return self.path, None, ""
@cached_property
def location(self) -> tuple[str, int | None, str]:
def location(self) -> Tuple[str, Optional[int], str]:
"""
Returns a tuple of ``(relfspath, lineno, testname)`` for this item
where ``relfspath`` is file path relative to ``config.rootpath``
and lineno is a 0-based line number.
"""
location = self.reportinfo()
path = absolutepath(location[0])
path = absolutepath(os.fspath(location[0]))
relfspath = self.session._node_location_to_relpath(path)
assert type(location[2]) is str
return (relfspath, location[1], location[2])