From 2eda3e36fe4863f98591cd69ed5d5134ac8fd61f Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Tue, 14 Nov 2023 10:30:52 +0100 Subject: [PATCH 01/10] Node: use f-string for repr --- src/_pytest/nodes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index bbde2664b90..6d3a6d8c1a7 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -238,7 +238,7 @@ def ihook(self) -> pluggy.HookRelay: 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. From 68b985346b3fba4360a1edfbbfd642a95dbd393b Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Tue, 14 Nov 2023 16:44:55 +0100 Subject: [PATCH 02/10] add type annotations, pass over Node to more --- src/_pytest/main.py | 1 + src/_pytest/nodes.py | 27 ++++++++++++++++++--------- testing/test_nodes.py | 2 +- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 47ebad4713d..919f9faf9a2 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -545,6 +545,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 6d3a6d8c1a7..ea3a46781fe 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -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 @@ def __init__( #: 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. @@ -214,6 +215,15 @@ def __init__( # Deprecated alias. Was never public. Can be removed in a few releases. self._store = self.stash + @classmethod + 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) -> Self: """Public constructor for Nodes. @@ -598,7 +608,6 @@ def __init__( if nodeid and os.sep != SEP: nodeid = nodeid.replace(os.sep, SEP) - super().__init__( name=name, parent=parent, diff --git a/testing/test_nodes.py b/testing/test_nodes.py index f039acf243b..e85f2157884 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( From 608436cda1ae9330ab5493524dfb3f1301eb7130 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 21 Jun 2024 13:38:03 +0200 Subject: [PATCH 03/10] complete typing of nodes.py --- src/_pytest/nodes.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index ea3a46781fe..816becbc99a 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,7 +104,7 @@ def __call__(cls, *k, **kw) -> NoReturn: ).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: @@ -225,7 +224,7 @@ def _make_nodeid(cls, name: str, parent: Node | None, given: str | None) -> str: return f"{parent.nodeid}::{name}" @classmethod - def from_parent(cls, parent: Node, **kw) -> Self: + def from_parent(cls, parent: Node, **kw: Any) -> Self: """Public constructor for Nodes. This indirection got introduced in order to enable removing @@ -620,11 +619,11 @@ def __init__( @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) @@ -661,16 +660,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 From cbd9e8996feb86d56807de575831f61fb18a9d0b Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sat, 22 Jun 2024 11:41:22 +0200 Subject: [PATCH 04/10] wip: more type annotations, too many Any still --- src/_pytest/compat.py | 20 +++---- src/_pytest/doctest.py | 3 +- src/_pytest/fixtures.py | 7 ++- src/_pytest/python.py | 115 +++++++++++++++++++++++-------------- testing/python/collect.py | 3 +- testing/python/metafunc.py | 3 +- 6 files changed, 91 insertions(+), 60 deletions(-) diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 614848e0dba..401e9417e6a 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 @@ 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 cb46d9a3bb5..6b57b35a965 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 0151a4d9c86..1e3385e3c71 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/python.py b/src/_pytest/python.py index 9182ce7dfe9..256f32aa453 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 @@ 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 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 @@ -255,19 +258,19 @@ class PyobjMixin(nodes.Node): _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, @@ -277,7 +280,7 @@ def instance(self): return None @property - def obj(self): + def obj(self) -> Any: """Underlying Python object.""" obj = getattr(self, "_obj", None) if obj is None: @@ -292,10 +295,10 @@ def obj(self): 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 +437,20 @@ def collect(self) -> Iterable[nodes.Item | nodes.Collector]: 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 +471,7 @@ def _genfunctions(self, name: str, funcobj) -> Iterator[Function]: 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 +483,7 @@ def _genfunctions(self, name: str, funcobj) -> Iterator[Function]: 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 +495,7 @@ def _genfunctions(self, name: str, funcobj) -> Iterator[Function]: 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 +551,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 +579,9 @@ def _register_setup_module_fixture(self) -> None: 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 +612,9 @@ def _register_setup_function_fixture(self) -> None: 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 +657,9 @@ def __init__( 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 +720,47 @@ def sort_key(entry: os.DirEntry[str]) -> object: 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 +804,9 @@ def _register_setup_class_fixture(self) -> None: 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 +839,9 @@ def _register_setup_method_fixture(self) -> None: 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 +1129,8 @@ def __init__( 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 +1551,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 +1606,7 @@ def __init__( # 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 +1615,27 @@ def _initrequest(self) -> None: 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 +1645,7 @@ def _getobj(self): 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 06386611279..e91808bd38d 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -325,7 +325,8 @@ def make_function(pytester: Pytester, **kwargs: Any) -> Any: 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 2dd85607e71..9f02864256f 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -7,6 +7,7 @@ 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 SessionMock: @dataclasses.dataclass class DefinitionMock(python.FunctionDefinition): _nodeid: str - obj: object + obj: Callable[..., Any] names = getfuncargnames(func) fixtureinfo: Any = FuncFixtureInfoMock(names) From ec2bcc51eedcfac30f42ed335c8689fb231e2f86 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sat, 22 Jun 2024 12:21:00 +0200 Subject: [PATCH 05/10] expand marker test debugging and restore maker discovery when obj is passed trough --- src/_pytest/nodes.py | 3 ++- src/_pytest/python.py | 24 ++++++++++++++++-------- testing/test_mark.py | 8 ++++++++ 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 816becbc99a..208563c1238 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -107,7 +107,7 @@ def __call__(cls, *k: object, **kw: object) -> NoReturn: 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 @@ -115,6 +115,7 @@ def _create(cls: type[_T], *k: Any, **kw: Any) -> _T: 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." diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 256f32aa453..62f605360ef 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -255,6 +255,10 @@ 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 @@ -279,19 +283,23 @@ def instance(self) -> object | None: # 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) -> 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 diff --git a/testing/test_mark.py b/testing/test_mark.py index 090e10ee9c4..8f08ea57c40 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -582,6 +582,14 @@ def test_has_inherited(self): 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"} From 08b4afd1bc8970bb0fa29af0fe5fb5a0d80ce663 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 5 Nov 2023 22:06:54 +0100 Subject: [PATCH 06/10] nodes: switch to from __future__ import annotations --- src/_pytest/nodes.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 208563c1238..e450a32bd1c 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -53,7 +53,6 @@ tracebackcutdir = Path(_pytest.__file__).parent -_T = TypeVar("_T") def _imply_path( From 56fc42ab9f94e1afda79a335e4ee7d93c7101fff Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Tue, 18 Jun 2024 16:51:19 +0200 Subject: [PATCH 07/10] WIP HACK: traceback entry lineno was none - how ? --- src/_pytest/_code/code.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index e7452825756..2ff222b9c78 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -210,7 +210,11 @@ def with_repr_style( @property 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 def frame(self) -> Frame: From befa0c5e40b08f1d12807b57a174165b5086f537 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Tue, 18 Jun 2024 16:53:50 +0200 Subject: [PATCH 08/10] Node: enable self-type for _create --- src/_pytest/nodes.py | 45 ++++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index e450a32bd1c..78c37331ccf 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -19,6 +19,7 @@ import warnings import pluggy +from typing_extensions import Self import _pytest._code from _pytest._code import getfslineno @@ -53,8 +54,6 @@ tracebackcutdir = Path(_pytest.__file__).parent - - def _imply_path( node_type: type[Node], path: Path | None, @@ -103,26 +102,6 @@ def __call__(cls, *k: object, **kw: object) -> NoReturn: ).format(name=f"{cls.__module__}.{cls.__name__}") fail(msg, pytrace=False) - def _create(cls: type[_T], *k: Any, **kw: Any) -> _T: - try: - return super().__call__(*k, **kw) # type: ignore[no-any-return,misc] - 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 super().__call__(*k, **known_kw) # type: ignore[no-any-return,misc] - class Node(abc.ABC, metaclass=NodeMeta): r"""Base class of :class:`Collector` and :class:`Item`, the components of @@ -223,6 +202,28 @@ def _make_nodeid(cls, name: str, parent: Node | None, given: str | None) -> str: 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. From 988eb6ac9d6f70cacedd0a40f87ddddaef35f959 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Tue, 18 Jun 2024 16:55:50 +0200 Subject: [PATCH 09/10] typing - switch around node Definition usage --- src/_pytest/doctest.py | 4 ++-- src/_pytest/nodes.py | 5 +++++ src/_pytest/python.py | 14 +++++++++----- src/_pytest/unittest.py | 2 +- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index 6b57b35a965..c0fac8aa526 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -420,7 +420,7 @@ class DoctestTextfile(Module): # 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 # Inspired by doctest.testfile; ideally we would use it directly, @@ -498,7 +498,7 @@ def _mock_aware_unwrap( class DoctestModule(Module): - def collect(self) -> Iterable[DoctestItem]: + def collect(self) -> Iterable[DoctestItem]: # type: ignore[override] import doctest class MockAwareDocTestFinder(doctest.DocTestFinder): diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 78c37331ccf..3076484a346 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -655,6 +655,11 @@ class Directory(FSCollector, abc.ABC): """ +class Definition(Collector, abc.ABC): + @abc.abstractmethod + def collect(self) -> Iterable[Item]: ... + + class Item(Node, abc.ABC): """Base class of all test invocation items. diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 62f605360ef..9a0141e4cbf 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -402,7 +402,7 @@ def _matches_prefix_or_glob_option(self, option_name: str, name: str) -> bool: return True 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): return [] @@ -415,10 +415,10 @@ def collect(self) -> Iterable[nodes.Item | nodes.Collector]: # In each class, nodes should be definition ordered. # __dict__ is definition ordered. seen: set[str] = set() - dict_values: list[list[nodes.Item | nodes.Collector]] = [] + dict_values: list[list[nodes.Definition | nodes.Collector]] = [] ihook = self.ihook 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 - # be careful not to remove the list() without consideration. for name, obj in list(dic.items()): @@ -564,7 +564,7 @@ class Module(nodes.File, PyCollector): def _getobj(self) -> types.ModuleType: 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_function_fixture() self.session._fixturemanager.parsefactories(self) @@ -771,7 +771,7 @@ def from_parent(cls, parent: nodes.Node, *, name: str, **kw: Any) -> Self: # ty def newinstance(self) -> Any: 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): return [] if hasinit(self.obj): @@ -1132,6 +1132,8 @@ class Metafunc: test function is defined. """ + definition: FunctionDefinition + def __init__( self, definition: FunctionDefinition, @@ -1708,6 +1710,8 @@ class FunctionDefinition(Function): """This class is a stop gap solution until we evolve to have actual function definition nodes and manage to get rid of ``metafunc``.""" + parent: Module | Class + def runtest(self) -> None: raise RuntimeError("function definitions are not supposed to be run as tests") diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index aefea1333d9..e1202aaa5d2 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -81,7 +81,7 @@ def newinstance(self): # it. return self.obj("runTest") - def collect(self) -> Iterable[Item | Collector]: + def collect(self) -> Iterable[Item | Collector]: # type: ignore[override] from unittest import TestLoader cls = self.obj From 887f1a1d38b4098fe1549a040957c77e808f3f5f Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Tue, 18 Jun 2024 16:56:22 +0200 Subject: [PATCH 10/10] python Function instance: remove duplicate code --- src/_pytest/python.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 9a0141e4cbf..3e1f74d192a 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1634,8 +1634,8 @@ def instance(self) -> Any | None: try: return self._instance except AttributeError: + # Each Function gets a fresh class instance. self._instance = self._getinstance() - return self._instance def _getinstance(self) -> Any | None: