diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 614848e0d..401e9417e 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -1,4 +1,3 @@ -# mypy: allow-untyped-defs """Python version compatibility code.""" from __future__ import annotations @@ -12,8 +11,11 @@ from inspect import signature import os from pathlib import Path import sys +from types import FunctionType +from types import MethodType from typing import Any from typing import Callable +from typing import cast from typing import Final from typing import NoReturn @@ -66,7 +68,8 @@ def is_async_function(func: object) -> bool: 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) fn = Path(inspect.getfile(function)) 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) -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).""" patchings = getattr(function, "patchings", None) if not patchings: @@ -222,7 +225,7 @@ class _PytestWrapper: 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 functools.wraps or functools.partial.""" start_obj = obj @@ -249,7 +252,7 @@ def get_real_func(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 ``obj``, while at the same time returning a bound method to ``holder`` if the original object was a bound method.""" @@ -263,11 +266,8 @@ def get_real_method(obj, holder): return obj -def getimfunc(func): - try: - return func.__func__ - except AttributeError: - return func +def getimfunc(func: FunctionType | MethodType | Callable[..., Any]) -> FunctionType: + return cast(FunctionType, getattr(func, "__func__", func)) def safe_getattr(object: Any, name: str, default: Any) -> Any: diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index cb46d9a3b..6b57b35a9 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -417,7 +417,8 @@ def _get_continue_on_failure(config: Config) -> bool: class DoctestTextfile(Module): - obj = None + # todo: this shouldnt be a module + obj: None = None # type: ignore[assignment] def collect(self) -> Iterable[DoctestItem]: import doctest diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 0151a4d9c..1e3385e3c 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1100,7 +1100,8 @@ def resolve_fixture_function( ) -> _FixtureFunc[FixtureValue]: """Get the actual callable that can be called to obtain the fixture 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 # request.instance so that code working with "fixturedef" behaves # as expected. @@ -1112,11 +1113,11 @@ def resolve_fixture_function( instance, fixturefunc.__self__.__class__, ): - return fixturefunc + return cast(_FixtureFunc[FixtureValue], fixturefunc) fixturefunc = getimfunc(fixturedef.func) if fixturefunc != fixturedef.func: fixturefunc = fixturefunc.__get__(instance) - return fixturefunc + return cast(_FixtureFunc[FixtureValue], fixturefunc) def pytest_fixture_setup( diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 8ec269060..9818fe409 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -535,6 +535,7 @@ class Session(nodes.Collector): ``Session`` collects the initial paths given as arguments to pytest. """ + parent: None Interrupted = Interrupted Failed = Failed # Set on the session by runner.pytest_sessionstart. diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index bbde2664b..208563c12 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -1,4 +1,3 @@ -# mypy: allow-untyped-defs from __future__ import annotations import abc @@ -96,7 +95,7 @@ class NodeMeta(abc.ABCMeta): progress on detangling the :class:`Node` classes. """ - def __call__(cls, *k, **kw) -> NoReturn: + def __call__(cls, *k: object, **kw: object) -> NoReturn: msg = ( "Direct construction of {name} has been deprecated, please use {name}.from_parent.\n" "See " @@ -105,10 +104,10 @@ class NodeMeta(abc.ABCMeta): ).format(name=f"{cls.__module__}.{cls.__name__}") fail(msg, pytrace=False) - def _create(cls: type[_T], *k, **kw) -> _T: + def _create(cls: type[_T], *k: Any, **kw: Any) -> _T: try: return super().__call__(*k, **kw) # type: ignore[no-any-return,misc] - except TypeError: + 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 @@ -116,6 +115,7 @@ class NodeMeta(abc.ABCMeta): 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." @@ -138,8 +138,13 @@ class Node(abc.ABC, metaclass=NodeMeta): #: for methods not migrated to ``pathlib.Path`` yet, such as #: :meth:`Item.reportinfo `. Will be deprecated in #: a future release, prefer using :attr:`path` instead. + name: str + parent: Node | None + config: Config + session: Session fspath: LEGACY_PATH + _nodeid: str # Use __slots__ to make attribute access faster. # Note that __dict__ is still available. __slots__ = ( @@ -156,7 +161,7 @@ class Node(abc.ABC, metaclass=NodeMeta): def __init__( self, name: str, - parent: Node | None = None, + parent: Node | None, config: Config | None = None, session: Session | None = None, fspath: LEGACY_PATH | None = None, @@ -200,13 +205,9 @@ class Node(abc.ABC, metaclass=NodeMeta): #: Allow adding of extra keywords to use for matching. self.extra_keyword_matches: set[str] = set() - if nodeid is not None: - assert "::()" not in nodeid - self._nodeid = nodeid - else: - if not self.parent: - raise TypeError("nodeid or parent must be provided") - self._nodeid = self.parent.nodeid + "::" + self.name + self._nodeid = self._make_nodeid( + name=self.name, parent=self.parent, given=nodeid + ) #: A place where plugins can store information on the node for their #: own use. @@ -215,7 +216,16 @@ class Node(abc.ABC, metaclass=NodeMeta): self._store = self.stash @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 from_parent(cls, parent: Node, **kw: Any) -> Self: """Public constructor for Nodes. 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) 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: """Issue a warning for this Node. @@ -598,7 +608,6 @@ class FSCollector(Collector, abc.ABC): if nodeid and os.sep != SEP: nodeid = nodeid.replace(os.sep, SEP) - super().__init__( name=name, parent=parent, @@ -611,11 +620,11 @@ class FSCollector(Collector, abc.ABC): @classmethod def from_parent( cls, - parent, + parent: Node, *, fspath: LEGACY_PATH | None = None, path: Path | None = None, - **kw, + **kw: Any, ) -> Self: """The public constructor.""" return super().from_parent(parent=parent, fspath=fspath, path=path, **kw) @@ -652,16 +661,14 @@ class Item(Node, abc.ABC): Note that for a single function there might be multiple test invocation items. """ - nextitem = None - def __init__( self, - name, - parent=None, + name: str, + parent: Node | None = None, config: Config | None = None, session: Session | None = None, nodeid: str | None = None, - **kw, + **kw: Any, ) -> None: # The first two arguments are intentionally passed positionally, # to keep plugins who define a node type which inherits from diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 9182ce7df..62f605360 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1,4 +1,3 @@ -# mypy: allow-untyped-defs """Python test discovery, setup and run of test functions.""" from __future__ import annotations @@ -17,6 +16,7 @@ from pathlib import Path import types from typing import Any from typing import Callable +from typing import cast from typing import Dict from typing import final from typing import Generator @@ -27,6 +27,8 @@ from typing import Mapping from typing import Pattern from typing import Sequence from typing import TYPE_CHECKING +from typing import TypeVar +from typing import Union import warnings 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) -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) @@ -242,6 +244,7 @@ def pytest_pycollect_makeitem( res.warn(PytestCollectionWarning(reason)) return res else: + assert isinstance(obj, (types.FunctionType)) return list(collector._genfunctions(name, obj)) return None @@ -252,22 +255,26 @@ class PyobjMixin(nodes.Node): as its intended to always mix in before a node 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 @property - def module(self): + def module(self) -> types.ModuleType | None: """Python module object this node was collected from (can be None).""" node = self.getparent(Module) return node.obj if node is not None else None @property - def cls(self): + def cls(self) -> type | None: """Python class object this node was collected from (can be None).""" node = self.getparent(Class) return node.obj if node is not None else None @property - def instance(self): + def instance(self) -> object | None: """Python instance object the function is bound to. 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. 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 - def obj(self): + def obj(self) -> Any: """Underlying Python object.""" obj = getattr(self, "_obj", None) if obj is None: - self._obj = obj = self._getobj() - # XXX evil hack - # 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) + obj = self._getobj() + self._assign_obj_with_markers(obj) return obj @obj.setter - def obj(self, value): + def obj(self, value: Any) -> None: self._obj = value - def _getobj(self): + def _getobj(self) -> Any: """Get the underlying Python object. May be overwritten by subclasses.""" # TODO: Improve the type of `parent` such that assert/ignore aren't needed. assert self.parent is not None @@ -434,14 +445,20 @@ class PyCollector(PyobjMixin, nodes.Collector, abc.ABC): result.extend(values) return result - def _genfunctions(self, name: str, funcobj) -> Iterator[Function]: + def _genfunctions( + self, name: str, funcobj: types.FunctionType + ) -> Iterator[Function]: modulecol = self.getparent(Module) assert modulecol is not None module = modulecol.obj 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 # 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)) 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: # Direct parametrizations taking place in module/class-specific # `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: subname = f"{name}[{callspec.id}]" yield Function.from_parent( - self, + self, # type: ignore[arg-type] name=subname, callspec=callspec, fixtureinfo=fixtureinfo, @@ -486,7 +503,7 @@ class PyCollector(PyobjMixin, nodes.Collector, abc.ABC): def importtestmodule( path: Path, config: Config, -): +) -> types.ModuleType: # We assume we are only called once per module. importmode = config.getoption("--import-mode") try: @@ -542,7 +559,9 @@ def importtestmodule( class Module(nodes.File, PyCollector): """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) def collect(self) -> Iterable[nodes.Item | nodes.Collector]: @@ -568,7 +587,9 @@ class Module(nodes.File, PyCollector): if setup_module is None and teardown_module is None: 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 if setup_module is not None: _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: 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: # in this case we are bound to an instance, so we need to let # setup_method handle this @@ -642,9 +665,9 @@ class Package(nodes.Directory): fspath: LEGACY_PATH | None, parent: nodes.Collector, # NOTE: following args are unused: - config=None, - session=None, - nodeid=None, + config: Config | None = None, + session: Session | None = None, + nodeid: str | None = None, path: Path | None = None, ) -> None: # NOTE: Could be just the following, but kept as-is for compat. @@ -705,38 +728,47 @@ class Package(nodes.Directory): 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 calls func without arguments.""" arg_count = func.__code__.co_argcount if inspect.ismethod(func): arg_count -= 1 if arg_count: - func(arg) + func(arg) # type: ignore[call-arg] 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 xunit-style function, but only if not marked as a fixture to avoid calling it twice. """ for name in names: meth: object | None = getattr(obj, name, None) if meth is not None and fixtures.getfixturemarker(meth) is None: - return meth + return cast(Union[types.FunctionType, types.MethodType], meth) return None class Class(PyCollector): """Collector for test methods (and nested classes) in a Python class.""" + obj: type + @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.""" return super().from_parent(name=name, parent=parent, **kw) - def newinstance(self): + def newinstance(self) -> Any: return self.obj() def collect(self) -> Iterable[nodes.Item | nodes.Collector]: @@ -780,7 +812,9 @@ class Class(PyCollector): if setup_class is None and teardown_class is None: 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 if setup_class is not None: func = getimfunc(setup_class) @@ -813,7 +847,9 @@ class Class(PyCollector): if setup_method is None and teardown_method is None: 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 method = request.function if setup_method is not None: @@ -1101,8 +1137,8 @@ class Metafunc: definition: FunctionDefinition, fixtureinfo: fixtures.FuncFixtureInfo, config: Config, - cls=None, - module=None, + cls: type | None = None, + module: types.ModuleType | None = None, *, _ispytest: bool = False, ) -> None: @@ -1523,13 +1559,15 @@ class Function(PyobjMixin, nodes.Item): # Disable since functions handle it themselves. _ALLOW_MARKERS = False + obj: Callable[..., object] + def __init__( self, name: str, - parent, + parent: PyCollector | Module | Class, config: Config | None = None, callspec: CallSpec2 | None = None, - callobj=NOTSET, + callobj: Any = NOTSET, keywords: Mapping[str, Any] | None = None, session: Session | None = None, fixtureinfo: FuncFixtureInfo | None = None, @@ -1576,7 +1614,7 @@ class Function(PyobjMixin, nodes.Item): # todo: determine sound type limitations @classmethod - def from_parent(cls, parent, **kw) -> Self: + def from_parent(cls, parent: Module | Class, **kw: Any) -> Self: # type: ignore[override] """The public constructor.""" return super().from_parent(parent=parent, **kw) @@ -1585,30 +1623,27 @@ class Function(PyobjMixin, nodes.Item): self._request = fixtures.TopRequest(self, _ispytest=True) @property - def function(self): + def function(self) -> types.FunctionType: """Underlying python 'function' object.""" return getimfunc(self.obj) @property - def instance(self): + def instance(self) -> Any | None: try: return self._instance except AttributeError: - if isinstance(self.parent, Class): - # Each Function gets a fresh class instance. - self._instance = self._getinstance() - else: - self._instance = None + self._instance = self._getinstance() + return self._instance - def _getinstance(self): + def _getinstance(self) -> Any | None: if isinstance(self.parent, Class): # Each Function gets a fresh class instance. return self.parent.newinstance() else: return None - def _getobj(self): + def _getobj(self) -> object: instance = self.instance if instance is not None: parent_obj = instance @@ -1618,7 +1653,7 @@ class Function(PyobjMixin, nodes.Item): return getattr(parent_obj, self.originalname) @property - def _pyfuncitem(self): + def _pyfuncitem(self) -> Self: """(compatonly) for code expecting pytest-2.2 style request objects.""" return self diff --git a/testing/python/collect.py b/testing/python/collect.py index 063866112..e91808bd3 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -325,7 +325,8 @@ class TestFunction: session = Session.from_config(config) 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 func1(): diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 2dd85607e..9f0286425 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -7,6 +7,7 @@ import re import sys import textwrap from typing import Any +from typing import Callable from typing import cast from typing import Dict from typing import Iterator @@ -49,7 +50,7 @@ class TestMetafunc: @dataclasses.dataclass class DefinitionMock(python.FunctionDefinition): _nodeid: str - obj: object + obj: Callable[..., Any] names = getfuncargnames(func) fixtureinfo: Any = FuncFixtureInfoMock(names) diff --git a/testing/test_mark.py b/testing/test_mark.py index 89eef7920..16ca96711 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -634,6 +634,14 @@ class TestFunctional: has_own, has_inherited = items has_own_marker = has_own.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_inherited_marker is not None assert has_own_marker.kwargs == {"location": "function"} diff --git a/testing/test_nodes.py b/testing/test_nodes.py index f039acf24..e85f21578 100644 --- a/testing/test_nodes.py +++ b/testing/test_nodes.py @@ -31,7 +31,7 @@ def test_node_direct_construction_deprecated() -> None: " 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(