This commit is contained in:
Ronny Pfannschmidt 2024-07-03 15:37:53 +02:00 committed by GitHub
commit 5f75e824b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 188 additions and 120 deletions

View File

@ -210,7 +210,11 @@ class TracebackEntry:
@property @property
def lineno(self) -> int: def lineno(self) -> int:
return self._rawentry.tb_lineno - 1 if self._rawentry.tb_lineno is None:
# how did i trigger this 😱
return -1 # type: ignore[unreachable]
else:
return self._rawentry.tb_lineno - 1
@property @property
def frame(self) -> Frame: def frame(self) -> Frame:

View File

@ -1,4 +1,3 @@
# mypy: allow-untyped-defs
"""Python version compatibility code.""" """Python version compatibility code."""
from __future__ import annotations from __future__ import annotations
@ -12,8 +11,11 @@ from inspect import signature
import os import os
from pathlib import Path from pathlib import Path
import sys import sys
from types import FunctionType
from types import MethodType
from typing import Any from typing import Any
from typing import Callable from typing import Callable
from typing import cast
from typing import Final from typing import Final
from typing import NoReturn from typing import NoReturn
@ -66,7 +68,8 @@ def is_async_function(func: object) -> bool:
return iscoroutinefunction(func) or inspect.isasyncgenfunction(func) return iscoroutinefunction(func) or inspect.isasyncgenfunction(func)
def getlocation(function, curdir: str | os.PathLike[str] | None = None) -> str: def getlocation(function: Any, curdir: str | os.PathLike[str] | None = None) -> str:
# todo: declare a type alias for function, fixturefunction and callables/generators
function = get_real_func(function) function = get_real_func(function)
fn = Path(inspect.getfile(function)) fn = Path(inspect.getfile(function))
lineno = function.__code__.co_firstlineno lineno = function.__code__.co_firstlineno
@ -80,7 +83,7 @@ def getlocation(function, curdir: str | os.PathLike[str] | None = None) -> str:
return "%s:%d" % (fn, lineno + 1) return "%s:%d" % (fn, lineno + 1)
def num_mock_patch_args(function) -> int: def num_mock_patch_args(function: Callable[..., object]) -> int:
"""Return number of arguments used up by mock arguments (if any).""" """Return number of arguments used up by mock arguments (if any)."""
patchings = getattr(function, "patchings", None) patchings = getattr(function, "patchings", None)
if not patchings: if not patchings:
@ -222,7 +225,7 @@ class _PytestWrapper:
obj: Any obj: Any
def get_real_func(obj): def get_real_func(obj: Any) -> Any:
"""Get the real function object of the (possibly) wrapped object by """Get the real function object of the (possibly) wrapped object by
functools.wraps or functools.partial.""" functools.wraps or functools.partial."""
start_obj = obj start_obj = obj
@ -249,7 +252,7 @@ def get_real_func(obj):
return obj return obj
def get_real_method(obj, holder): def get_real_method(obj: Any, holder: object) -> Any:
"""Attempt to obtain the real function object that might be wrapping """Attempt to obtain the real function object that might be wrapping
``obj``, while at the same time returning a bound method to ``holder`` if ``obj``, while at the same time returning a bound method to ``holder`` if
the original object was a bound method.""" the original object was a bound method."""
@ -263,11 +266,8 @@ def get_real_method(obj, holder):
return obj return obj
def getimfunc(func): def getimfunc(func: FunctionType | MethodType | Callable[..., Any]) -> FunctionType:
try: return cast(FunctionType, getattr(func, "__func__", func))
return func.__func__
except AttributeError:
return func
def safe_getattr(object: Any, name: str, default: Any) -> Any: def safe_getattr(object: Any, name: str, default: Any) -> Any:

View File

@ -417,9 +417,10 @@ def _get_continue_on_failure(config: Config) -> bool:
class DoctestTextfile(Module): class DoctestTextfile(Module):
obj = None # todo: this shouldnt be a module
obj: None = None # type: ignore[assignment]
def collect(self) -> Iterable[DoctestItem]: def collect(self) -> Iterable[DoctestItem]: # type: ignore[override]
import doctest import doctest
# Inspired by doctest.testfile; ideally we would use it directly, # Inspired by doctest.testfile; ideally we would use it directly,
@ -497,7 +498,7 @@ def _patch_unwrap_mock_aware() -> Generator[None, None, None]:
class DoctestModule(Module): class DoctestModule(Module):
def collect(self) -> Iterable[DoctestItem]: def collect(self) -> Iterable[DoctestItem]: # type: ignore[override]
import doctest import doctest
class MockAwareDocTestFinder(doctest.DocTestFinder): class MockAwareDocTestFinder(doctest.DocTestFinder):

