From 0ce4b0b067763ad2d1c4e5f098f9a62cb693eeac Mon Sep 17 00:00:00 2001 From: Deepak Ganesh Date: Sat, 25 Jul 2026 14:41:00 +0530 Subject: [PATCH 1/2] Fix marker precedence for parametrized tests Apply closest markers last across callspec and warning-filter paths while preserving same-node decorator ordering. Closes #10406 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- changelog/10406.bugfix.rst | 1 + src/_pytest/nodes.py | 10 ++++- src/_pytest/python.py | 37 +++++++++++++++++ src/_pytest/warnings.py | 11 +++-- testing/test_mark.py | 26 ++++++++++++ testing/test_warnings.py | 84 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 changelog/10406.bugfix.rst 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..12d56f52ba5 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,31 @@ def test_custom_mark_parametrized(obj_type): result.assert_outcomes(passed=4) +def test_parametrize_marks_are_closest(pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + + @pytest.mark.custom_mark("module") + class TestMarkers: + @pytest.mark.custom_mark("function") + @pytest.mark.parametrize( + "_", + [pytest.param(None, marks=pytest.mark.custom_mark("parametrize"))], + ) + def test_marker_order(self, _, request): + assert [mark.args[0] for mark in request.node.iter_markers("custom_mark")] == [ + "parametrize", + "function", + "module", + ] + assert request.node.get_closest_marker("custom_mark").args[0] == "parametrize" + """ + ) + 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..35edc37f112 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -209,6 +209,90 @@ 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: + pytester.makepyfile( + """ + import pytest + import warnings + + @pytest.mark.filterwarnings("error") + class TestMarkClass: + @pytest.mark.parametrize( + "_", + [pytest.param(None, marks=pytest.mark.filterwarnings("ignore"))], + ) + def test_class_mark(self, _): + warnings.warn("class") + + class TestMarkMethods: + @pytest.mark.filterwarnings("error") + @pytest.mark.parametrize( + "_", + [pytest.param(None, marks=pytest.mark.filterwarnings("ignore"))], + ) + def test_function_mark_before_parametrize(self, _): + warnings.warn("function-before") + + @pytest.mark.parametrize( + "_", + [pytest.param(None, marks=pytest.mark.filterwarnings("ignore"))], + ) + @pytest.mark.filterwarnings("error") + def test_function_mark_after_parametrize(self, _): + warnings.warn("function-after") + """ + ) + result = pytester.runpytest_subprocess("-q") + + result.stdout.fnmatch_lines(["*3 passed*"]) + + +def test_filterwarnings_mark_preserves_decorator_order(pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + import warnings + + @pytest.mark.filterwarnings("ignore:api v1") + @pytest.mark.filterwarnings("error") + def test_decorator_order(): + warnings.warn(UserWarning("api v1")) + """ + ) + result = pytester.runpytest_subprocess("-q") + + result.stdout.fnmatch_lines(["*1 passed*"]) + + +def test_filterwarnings_prepend_mark_is_most_specific(pytester: Pytester) -> None: + pytester.makeconftest( + """ + import pytest + + def pytest_collection_modifyitems(items): + for item in items: + item.add_marker(pytest.mark.filterwarnings("error"), append=False) + """ + ) + pytester.makepyfile( + """ + import pytest + import warnings + + @pytest.mark.parametrize( + "_", + [pytest.param(None, marks=pytest.mark.filterwarnings("ignore"))], + ) + def test_prepend_mark(_, request): + assert request.node.get_closest_marker("filterwarnings").args[0] == "error" + warnings.warn("prepend") + """ + ) + result = pytester.runpytest_subprocess("-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( From 705b12767a7af1ba8c36bcc5eb143bb7d7b770c0 Mon Sep 17 00:00:00 2001 From: Deepak Ganesh Date: Sat, 25 Jul 2026 14:57:09 +0530 Subject: [PATCH 2/2] Improve coverage for marker precedence regressions Run warning precedence cases in-process and keep generated source compact so patch coverage observes the exercised branches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- testing/test_mark.py | 23 ++--------- testing/test_warnings.py | 85 ++++++---------------------------------- 2 files changed, 16 insertions(+), 92 deletions(-) diff --git a/testing/test_mark.py b/testing/test_mark.py index 12d56f52ba5..5602a43e3f2 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -1184,26 +1184,9 @@ def test_custom_mark_parametrized(obj_type): def test_parametrize_marks_are_closest(pytester: Pytester) -> None: - pytester.makepyfile( - """ - import pytest - - @pytest.mark.custom_mark("module") - class TestMarkers: - @pytest.mark.custom_mark("function") - @pytest.mark.parametrize( - "_", - [pytest.param(None, marks=pytest.mark.custom_mark("parametrize"))], - ) - def test_marker_order(self, _, request): - assert [mark.args[0] for mark in request.node.iter_markers("custom_mark")] == [ - "parametrize", - "function", - "module", - ] - assert request.node.get_closest_marker("custom_mark").args[0] == "parametrize" - """ - ) + # 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) diff --git a/testing/test_warnings.py b/testing/test_warnings.py index 35edc37f112..6f300b73a13 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -210,86 +210,27 @@ def test_show_warning(): def test_filterwarnings_parametrize_mark_is_most_specific(pytester: Pytester) -> None: - pytester.makepyfile( - """ - import pytest - import warnings - - @pytest.mark.filterwarnings("error") - class TestMarkClass: - @pytest.mark.parametrize( - "_", - [pytest.param(None, marks=pytest.mark.filterwarnings("ignore"))], - ) - def test_class_mark(self, _): - warnings.warn("class") - - class TestMarkMethods: - @pytest.mark.filterwarnings("error") - @pytest.mark.parametrize( - "_", - [pytest.param(None, marks=pytest.mark.filterwarnings("ignore"))], - ) - def test_function_mark_before_parametrize(self, _): - warnings.warn("function-before") - - @pytest.mark.parametrize( - "_", - [pytest.param(None, marks=pytest.mark.filterwarnings("ignore"))], - ) - @pytest.mark.filterwarnings("error") - def test_function_mark_after_parametrize(self, _): - warnings.warn("function-after") - """ - ) - result = pytester.runpytest_subprocess("-q") - + # 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: - pytester.makepyfile( - """ - import pytest - import warnings - - @pytest.mark.filterwarnings("ignore:api v1") - @pytest.mark.filterwarnings("error") - def test_decorator_order(): - warnings.warn(UserWarning("api v1")) - """ - ) - result = pytester.runpytest_subprocess("-q") - + # 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: - pytester.makeconftest( - """ - import pytest - - def pytest_collection_modifyitems(items): - for item in items: - item.add_marker(pytest.mark.filterwarnings("error"), append=False) - """ - ) - pytester.makepyfile( - """ - import pytest - import warnings - - @pytest.mark.parametrize( - "_", - [pytest.param(None, marks=pytest.mark.filterwarnings("ignore"))], - ) - def test_prepend_mark(_, request): - assert request.node.get_closest_marker("filterwarnings").args[0] == "error" - warnings.warn("prepend") - """ - ) - result = pytester.runpytest_subprocess("-q") - + # 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 *"])