From 2a7acaa9c56dd607784c3ff103179197852eecd5 Mon Sep 17 00:00:00 2001 From: ace2016 <23010364+ace2016@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:52:40 +0100 Subject: [PATCH] Fix parameter mark precedence Co-authored-by: Codex --- changelog/10406.bugfix.rst | 3 +++ src/_pytest/nodes.py | 6 ++++- src/_pytest/python.py | 25 +++++++++++++++++++- src/_pytest/warnings.py | 10 +++++--- testing/test_mark.py | 34 +++++++++++++++++++++++++++ testing/test_warnings.py | 48 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 121 insertions(+), 5 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..4d8e48dfbfc --- /dev/null +++ b/changelog/10406.bugfix.rst @@ -0,0 +1,3 @@ +Parameter-set marks now take precedence over marks on the test function, +class, or module. This also fixes ``filterwarnings`` precedence across those +levels while preserving the documented ordering of filters defined together. diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index f0629c2daf7..569d179f53e 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -344,10 +344,14 @@ 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) -> Iterable[Mark]: + """Iterate over this node's markers from closest to farthest.""" + return 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 eb481393ba1..6376533087f 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1634,7 +1634,10 @@ def __init__( # Note: when FunctionDefinition is introduced, we should change ``originalname`` # to a readonly property that returns FunctionDefinition.name. - self.own_markers.extend(get_unpacked_marks(self.obj)) + function_markers = get_unpacked_marks(self.obj) + self.own_markers.extend(function_markers) + self._function_markers_count = len(function_markers) + self._prepended_markers_count = 0 if callspec: self.callspec = callspec self.own_markers.extend(callspec.marks) @@ -1656,6 +1659,26 @@ def __init__( self.fixturenames = fixtureinfo.names_closure self._initrequest() + def add_marker(self, marker: str | MarkDecorator, append: bool = True) -> None: + super().add_marker(marker, append=append) + if not append: + self._prepended_markers_count += 1 + + def _iter_own_markers_closest_first(self) -> Iterator[Mark]: + """Yield parameter-set marks before function-level marks.""" + if not hasattr(self, "callspec"): + yield from self.own_markers + return + + function_start = self._prepended_markers_count + function_end = function_start + self._function_markers_count + callspec_end = function_end + len(self.callspec.marks) + + yield from self.own_markers[:function_start] + yield from self.own_markers[function_end:callspec_end] + yield from self.own_markers[function_start:function_end] + yield from self.own_markers[callspec_end:] + # todo: determine sound type limitations @classmethod def from_parent(cls, parent, **kw) -> Self: diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index d599d6c27e6..9c85eda4272 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -49,9 +49,13 @@ 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.own_markers: + if mark.name == "filterwarnings": + 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..b64b9ff317d 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 = session.own_markers session.parent = None session.nodeid = "" session.path = pytester.path @@ -1182,6 +1183,39 @@ def test_custom_mark_parametrized(obj_type): result.assert_outcomes(passed=4) +def test_parametrize_mark_is_closest(pytester: Pytester) -> None: + """Marks on a parameter set take precedence over function marks (#10406).""" + pytester.makepyfile( + """ + import pytest + + pytestmark = pytest.mark.priority("module") + + @pytest.mark.priority("class") + class TestMarkers: + @pytest.mark.priority("function") + @pytest.mark.parametrize( + "value", + [pytest.param(None, marks=pytest.mark.priority("parametrize"))], + ) + def test_mark_order(self, value, request): + priorities = [ + mark.args[0] + for mark in request.node.iter_markers("priority") + ] + assert priorities == [ + "parametrize", + "function", + "class", + "module", + ] + """ + ) + + 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 e2363bd884a..a57b242b33f 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -425,6 +425,54 @@ def test(): result.stdout.fnmatch_lines(["* 1 failed in*"]) +def test_filterwarnings_mark_hierarchy_precedence(pytester: Pytester) -> None: + """The closest filterwarnings mark should take precedence (#10406).""" + pytester.makepyfile( + """ + import warnings + + import pytest + + + @pytest.mark.filterwarnings("error") + class TestWarnings: + @pytest.mark.parametrize( + "value", + [ + pytest.param( + None, + marks=pytest.mark.filterwarnings("ignore"), + ) + ], + ) + def test_parametrize_over_class(self, value): + warnings.warn("parametrize over class") + + @pytest.mark.filterwarnings("error") + @pytest.mark.parametrize( + "value", + [ + pytest.param( + None, + marks=pytest.mark.filterwarnings("ignore"), + ) + ], + ) + def test_parametrize_over_function(self, value): + warnings.warn("parametrize over function") + + + @pytest.mark.filterwarnings("ignore:specific") + @pytest.mark.filterwarnings("error") + def test_same_level_decorator_order(): + warnings.warn("specific") + """ + ) + + result = pytester.runpytest() + result.assert_outcomes(passed=3) + + def test_accept_unknown_category(pytester: Pytester) -> None: """Category types that can't be imported don't cause failure (#13732).""" pytester.makeini(