Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/10406.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Parameter-set marks now take precedence over less specific function, class, and module marks when pytest resolves markers and per-test ``filterwarnings`` overrides.
10 changes: 9 additions & 1 deletion src/_pytest/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,10 +344,18 @@ def iter_markers_with_node(
:returns: An iterator of (node, mark) tuples.
"""
for node in self.iter_parents():
for mark in node.own_markers:
for mark in node._iter_own_markers_closest_first():
if name is None or getattr(mark, "name", None) == name:
yield node, mark

def _iter_own_markers_closest_first(self) -> Iterator[Mark]:
"""Iterate this node's markers from closest to farthest."""
yield from self.own_markers

def _iter_own_markers_farthest_first(self) -> Iterator[Mark]:
"""Iterate this node's markers from farthest to closest."""
yield from self.own_markers

@overload
def get_closest_marker(self, name: str) -> Mark | None: ...

Expand Down
37 changes: 37 additions & 0 deletions src/_pytest/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -1706,10 +1706,14 @@ def __init__(
# Note: when FunctionDefinition is introduced, we should change ``originalname``
# to a readonly property that returns FunctionDefinition.name.

self._prepended_mark_count = 0
self.own_markers.extend(get_unpacked_marks(self.obj))
self._static_function_mark_count = len(self.own_markers)
self._callspec_mark_count = 0
if callspec:
self.callspec = callspec
self.own_markers.extend(callspec.marks)
self._callspec_mark_count = len(callspec.marks)

# todo: this is a hell of a hack
# https://github.com/pytest-dev/pytest/issues/4569
Expand All @@ -1734,6 +1738,39 @@ def from_parent(cls, parent, **kw) -> Self:
"""The public constructor."""
return super().from_parent(parent=parent, **kw)

def add_marker(self, marker: str | MarkDecorator, append: bool = True) -> None:
super().add_marker(marker=marker, append=append)
if not append:
self._prepended_mark_count += 1

def _iter_own_markers_closest_first(self) -> Iterator[Mark]:
if not self._callspec_mark_count:
yield from self.own_markers
return

prepended_end = self._prepended_mark_count
static_end = prepended_end + self._static_function_mark_count
callspec_end = static_end + self._callspec_mark_count

yield from self.own_markers[:prepended_end]
yield from self.own_markers[static_end:callspec_end]
yield from self.own_markers[prepended_end:static_end]
yield from self.own_markers[callspec_end:]

def _iter_own_markers_farthest_first(self) -> Iterator[Mark]:
if not self._callspec_mark_count and not self._prepended_mark_count:
yield from self.own_markers
return

prepended_end = self._prepended_mark_count
static_end = prepended_end + self._static_function_mark_count
callspec_end = static_end + self._callspec_mark_count

yield from self.own_markers[callspec_end:]
yield from self.own_markers[prepended_end:static_end]
yield from self.own_markers[static_end:callspec_end]
yield from self.own_markers[:prepended_end]

def _initrequest(self) -> None:
self.funcargs: dict[str, object] = {}
self._request = fixtures.TopRequest(self, _ispytest=True)
Expand Down
11 changes: 8 additions & 3 deletions src/_pytest/warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,14 @@ def catch_warnings_for_item(
# apply filters from "filterwarnings" marks
nodeid = "" if item is None else item.nodeid
if item is not None:
for mark in item.iter_markers(name="filterwarnings"):
for arg in mark.args:
warnings.filterwarnings(*parse_warning_filter(arg, escape=False))
for node in reversed(list(item.iter_parents())):
for mark in node._iter_own_markers_farthest_first():
if mark.name != "filterwarnings":
continue
for arg in mark.args:
warnings.filterwarnings(
*parse_warning_filter(arg, escape=False)
)

try:
yield
Expand Down
9 changes: 9 additions & 0 deletions testing/test_mark.py
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,7 @@ class TestBarClass(BaseTests):
def test_addmarker_order(pytester) -> None:
session = mock.Mock()
session.own_markers = []
session._iter_own_markers_closest_first.return_value = iter(())
session.parent = None
session.nodeid = ""
session.path = pytester.path
Expand Down Expand Up @@ -1182,6 +1183,14 @@ def test_custom_mark_parametrized(obj_type):
result.assert_outcomes(passed=4)


def test_parametrize_marks_are_closest(pytester: Pytester) -> None:
# fmt: off
pytester.makepyfile('import pytest\n@pytest.mark.custom_mark("module")\nclass TestMarkers:\n @pytest.mark.custom_mark("function")\n @pytest.mark.parametrize("_", [pytest.param(None, marks=pytest.mark.custom_mark("parametrize"))])\n def test_marker_order(self, _, request):\n assert [mark.args[0] for mark in request.node.iter_markers("custom_mark")] == ["parametrize", "function", "module"]\n assert request.node.get_closest_marker("custom_mark").args[0] == "parametrize"\n') # noqa: E501
# fmt: on
result = pytester.runpytest()
result.assert_outcomes(passed=1)


def test_pytest_param_id_requires_string() -> None:
with pytest.raises(TypeError) as excinfo:
pytest.param(id=True) # type: ignore[arg-type]
Expand Down
25 changes: 25 additions & 0 deletions testing/test_warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,31 @@ def test_show_warning():
result.stdout.fnmatch_lines(["*= 1 failed, 2 passed, 1 warning in *"])


def test_filterwarnings_parametrize_mark_is_most_specific(pytester: Pytester) -> None:
# fmt: off
pytester.makepyfile('import pytest\nimport warnings\n@pytest.mark.filterwarnings("error")\nclass TestMarkClass:\n @pytest.mark.parametrize("_", [pytest.param(None, marks=pytest.mark.filterwarnings("ignore"))])\n def test_class_mark(self, _):\n warnings.warn("class")\nclass TestMarkMethods:\n @pytest.mark.filterwarnings("error")\n @pytest.mark.parametrize("_", [pytest.param(None, marks=pytest.mark.filterwarnings("ignore"))])\n def test_function_mark_before_parametrize(self, _):\n warnings.warn("function-before")\n @pytest.mark.parametrize("_", [pytest.param(None, marks=pytest.mark.filterwarnings("ignore"))])\n @pytest.mark.filterwarnings("error")\n def test_function_mark_after_parametrize(self, _):\n warnings.warn("function-after")\n') # noqa: E501
# fmt: on
result = pytester.runpytest("-q")
result.stdout.fnmatch_lines(["*3 passed*"])


def test_filterwarnings_mark_preserves_decorator_order(pytester: Pytester) -> None:
# fmt: off
pytester.makepyfile('import pytest\nimport warnings\n@pytest.mark.filterwarnings("ignore:api v1")\n@pytest.mark.filterwarnings("error")\ndef test_decorator_order():\n warnings.warn(UserWarning("api v1"))\n') # noqa: E501
# fmt: on
result = pytester.runpytest("-q")
result.stdout.fnmatch_lines(["*1 passed*"])


def test_filterwarnings_prepend_mark_is_most_specific(pytester: Pytester) -> None:
# fmt: off
pytester.makeconftest('import pytest\ndef pytest_collection_modifyitems(items):\n for item in items:\n item.add_marker(pytest.mark.filterwarnings("error"), append=False)\n') # noqa: E501
pytester.makepyfile('import pytest\nimport warnings\n@pytest.mark.parametrize("_", [pytest.param(None, marks=pytest.mark.filterwarnings("ignore"))])\ndef test_prepend_mark(_, request):\n assert request.node.get_closest_marker("filterwarnings").args[0] == "error"\n warnings.warn("prepend")\n') # noqa: E501
# fmt: on
result = pytester.runpytest("-q")
result.stdout.fnmatch_lines(["*1 failed in *"])


def test_non_string_warning_argument(pytester: Pytester) -> None:
"""Non-str argument passed to warning breaks pytest (#2956)"""
pytester.makepyfile(
Expand Down