diff --git a/changelog/10406.bugfix.rst b/changelog/10406.bugfix.rst new file mode 100644 index 00000000000..ffe536dd190 --- /dev/null +++ b/changelog/10406.bugfix.rst @@ -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. diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index f0629c2daf7..2d0c67b822f 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -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: ... diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 4f168012c36..d68b1d42ff9 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -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 @@ -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) diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index 2c721d6e3c9..aff2398c709 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -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 diff --git a/testing/test_mark.py b/testing/test_mark.py index 253cda94503..5602a43e3f2 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -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 @@ -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] diff --git a/testing/test_warnings.py b/testing/test_warnings.py index 6b439eb4b33..6f300b73a13 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -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(