View File

@ -1100,7 +1100,8 @@ def resolve_fixture_function(
) -> _FixtureFunc[FixtureValue]: ) -> _FixtureFunc[FixtureValue]:
"""Get the actual callable that can be called to obtain the fixture """Get the actual callable that can be called to obtain the fixture
value.""" value."""
fixturefunc = fixturedef.func # absuing any for the differences between FunctionTpye and Callable
fixturefunc: Any = fixturedef.func
# The fixture function needs to be bound to the actual # The fixture function needs to be bound to the actual
# request.instance so that code working with "fixturedef" behaves # request.instance so that code working with "fixturedef" behaves
# as expected. # as expected.
@ -1112,11 +1113,11 @@ def resolve_fixture_function(
instance, instance,
fixturefunc.__self__.__class__, fixturefunc.__self__.__class__,
): ):
return fixturefunc return cast(_FixtureFunc[FixtureValue], fixturefunc)
fixturefunc = getimfunc(fixturedef.func) fixturefunc = getimfunc(fixturedef.func)
if fixturefunc != fixturedef.func: if fixturefunc != fixturedef.func:
fixturefunc = fixturefunc.__get__(instance) fixturefunc = fixturefunc.__get__(instance)
return fixturefunc return cast(_FixtureFunc[FixtureValue], fixturefunc)
def pytest_fixture_setup( def pytest_fixture_setup(

View File

@ -535,6 +535,7 @@ class Session(nodes.Collector):
``Session`` collects the initial paths given as arguments to pytest. ``Session`` collects the initial paths given as arguments to pytest.
""" """
parent: None
Interrupted = Interrupted Interrupted = Interrupted
Failed = Failed Failed = Failed
# Set on the session by runner.pytest_sessionstart. # Set on the session by runner.pytest_sessionstart.

View File

@ -1,4 +1,3 @@
# mypy: allow-untyped-defs
from __future__ import annotations from __future__ import annotations
import abc import abc
@ -20,6 +19,7 @@ from typing import TypeVar
import warnings import warnings
import pluggy import pluggy
from typing_extensions import Self
import _pytest._code import _pytest._code
from _pytest._code import getfslineno from _pytest._code import getfslineno
@ -54,9 +54,6 @@ SEP = "/"
tracebackcutdir = Path(_pytest.__file__).parent tracebackcutdir = Path(_pytest.__file__).parent
_T = TypeVar("_T")
def _imply_path( def _imply_path(
node_type: type[Node], node_type: type[Node],
path: Path | None, path: Path | None,
@ -96,7 +93,7 @@ class NodeMeta(abc.ABCMeta):
progress on detangling the :class:`Node` classes. progress on detangling the :class:`Node` classes.
""" """
def __call__(cls, *k, **kw) -> NoReturn: def __call__(cls, *k: object, **kw: object) -> NoReturn:
msg = ( msg = (
"Direct construction of {name} has been deprecated, please use {name}.from_parent.\n" "Direct construction of {name} has been deprecated, please use {name}.from_parent.\n"
"See " "See "
@ -105,25 +102,6 @@ class NodeMeta(abc.ABCMeta):
).format(name=f"{cls.__module__}.{cls.__name__}") ).format(name=f"{cls.__module__}.{cls.__name__}")
fail(msg, pytrace=False) fail(msg, pytrace=False)
def _create(cls: type[_T], *k, **kw) -> _T:
try:
return super().__call__(*k, **kw) # type: ignore[no-any-return,misc]
except TypeError:
sig = signature(getattr(cls, "__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"
"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]
class Node(abc.ABC, metaclass=NodeMeta): class Node(abc.ABC, metaclass=NodeMeta):
r"""Base class of :class:`Collector` and :class:`Item`, the components of r"""Base class of :class:`Collector` and :class:`Item`, the components of
@ -138,8 +116,13 @@ class Node(abc.ABC, metaclass=NodeMeta):
#: for methods not migrated to ``pathlib.Path`` yet, such as #: for methods not migrated to ``pathlib.Path`` yet, such as
#: :meth:`Item.reportinfo <pytest.Item.reportinfo>`. Will be deprecated in #: :meth:`Item.reportinfo <pytest.Item.reportinfo>`. Will be deprecated in
#: a future release, prefer using :attr:`path` instead. #: a future release, prefer using :attr:`path` instead.
name: str
parent: Node | None
config: Config
session: Session
fspath: LEGACY_PATH fspath: LEGACY_PATH
_nodeid: str
# Use __slots__ to make attribute access faster. # Use __slots__ to make attribute access faster.
# Note that __dict__ is still available. # Note that __dict__ is still available.
__slots__ = ( __slots__ = (
@ -156,7 +139,7 @@ class Node(abc.ABC, metaclass=NodeMeta):
def __init__( def __init__(
self, self,
name: str, name: str,
parent: Node | None = None, parent: Node | None,
config: Config | None = None, config: Config | None = None,
session: Session | None = None, session: Session | None = None,
fspath: LEGACY_PATH | None = None, fspath: LEGACY_PATH | None = None,
@ -200,13 +183,9 @@ class Node(abc.ABC, metaclass=NodeMeta):
#: Allow adding of extra keywords to use for matching. #: 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: self._nodeid = self._make_nodeid(
assert "::()" not in nodeid name=self.name, parent=self.parent, given=nodeid
self._nodeid = nodeid )
else:
if not self.parent:
raise TypeError("nodeid or parent must be provided")
self._nodeid = self.parent.nodeid + "::" + self.name
#: A place where plugins can store information on the node for their #: A place where plugins can store information on the node for their
#: own use. #: own use.
@ -215,7 +194,38 @@ class Node(abc.ABC, metaclass=NodeMeta):
self._store = self.stash self._store = self.stash
@classmethod @classmethod
def from_parent(cls, parent: Node, **kw) -> Self: def _make_nodeid(cls, name: str, parent: Node | None, given: str | None) -> str:
if given is not None:
assert "::()" not in given
return given
else:
assert parent is not None
return f"{parent.nodeid}::{name}"
@classmethod
def _create(cls, *k: object, **kw: object) -> Self:
callit = super(type(cls), NodeMeta).__call__ # type: ignore[misc]
try:
return cast(Self, callit(cls, *k, **kw))
except TypeError as e:
sig = signature(getattr(cls, "__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"Exception: {e}\n"
"See https://docs.pytest.org/en/stable/deprecations.html"
"#constructors-of-custom-pytest-node-subclasses-should-take-kwargs "
"for more details."
)
)
return cast(Self, callit(cls, *k, **known_kw))
@classmethod
def from_parent(cls, parent: Node, **kw: Any) -> Self:
"""Public constructor for Nodes. """Public constructor for Nodes.
This indirection got introduced in order to enable removing This indirection got introduced in order to enable removing
@ -238,7 +248,7 @@ class Node(abc.ABC, metaclass=NodeMeta):
return self.session.gethookproxy(self.path) return self.session.gethookproxy(self.path)
def __repr__(self) -> str: def __repr__(self) -> str:
return "<{} {}>".format(self.__class__.__name__, getattr(self, "name", None)) return f'<{self.__class__.__name__} { getattr(self, "name", None)}>'
def warn(self, warning: Warning) -> None: def warn(self, warning: Warning) -> None:
"""Issue a warning for this Node. """Issue a warning for this Node.
@ -598,7 +608,6 @@ class FSCollector(Collector, abc.ABC):
if nodeid and os.sep != SEP: if nodeid and os.sep != SEP:
nodeid = nodeid.replace(os.sep, SEP) nodeid = nodeid.replace(os.sep, SEP)
super().__init__( super().__init__(
name=name, name=name,
parent=parent, parent=parent,
@ -611,11 +620,11 @@ class FSCollector(Collector, abc.ABC):
@classmethod @classmethod
def from_parent( def from_parent(
cls, cls,
parent, parent: Node,
*, *,
fspath: LEGACY_PATH | None = None, fspath: LEGACY_PATH | None = None,
path: Path | None = None, path: Path | None = None,
**kw, **kw: Any,
) -> Self: ) -> Self:
"""The public constructor.""" """The public constructor."""
return super().from_parent(parent=parent, fspath=fspath, path=path, **kw) return super().from_parent(parent=parent, fspath=fspath, path=path, **kw)
@ -646,22 +655,25 @@ class Directory(FSCollector, abc.ABC):
""" """
class Definition(Collector, abc.ABC):
@abc.abstractmethod
def collect(self) -> Iterable[Item]: ...
class Item(Node, abc.ABC): class Item(Node, abc.ABC):
"""Base class of all test invocation items. """Base class of all test invocation items.
Note that for a single function there might be multiple test invocation items. Note that for a single function there might be multiple test invocation items.
""" """
nextitem = None
def __init__( def __init__(
self, self,
name, name: str,
parent=None, parent: Node | None = None,
config: Config | None = None, config: Config | None = None,
session: Session | None = None, session: Session | None = None,
nodeid: str | None = None, nodeid: str | None = None,
**kw, **kw: Any,
) -> None: ) -> None:
# The first two arguments are intentionally passed positionally, # The first two arguments are intentionally passed positionally,
# to keep plugins who define a node type which inherits from # to keep plugins who define a node type which inherits from

View File

@ -1,4 +1,3 @@
# mypy: allow-untyped-defs
"""Python test discovery, setup and run of test functions.""" """Python test discovery, setup and run of test functions."""
from __future__ import annotations from __future__ import annotations
@ -17,6 +16,7 @@ from pathlib import Path
import types import types
from typing import Any from typing import Any
from typing import Callable from typing import Callable
from typing import cast
from typing import Dict from typing import Dict
from typing import final from typing import final
from typing import Generator from typing import Generator
@ -27,6 +27,8 @@ from typing import Mapping
from typing import Pattern from typing import Pattern
from typing import Sequence from typing import Sequence
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import TypeVar
from typing import Union
import warnings import warnings
import _pytest import _pytest
@ -203,7 +205,7 @@ def path_matches_patterns(path: Path, patterns: Iterable[str]) -> bool:
return any(fnmatch_ex(pattern, path) for pattern in patterns) return any(fnmatch_ex(pattern, path) for pattern in patterns)
def pytest_pycollect_makemodule(module_path: Path, parent) -> Module: def pytest_pycollect_makemodule(module_path: Path, parent: nodes.FSCollector) -> Module:
return Module.from_parent(parent, path=module_path) return Module.from_parent(parent, path=module_path)
@ -242,6 +244,7 @@ def pytest_pycollect_makeitem(
res.warn(PytestCollectionWarning(reason)) res.warn(PytestCollectionWarning(reason))
return res return res
else: else:
assert isinstance(obj, (types.FunctionType))
return list(collector._genfunctions(name, obj)) return list(collector._genfunctions(name, obj))
return None return None
@ -252,22 +255,26 @@ class PyobjMixin(nodes.Node):
as its intended to always mix in before a node as its intended to always mix in before a node
its position in the mro is unaffected""" its position in the mro is unaffected"""
def __init__(self, *k: Any, obj: Any | None = None, **kw: Any) -> None:
super().__init__(*k, **kw)
self._assign_obj_with_markers(obj)
_ALLOW_MARKERS = True _ALLOW_MARKERS = True
@property @property
def module(self): def module(self) -> types.ModuleType | None:
"""Python module object this node was collected from (can be None).""" """Python module object this node was collected from (can be None)."""
node = self.getparent(Module) node = self.getparent(Module)
return node.obj if node is not None else None return node.obj if node is not None else None
@property @property
def cls(self): def cls(self) -> type | None:
"""Python class object this node was collected from (can be None).""" """Python class object this node was collected from (can be None)."""
node = self.getparent(Class) node = self.getparent(Class)
return node.obj if node is not None else None return node.obj if node is not None else None
@property @property
def instance(self): def instance(self) -> object | None:
"""Python instance object the function is bound to. """Python instance object the function is bound to.
Returns None if not a test method, e.g. for a standalone test function, Returns None if not a test method, e.g. for a standalone test function,
@ -276,26 +283,30 @@ class PyobjMixin(nodes.Node):
# Overridden by Function. # Overridden by Function.
return None return None
def _assign_obj_with_markers(self, obj: Any | None) -> None:
self._obj = obj
# XXX evil hack
# used to avoid Function marker duplication
if self._ALLOW_MARKERS and obj is not None:
self.own_markers.extend(get_unpacked_marks(self.obj))
# This assumes that `obj` is called before there is a chance
# to add custom keys to `self.keywords`, so no fear of overriding.
self.keywords.update((mark.name, mark) for mark in self.own_markers)
@property @property
def obj(self): def obj(self) -> Any:
"""Underlying Python object.""" """Underlying Python object."""
obj = getattr(self, "_obj", None) obj = getattr(self, "_obj", None)
if obj is None: if obj is None:
self._obj = obj = self._getobj() obj = self._getobj()
# XXX evil hack self._assign_obj_with_markers(obj)
# used to avoid Function marker duplication
if self._ALLOW_MARKERS:
self.own_markers.extend(get_unpacked_marks(self.obj))
# This assumes that `obj` is called before there is a chance
# to add custom keys to `self.keywords`, so no fear of overriding.
self.keywords.update((mark.name, mark) for mark in self.own_markers)
return obj return obj
@obj.setter @obj.setter
def obj(self, value): def obj(self, value: Any) -> None:
self._obj = value self._obj = value
def _getobj(self): def _getobj(self) -> Any:
"""Get the underlying Python object. May be overwritten by subclasses.""" """Get the underlying Python object. May be overwritten by subclasses."""
# TODO: Improve the type of `parent` such that assert/ignore aren't needed. # TODO: Improve the type of `parent` such that assert/ignore aren't needed.
assert self.parent is not None assert self.parent is not None
@ -391,7 +402,7 @@ class PyCollector(PyobjMixin, nodes.Collector, abc.ABC):
return True return True
return False return False
def collect(self) -> Iterable[nodes.Item | nodes.Collector]: def collect(self) -> Iterable[nodes.Definition | nodes.Collector]:
if not getattr(self.obj, "__test__", True): if not getattr(self.obj, "__test__", True):
return [] return []
@ -404,10 +415,10 @@ class PyCollector(PyobjMixin, nodes.Collector, abc.ABC):
# In each class, nodes should be definition ordered. # In each class, nodes should be definition ordered.
# __dict__ is definition ordered. # __dict__ is definition ordered.
seen: set[str] = set() seen: set[str] = set()
dict_values: list[list[nodes.Item | nodes.Collector]] = [] dict_values: list[list[nodes.Definition | nodes.Collector]] = []
ihook = self.ihook ihook = self.ihook
for dic in dicts: for dic in dicts:
values: list[nodes.Item | nodes.Collector] = [] values: list[nodes.Definition | nodes.Collector] = []
# Note: seems like the dict can change during iteration - # Note: seems like the dict can change during iteration -
# be careful not to remove the list() without consideration. # be careful not to remove the list() without consideration.
for name, obj in list(dic.items()): for name, obj in list(dic.items()):
@ -434,14 +445,20 @@ class PyCollector(PyobjMixin, nodes.Collector, abc.ABC):
result.extend(values) result.extend(values)
return result return result
def _genfunctions(self, name: str, funcobj) -> Iterator[Function]: def _genfunctions(
self, name: str, funcobj: types.FunctionType
) -> Iterator[Function]:
modulecol = self.getparent(Module) modulecol = self.getparent(Module)
assert modulecol is not None assert modulecol is not None
module = modulecol.obj module = modulecol.obj
clscol = self.getparent(Class) clscol = self.getparent(Class)
cls = clscol and clscol.obj or None cls: type | None = getattr(clscol, "obj", None)
definition = FunctionDefinition.from_parent(self, name=name, callobj=funcobj) definition = FunctionDefinition.from_parent(
self, # type: ignore[arg-type]
name=name,
callobj=funcobj,
)
fixtureinfo = definition._fixtureinfo fixtureinfo = definition._fixtureinfo
# pytest_generate_tests impls call metafunc.parametrize() which fills # pytest_generate_tests impls call metafunc.parametrize() which fills
@ -462,7 +479,7 @@ class PyCollector(PyobjMixin, nodes.Collector, abc.ABC):
self.ihook.pytest_generate_tests.call_extra(methods, dict(metafunc=metafunc)) self.ihook.pytest_generate_tests.call_extra(methods, dict(metafunc=metafunc))
if not metafunc._calls: if not metafunc._calls:
yield Function.from_parent(self, name=name, fixtureinfo=fixtureinfo) yield Function.from_parent(self, name=name, fixtureinfo=fixtureinfo) # type: ignore[arg-type]
else: else:
# Direct parametrizations taking place in module/class-specific # Direct parametrizations taking place in module/class-specific
# `metafunc.parametrize` calls may have shadowed some fixtures, so make sure # `metafunc.parametrize` calls may have shadowed some fixtures, so make sure
@ -474,7 +491,7 @@ class PyCollector(PyobjMixin, nodes.Collector, abc.ABC):
for callspec in metafunc._calls: for callspec in metafunc._calls:
subname = f"{name}[{callspec.id}]" subname = f"{name}[{callspec.id}]"
yield Function.from_parent( yield Function.from_parent(
self, self, # type: ignore[arg-type]
name=subname, name=subname,
callspec=callspec, callspec=callspec,
fixtureinfo=fixtureinfo, fixtureinfo=fixtureinfo,
@ -486,7 +503,7 @@ class PyCollector(PyobjMixin, nodes.Collector, abc.ABC):
def importtestmodule( def importtestmodule(
path: Path, path: Path,
config: Config, config: Config,
): ) -> types.ModuleType:
# We assume we are only called once per module. # We assume we are only called once per module.
importmode = config.getoption("--import-mode") importmode = config.getoption("--import-mode")
try: try:
@ -542,10 +559,12 @@ def importtestmodule(
class Module(nodes.File, PyCollector): class Module(nodes.File, PyCollector):
"""Collector for test classes and functions in a Python module.""" """Collector for test classes and functions in a Python module."""
def _getobj(self): obj: types.ModuleType
def _getobj(self) -> types.ModuleType:
return importtestmodule(self.path, self.config) return importtestmodule(self.path, self.config)
def collect(self) -> Iterable[nodes.Item | nodes.Collector]: def collect(self) -> Iterable[nodes.Collector]:
self._register_setup_module_fixture() self._register_setup_module_fixture()
self._register_setup_function_fixture() self._register_setup_function_fixture()
self.session._fixturemanager.parsefactories(self) self.session._fixturemanager.parsefactories(self)
@ -568,7 +587,9 @@ class Module(nodes.File, PyCollector):
if setup_module is None and teardown_module is None: if setup_module is None and teardown_module is None:
return return
def xunit_setup_module_fixture(request) -> Generator[None, None, None]: def xunit_setup_module_fixture(
request: FixtureRequest,
) -> Generator[None, None, None]:
module = request.module module = request.module
if setup_module is not None: if setup_module is not None:
_call_with_optional_argument(setup_module, module) _call_with_optional_argument(setup_module, module)
@ -599,7 +620,9 @@ class Module(nodes.File, PyCollector):
if setup_function is None and teardown_function is None: if setup_function is None and teardown_function is None:
return return
def xunit_setup_function_fixture(request) -> Generator[None, None, None]: def xunit_setup_function_fixture(
request: FixtureRequest,
) -> Generator[None, None, None]:
if request.instance is not None: if request.instance is not None:
# in this case we are bound to an instance, so we need to let # in this case we are bound to an instance, so we need to let
# setup_method handle this # setup_method handle this
@ -642,9 +665,9 @@ class Package(nodes.Directory):
fspath: LEGACY_PATH | None, fspath: LEGACY_PATH | None,
parent: nodes.Collector, parent: nodes.Collector,
# NOTE: following args are unused: # NOTE: following args are unused:
config=None, config: Config | None = None,
session=None, session: Session | None = None,
nodeid=None, nodeid: str | None = None,
path: Path | None = None, path: Path | None = None,
) -> None: ) -> None:
# NOTE: Could be just the following, but kept as-is for compat. # NOTE: Could be just the following, but kept as-is for compat.
@ -705,41 +728,50 @@ class Package(nodes.Directory):
yield from cols yield from cols
def _call_with_optional_argument(func, arg) -> None: T = TypeVar("T")
def _call_with_optional_argument(
func: Callable[[T], None] | Callable[[], None], arg: T
) -> None:
"""Call the given function with the given argument if func accepts one argument, otherwise """Call the given function with the given argument if func accepts one argument, otherwise
calls func without arguments.""" calls func without arguments."""
arg_count = func.__code__.co_argcount arg_count = func.__code__.co_argcount
if inspect.ismethod(func): if inspect.ismethod(func):
arg_count -= 1 arg_count -= 1
if arg_count: if arg_count:
func(arg) func(arg) # type: ignore[call-arg]
else: else:
func() func() # type: ignore[call-arg]
def _get_first_non_fixture_func(obj: object, names: Iterable[str]) -> object | None: def _get_first_non_fixture_func(
obj: object, names: Iterable[str]
) -> types.FunctionType | types.MethodType | None:
"""Return the attribute from the given object to be used as a setup/teardown """Return the attribute from the given object to be used as a setup/teardown
xunit-style function, but only if not marked as a fixture to avoid calling it twice. xunit-style function, but only if not marked as a fixture to avoid calling it twice.
""" """
for name in names: for name in names:
meth: object | None = getattr(obj, name, None) meth: object | None = getattr(obj, name, None)
if meth is not None and fixtures.getfixturemarker(meth) is None: if meth is not None and fixtures.getfixturemarker(meth) is None:
return meth return cast(Union[types.FunctionType, types.MethodType], meth)
return None return None
class Class(PyCollector): class Class(PyCollector):
"""Collector for test methods (and nested classes) in a Python class.""" """Collector for test methods (and nested classes) in a Python class."""
obj: type
@classmethod @classmethod
def from_parent(cls, parent, *, name, obj=None, **kw) -> Self: # type: ignore[override] def from_parent(cls, parent: nodes.Node, *, name: str, **kw: Any) -> Self: # type: ignore[override]
"""The public constructor.""" """The public constructor."""
return super().from_parent(name=name, parent=parent, **kw) return super().from_parent(name=name, parent=parent, **kw)
def newinstance(self): def newinstance(self) -> Any:
return self.obj() return self.obj()
def collect(self) -> Iterable[nodes.Item | nodes.Collector]: def collect(self) -> Iterable[nodes.Collector]:
if not safe_getattr(self.obj, "__test__", True): if not safe_getattr(self.obj, "__test__", True):
return [] return []
if hasinit(self.obj): if hasinit(self.obj):
@ -780,7 +812,9 @@ class Class(PyCollector):
if setup_class is None and teardown_class is None: if setup_class is None and teardown_class is None:
return return
def xunit_setup_class_fixture(request) -> Generator[None, None, None]: def xunit_setup_class_fixture(
request: FixtureRequest,
) -> Generator[None, None, None]:
cls = request.cls cls = request.cls
if setup_class is not None: if setup_class is not None:
func = getimfunc(setup_class) func = getimfunc(setup_class)
@ -813,7 +847,9 @@ class Class(PyCollector):
if setup_method is None and teardown_method is None: if setup_method is None and teardown_method is None:
return return
def xunit_setup_method_fixture(request) -> Generator[None, None, None]: def xunit_setup_method_fixture(
request: FixtureRequest,
) -> Generator[None, None, None]:
instance = request.instance instance = request.instance
method = request.function method = request.function
if setup_method is not None: if setup_method is not None:
@ -1096,13 +1132,15 @@ class Metafunc:
test function is defined. test function is defined.
""" """
definition: FunctionDefinition
def __init__( def __init__(
self, self,
definition: FunctionDefinition, definition: FunctionDefinition,
fixtureinfo: fixtures.FuncFixtureInfo, fixtureinfo: fixtures.FuncFixtureInfo,
config: Config, config: Config,
cls=None, cls: type | None = None,
module=None, module: types.ModuleType | None = None,
*, *,
_ispytest: bool = False, _ispytest: bool = False,
) -> None: ) -> None:
@ -1523,13 +1561,15 @@ class Function(PyobjMixin, nodes.Item):
# Disable since functions handle it themselves. # Disable since functions handle it themselves.
_ALLOW_MARKERS = False _ALLOW_MARKERS = False
obj: Callable[..., object]
def __init__( def __init__(
self, self,
name: str, name: str,
parent, parent: PyCollector | Module | Class,
config: Config | None = None, config: Config | None = None,
callspec: CallSpec2 | None = None, callspec: CallSpec2 | None = None,
callobj=NOTSET, callobj: Any = NOTSET,
keywords: Mapping[str, Any] | None = None, keywords: Mapping[str, Any] | None = None,
session: Session | None = None, session: Session | None = None,
fixtureinfo: FuncFixtureInfo | None = None, fixtureinfo: FuncFixtureInfo | None = None,
@ -1576,7 +1616,7 @@ class Function(PyobjMixin, nodes.Item):
# todo: determine sound type limitations # todo: determine sound type limitations
@classmethod @classmethod
def from_parent(cls, parent, **kw) -> Self: def from_parent(cls, parent: Module | Class, **kw: Any) -> Self: # type: ignore[override]
"""The public constructor.""" """The public constructor."""
return super().from_parent(parent=parent, **kw) return super().from_parent(parent=parent, **kw)
@ -1585,30 +1625,27 @@ class Function(PyobjMixin, nodes.Item):
self._request = fixtures.TopRequest(self, _ispytest=True) self._request = fixtures.TopRequest(self, _ispytest=True)
@property @property
def function(self): def function(self) -> types.FunctionType:
"""Underlying python 'function' object.""" """Underlying python 'function' object."""
return getimfunc(self.obj) return getimfunc(self.obj)
@property @property
def instance(self): def instance(self) -> Any | None:
try: try:
return self._instance return self._instance
except AttributeError: except AttributeError:
if isinstance(self.parent, Class): # Each Function gets a fresh class instance.
# Each Function gets a fresh class instance. self._instance = self._getinstance()
self._instance = self._getinstance()
else:
self._instance = None
return self._instance return self._instance
def _getinstance(self): def _getinstance(self) -> Any | None:
if isinstance(self.parent, Class): if isinstance(self.parent, Class):
# Each Function gets a fresh class instance. # Each Function gets a fresh class instance.
return self.parent.newinstance() return self.parent.newinstance()
else: else:
return None return None
def _getobj(self): def _getobj(self) -> object:
instance = self.instance instance = self.instance
if instance is not None: if instance is not None:
parent_obj = instance parent_obj = instance
@ -1618,7 +1655,7 @@ class Function(PyobjMixin, nodes.Item):
return getattr(parent_obj, self.originalname) return getattr(parent_obj, self.originalname)
@property @property
def _pyfuncitem(self): def _pyfuncitem(self) -> Self:
"""(compatonly) for code expecting pytest-2.2 style request objects.""" """(compatonly) for code expecting pytest-2.2 style request objects."""
return self return self
@ -1673,6 +1710,8 @@ class FunctionDefinition(Function):
"""This class is a stop gap solution until we evolve to have actual function """This class is a stop gap solution until we evolve to have actual function
definition nodes and manage to get rid of ``metafunc``.""" definition nodes and manage to get rid of ``metafunc``."""
parent: Module | Class
def runtest(self) -> None: def runtest(self) -> None:
raise RuntimeError("function definitions are not supposed to be run as tests") raise RuntimeError("function definitions are not supposed to be run as tests")

View File

@ -81,7 +81,7 @@ class UnitTestCase(Class):
# it. # it.
return self.obj("runTest") return self.obj("runTest")
def collect(self) -> Iterable[Item | Collector]: def collect(self) -> Iterable[Item | Collector]: # type: ignore[override]
from unittest import TestLoader from unittest import TestLoader
cls = self.obj cls = self.obj

View File

@ -325,7 +325,8 @@ class TestFunction:
session = Session.from_config(config) session = Session.from_config(config)
session._fixturemanager = FixtureManager(session) session._fixturemanager = FixtureManager(session)
return pytest.Function.from_parent(parent=session, **kwargs) # todo: implement intermediate node for testing
return pytest.Function.from_parent(parent=session, **kwargs) # type: ignore[arg-type]
def test_function_equality(self, pytester: Pytester) -> None: def test_function_equality(self, pytester: Pytester) -> None:
def func1(): def func1():

View File

@ -7,6 +7,7 @@ import re
import sys import sys
import textwrap import textwrap
from typing import Any from typing import Any
from typing import Callable
from typing import cast from typing import cast
from typing import Dict from typing import Dict
from typing import Iterator from typing import Iterator
@ -49,7 +50,7 @@ class TestMetafunc:
@dataclasses.dataclass @dataclasses.dataclass
class DefinitionMock(python.FunctionDefinition): class DefinitionMock(python.FunctionDefinition):
_nodeid: str _nodeid: str
obj: object obj: Callable[..., Any]
names = getfuncargnames(func) names = getfuncargnames(func)
fixtureinfo: Any = FuncFixtureInfoMock(names) fixtureinfo: Any = FuncFixtureInfoMock(names)

View File

@ -634,6 +634,14 @@ class TestFunctional:
has_own, has_inherited = items has_own, has_inherited = items
has_own_marker = has_own.get_closest_marker("c") has_own_marker = has_own.get_closest_marker("c")
has_inherited_marker = has_inherited.get_closest_marker("c") has_inherited_marker = has_inherited.get_closest_marker("c")
for item in items:
print(item)
for node in item.iter_parents():
print(" ", node)
for marker in node.own_markers:
print(" ", marker)
assert has_own_marker is not None assert has_own_marker is not None
assert has_inherited_marker is not None assert has_inherited_marker is not None
assert has_own_marker.kwargs == {"location": "function"} assert has_own_marker.kwargs == {"location": "function"}

View File

@ -31,7 +31,7 @@ def test_node_direct_construction_deprecated() -> None:
" for more details." " for more details."
), ),
): ):
nodes.Node(None, session=None) # type: ignore[arg-type] nodes.Node(None, parent=None, session=None) # type: ignore[arg-type]
def test_subclassing_both_item_and_collector_deprecated( def test_subclassing_both_item_and_collector_deprecated(