diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 40dfe01ea61..cd705cb7d03 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -24,6 +24,10 @@ from typing import Tuple +if TYPE_CHECKING: + from .. import nodes + + def istestfunc(func): return ( hasattr(func, "__call__") @@ -272,7 +276,7 @@ def __call__(self, *args, **kwargs): return self.with_args(*args, **kwargs) -def get_unpacked_marks(obj): +def get_unpacked_marks(obj: object) -> List[Mark]: """ obtain the unpacked marks that are stored on an object """ @@ -368,7 +372,7 @@ def __getattr__(self, name: str) -> MarkDecorator: class NodeKeywords(MutableMapping): - def __init__(self, node): + def __init__(self, node: "nodes.Node") -> None: self.node = node self.parent = node.parent self._markers = {node.name: True} diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index c452e63c48e..8fc60bc1144 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -3,6 +3,7 @@ from functools import lru_cache from typing import Any from typing import Dict +from typing import Iterator from typing import List from typing import Optional from typing import Set @@ -92,6 +93,8 @@ class Node(metaclass=NodeMeta): """ base class for Collector and Item the test collection tree. Collector subclasses have children, Items are terminal nodes.""" + _keywords = None # type: Optional[NodeKeywords] + def __init__( self, name: str, @@ -126,11 +129,8 @@ def __init__( #: filesystem path where this node was collected from (can be None) self.fspath = fspath or getattr(parent, "fspath", None) # type: py.path.local - #: keywords/markers collected from all scopes - self.keywords = NodeKeywords(self) - - #: the marker objects belonging to this node - self.own_markers = [] # type: List[Mark] + #: The (manually added) marks belonging to this node (start, end). + self._own_markers = ([], []) # type: Tuple[List[Mark], List[Mark]] #: allow adding of extra keywords to use for matching self.extra_keyword_matches = set() # type: Set[str] @@ -173,6 +173,18 @@ def ihook(self): """ fspath sensitive hook proxy used to call pytest hooks""" return self.session.gethookproxy(self.fspath) + @property + def own_markers(self) -> List[Mark]: + """The marker objects belonging to this node.""" + return self._own_markers[0] + self._own_markers[1] + + @property + def keywords(self) -> NodeKeywords: + """keywords/markers collected from all scopes.""" + if self._keywords is None: + self._keywords = NodeKeywords(self) + return self._keywords + def __repr__(self): return "<{} nodeid={!r}>".format( self.__class__.__name__, getattr(self, "nodeid", None) @@ -255,11 +267,11 @@ def add_marker( raise ValueError("is not a string or pytest.mark.* Marker") self.keywords[marker_.name] = marker if append: - self.own_markers.append(marker_.mark) + self._own_markers[1].append(marker_.mark) else: - self.own_markers.insert(0, marker_.mark) + self._own_markers[0].insert(0, marker_.mark) - def iter_markers(self, name=None): + def iter_markers(self, name: Optional[str] = None) -> Iterator[Mark]: """ :param name: if given, filter the results by the name attribute diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 4989d6b3984..84af0f44cd7 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -12,10 +12,12 @@ from collections.abc import Sequence from functools import partial from types import ModuleType +from typing import Any from typing import Callable from typing import Dict from typing import Iterable from typing import List +from typing import Mapping from typing import Optional from typing import Tuple from typing import Union @@ -58,6 +60,8 @@ from _pytest.warning_types import PytestUnhandledCoroutineWarning if TYPE_CHECKING: + from typing_extensions import Literal + from _pytest._io import TerminalWriter @@ -180,7 +184,7 @@ def async_warn(pyfuncitem: "Function") -> None: @hookimpl(trylast=True) -def pytest_pyfunc_call(pyfuncitem: "Function"): +def pytest_pyfunc_call(pyfuncitem: "Function") -> "Literal[True]": testfunction = pyfuncitem.obj if iscoroutinefunction(testfunction) or ( sys.version_info >= (3, 6) and inspect.isasyncgenfunction(testfunction) @@ -263,7 +267,12 @@ class PyobjContext: class PyobjMixin(PyobjContext): - _ALLOW_MARKERS = True + _obj_markers = None # type: Optional[List[Mark]] + + # Function and attributes that the mixin needs (for type-checking only). + if TYPE_CHECKING: + _keywords = None # type: Optional[nodes.NodeKeywords] + _own_markers = ([], []) # type: Tuple[List[Mark], List[Mark]] @property def obj(self): @@ -271,20 +280,24 @@ def obj(self): obj = getattr(self, "_obj", None) if obj is None: self._obj = obj = self._getobj() - # XXX evil hack - # used to avoid Instance collector marker duplication - if self._ALLOW_MARKERS: - self.own_markers.extend(get_unpacked_marks(self.obj)) return obj @obj.setter - def obj(self, value): + def obj(self, value) -> None: self._obj = value + self._obj_markers = None + self._keywords = None def _getobj(self): """Gets the underlying Python object. May be overwritten by subclasses.""" return getattr(self.parent.obj, self.name) + @property + def own_markers(self) -> List[Mark]: + if self._obj_markers is None: + self._obj_markers = get_unpacked_marks(self.obj) + return self._own_markers[0] + self._obj_markers + self._own_markers[1] + def getmodpath(self, stopatmodule=True, includemodule=False): """ return python path relative to the containing module. """ chain = self.listchain() @@ -751,14 +764,14 @@ def xunit_setup_method_fixture(self, request): class Instance(PyCollector): - _ALLOW_MARKERS = False # hack, destroy later - # instances share the object with their parents in a way - # that duplicates markers instances if not taken out - # can be removed at node structure reorganization time - def _getobj(self): return self.parent.obj() + @property + def own_markers(self) -> List[Mark]: + # Do not include markers from obj, coming from Class already. + return self._own_markers[0] + self._own_markers[1] + def collect(self): self.session._fixturemanager.parsefactories(self) return super().collect() @@ -781,14 +794,15 @@ def hasnew(obj): class CallSpec2: - def __init__(self, metafunc): + def __init__(self, metafunc: "Metafunc") -> None: self.metafunc = metafunc - self.funcargs = {} - self._idlist = [] - self.params = {} - self._arg2scopenum = {} # used for sorting parametrized resources - self.marks = [] - self.indices = {} + self.funcargs = {} # type: Dict[str, object] + self._idlist = [] # type: List[str] + self.params = {} # type: Dict[str, object] + # Used for sorting parametrized resources. + self._arg2scopenum = {} # type: Dict[str, int] + self.marks = [] # type: List[Mark] + self.indices = {} # type: Dict[str, int] def copy(self): cs = CallSpec2(self.metafunc) @@ -1395,9 +1409,6 @@ class Function(PyobjMixin, nodes.Item): Python test function. """ - # disable since functions handle it themselves - _ALLOW_MARKERS = False - def __init__( self, name, @@ -1406,7 +1417,7 @@ def __init__( config=None, callspec: Optional[CallSpec2] = None, callobj=NOTSET, - keywords=None, + keywords: Optional[Mapping[str, Any]] = None, session=None, fixtureinfo: Optional[FuncFixtureInfo] = None, originalname=None, @@ -1416,31 +1427,11 @@ def __init__( if callobj is not NOTSET: self.obj = callobj - self.keywords.update(self.obj.__dict__) - self.own_markers.extend(get_unpacked_marks(self.obj)) + self._callspec = callspec if callspec: - self.callspec = callspec - # this is total hostile and a mess - # keywords are broken by design by now - # this will be redeemed later - for mark in callspec.marks: - # feel free to cry, this was broken for years before - # and keywords cant fix it per design - self.keywords[mark.name] = mark - self.own_markers.extend(normalize_mark_list(callspec.marks)) - if keywords: - self.keywords.update(keywords) - - # todo: this is a hell of a hack - # https://github.com/pytest-dev/pytest/issues/4569 - - self.keywords.update( - { - mark.name: True - for mark in self.iter_markers() - if mark.name not in self.keywords - } - ) + # XXX: only set for existing hasattr checks..! + self.callspec = self._callspec + self._keywords_arg = keywords if fixtureinfo is None: fixtureinfo = self.session._fixturemanager.getfixtureinfo( @@ -1472,6 +1463,44 @@ def function(self): "underlying python 'function' object" return getimfunc(self.obj) + @property + def own_markers(self) -> List[Mark]: + if self._obj_markers is None: + self._obj_markers = get_unpacked_marks(self.obj) + if self._callspec: + self._obj_markers += normalize_mark_list(self._callspec.marks) + return self._own_markers[0] + self._obj_markers + self._own_markers[1] + + @property + def keywords(self) -> "nodes.NodeKeywords": + if self._keywords is not None: + return self._keywords + + keywords = super().keywords + keywords.update(self.obj.__dict__) + if self._callspec: + # this is total hostile and a mess + # keywords are broken by design by now + # this will be redeemed later + for mark in self._callspec.marks: + # feel free to cry, this was broken for years before + # and keywords cant fix it per design + self.keywords[mark.name] = mark + if self._keywords_arg: + keywords.update(self._keywords_arg) + + # todo: this is a hell of a hack + # https://github.com/pytest-dev/pytest/issues/4569 + keywords.update( + { + mark.name: True + for mark in self.iter_markers() + if mark.name not in self.keywords + } + ) + self._keywords = keywords + return self._keywords + def _getobj(self): name = self.name i = name.find("[") # parametrization diff --git a/testing/test_mark.py b/testing/test_mark.py index 09b1ab26421..7c0283c35dd 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -4,11 +4,14 @@ import pytest from _pytest.compat import TYPE_CHECKING +from _pytest.config import Config from _pytest.config import ExitCode from _pytest.mark import EMPTY_PARAMETERSET_OPTION from _pytest.mark import MarkGenerator as Mark +from _pytest.mark.structures import NodeKeywords from _pytest.nodes import Collector from _pytest.nodes import Node +from _pytest.python import Function if TYPE_CHECKING: from _pytest.pytester import Testdir @@ -1019,7 +1022,7 @@ def test_3(): assert reprec.countoutcomes() == [3, 0, 0] -def test_addmarker_order(): +def test_addmarker_order(pytestconfig: Config, monkeypatch) -> None: session = mock.Mock() session.own_markers = [] session.parent = None @@ -1032,6 +1035,56 @@ def test_addmarker_order(): extracted = [x.name for x in node.iter_markers()] assert extracted == ["baz", "foo", "bar"] + # Check marks/keywords with Function. + session.name = "session" + session.keywords = NodeKeywords(session) + + # Register markers for `--strict-markers`. + added_markers = pytestconfig._inicache["markers"] + [ + "funcmark", + "prepended", + "funcmark2", + ] + monkeypatch.setitem(pytestconfig._inicache, "markers", added_markers) + + @pytest.mark.funcmark + def f1(): + assert False, "don't call me" + + func = Function.from_parent(node, name="func", callobj=f1) + expected_marks = ["funcmark", "baz", "foo", "bar"] + assert [x.name for x in func.iter_markers()] == expected_marks + func.add_marker("prepended", append=False) + assert [x.name for x in func.iter_markers()] == ["prepended"] + expected_marks + assert set(func.keywords) == { + "Test", + "bar", + "baz", + "foo", + "func", + "funcmark", + "prepended", + "pytestmark", + "session", + } + + # Changing the "obj" updates marks and keywords (lazily). + @pytest.mark.funcmark2 + def f2(): + assert False, "don't call me" + + func.obj = f2 + assert [x.name for x in func.iter_markers()] == [ + "prepended", + "funcmark2", + "baz", + "foo", + "bar", + ] + keywords = set(func.keywords) + assert "funcmark2" in keywords + assert "funcmark" not in keywords + @pytest.mark.filterwarnings("ignore") def test_markers_from_parametrize(testdir):