Skip to content
8 changes: 6 additions & 2 deletions src/_pytest/mark/structures.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
from typing import Tuple


if TYPE_CHECKING:
from .. import nodes


def istestfunc(func):
return (
hasattr(func, "__call__")
Expand Down Expand Up @@ -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
"""
Expand Down Expand Up @@ -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}
Expand Down
28 changes: 20 additions & 8 deletions src/_pytest/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
123 changes: 76 additions & 47 deletions src/_pytest/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -58,6 +60,8 @@
from _pytest.warning_types import PytestUnhandledCoroutineWarning

if TYPE_CHECKING:
from typing_extensions import Literal

from _pytest._io import TerminalWriter


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -263,28 +267,37 @@ 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):
"""Underlying Python object."""
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()
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
55 changes: 54 additions & 1 deletion testing/test_mark.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down