From c4c5fa9b476a62722b5b9a2d12760f5f633ba5e2 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 19 Jun 2026 20:31:50 +0200 Subject: [PATCH 1/3] fix: iter_markers/get_closest_marker now correctly returns closest MRO marker Fix get_closest_marker and iter_markers to return markers in correct closest-first order when class inheritance (MRO) is involved (#14329). Previously, own_markers on Class nodes stored MRO-inherited markers in base-first (farthest) order, and iter_markers yielded them in that same order. This caused get_closest_marker to return a base class marker instead of the overriding child class marker. The fix introduces _iter_own_markers_closest_first() on Node, overridden by Class to walk the MRO in natural closest-first order while preserving decorator stacking order within each class. This avoids changing own_markers (keeping its base-first construction order) and avoids breaking parametrize naming order. Also reverses usefixtures marker iteration to maintain farthest-first setup ordering (module -> base class -> child class -> function). Alternative structural approach to PR #14332 that preserves own_markers order for backward compatibility. Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4.6 --- src/_pytest/fixtures.py | 7 ++++++- src/_pytest/nodes.py | 15 ++++++++++++++- src/_pytest/python.py | 22 ++++++++++++++++++++++ testing/test_mark.py | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index f4ca2eac455..2ef737afc2d 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1841,7 +1841,12 @@ def _getautousenames(self, node: nodes.Node) -> Iterator[str]: def _getusefixturesnames(self, node: nodes.Item) -> Iterator[str]: """Return the names of usefixtures fixtures applicable to node.""" - for marker_node, mark in node.iter_markers_with_node(name="usefixtures"): + # Reverse order (farthest to closest) is more natural for usefixtures, + # e.g. want a module-level usefixture to be requested before a class one, + # a parent class' before a child's, etc. + for marker_node, mark in reversed( + list(node.iter_markers_with_node(name="usefixtures")) + ): if not mark.args: marker_node.warn( PytestWarning( diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index f0629c2daf7..acbf847565e 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -330,6 +330,8 @@ def add_marker(self, marker: str | MarkDecorator, append: bool = True) -> None: def iter_markers(self, name: str | None = None) -> Iterator[Mark]: """Iterate over all markers of the node. + The markers are returned from closest to farthest. + :param name: If given, filter the results by the name attribute. :returns: An iterator of the markers of the node. """ @@ -340,14 +342,25 @@ def iter_markers_with_node( ) -> Iterator[tuple[Node, Mark]]: """Iterate over all markers of the node. + The markers are returned from closest to farthest. + :param name: If given, filter the results by the name attribute. :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]: + """Yield own markers in closest-first order. + + For most nodes this is just own_markers in order. + Overridden by nodes whose own_markers contain markers from + multiple levels (e.g. Class nodes with MRO-inherited markers). + """ + 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..c59732ee7ff 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -751,6 +751,28 @@ def from_parent(cls, parent, *, name, obj=None, **kw) -> Self: # type: ignore[o """The public constructor.""" return super().from_parent(name=name, parent=parent, **kw) + def _iter_own_markers_closest_first(self) -> Iterator[Mark]: + """own_markers stores MRO markers in base-first order + (construction order). For closest-first iteration, reverse at the + MRO class-group level while preserving decorator order within + each class.""" + from _pytest.mark.structures import normalize_mark_list + + # Walk MRO in natural order (closest first: Child, Parent, ...) + # yielding each class's marks in their decorator-stacking order. + mro_mark_ids: set[int] = set() + for cls in self.obj.__mro__: + cls_marks = cls.__dict__.get("pytestmark", []) + if not isinstance(cls_marks, list): + cls_marks = [cls_marks] + for mark in normalize_mark_list(cls_marks): + mro_mark_ids.add(id(mark)) + yield mark + # Yield any dynamically added markers (via add_marker) not from MRO. + for mark in self.own_markers: + if id(mark) not in mro_mark_ids: + yield mark + def newinstance(self): return self.obj() diff --git a/testing/test_mark.py b/testing/test_mark.py index 253cda94503..e66f01de8ba 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -656,6 +656,40 @@ def test_has_inherited(self): assert has_inherited_marker.kwargs == {"location": "class"} assert has_own.get_closest_marker("missing") is None + def test_mark_closest_mro(self, pytester: Pytester) -> None: + """Marks should be collected from MRO from nearest to furthest (#14329).""" + pytester.makepyfile( + """ + import pytest + + + @pytest.mark.foo(0) + class TestParent: + def test_only_class(self, request): + assert request.node.get_closest_marker("foo").args[0] == 0 + assert [mark.args[0] for mark in request.node.iter_markers("foo")] == [0] + + @pytest.mark.foo(1) + def test_function_and_class(self, request): + assert request.node.get_closest_marker("foo").args[0] == 1 + assert [mark.args[0] for mark in request.node.iter_markers("foo")] == [1, 0] + + + @pytest.mark.foo(2) + class TestChild(TestParent): + def test_only_class(self, request): + assert request.node.get_closest_marker("foo").args[0] == 2 + assert [mark.args[0] for mark in request.node.iter_markers("foo")] == [2, 0] + + @pytest.mark.foo(3) + def test_function_and_class(self, request): + assert request.node.get_closest_marker("foo").args[0] == 3 + assert [mark.args[0] for mark in request.node.iter_markers("foo")] == [3, 2, 0] + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=4) + def test_mark_with_wrong_marker(self, pytester: Pytester) -> None: reprec = pytester.inline_runsource( """ @@ -1133,6 +1167,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 From 3c7d2d5e356c6d768dacc54b2429d4d94e6399f7 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Tue, 14 Jul 2026 10:38:31 +0200 Subject: [PATCH 2/3] fix: address review comments on MRO marker iteration - Add @override decorator to Class._iter_own_markers_closest_first (with version-gated import for Python <3.12) - Broaden isinstance check from list to Sequence for cls_marks - Add test for dynamically added markers on Class collectors Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4.6 --- src/_pytest/python.py | 12 +++++++++++- testing/test_mark.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index c59732ee7ff..05ab7674845 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -21,6 +21,7 @@ import os from pathlib import Path import re +import sys import textwrap import types from typing import Any @@ -79,6 +80,14 @@ from _pytest.warning_types import PytestReturnNotNoneWarning +if sys.version_info >= (3, 12): + from typing import override +else: + + def override(func): + return func + + if TYPE_CHECKING: from typing_extensions import Self @@ -751,6 +760,7 @@ def from_parent(cls, parent, *, name, obj=None, **kw) -> Self: # type: ignore[o """The public constructor.""" return super().from_parent(name=name, parent=parent, **kw) + @override def _iter_own_markers_closest_first(self) -> Iterator[Mark]: """own_markers stores MRO markers in base-first order (construction order). For closest-first iteration, reverse at the @@ -763,7 +773,7 @@ def _iter_own_markers_closest_first(self) -> Iterator[Mark]: mro_mark_ids: set[int] = set() for cls in self.obj.__mro__: cls_marks = cls.__dict__.get("pytestmark", []) - if not isinstance(cls_marks, list): + if not isinstance(cls_marks, Sequence): cls_marks = [cls_marks] for mark in normalize_mark_list(cls_marks): mro_mark_ids.add(id(mark)) diff --git a/testing/test_mark.py b/testing/test_mark.py index e66f01de8ba..23f77f6bd1e 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -690,6 +690,40 @@ def test_function_and_class(self, request): result = pytester.runpytest() result.assert_outcomes(passed=4) + def test_mark_closest_mro_with_dynamic_class_marker( + self, pytester: Pytester + ) -> None: + """Dynamic markers added to a Class collector via add_marker appear + alongside MRO markers in iter_markers (#14329).""" + pytester.makeconftest( + """ + import pytest + + def pytest_collectstart(collector): + if getattr(collector, "name", None) == "TestChild": + collector.add_marker(pytest.mark.foo(99)) + """ + ) + pytester.makepyfile( + """ + import pytest + + @pytest.mark.foo(0) + class TestBase: + pass + + @pytest.mark.foo(1) + class TestChild(TestBase): + def test_it(self, request): + names = [m.args[0] for m in request.node.iter_markers("foo")] + # MRO markers (child=1, base=0) plus dynamic marker (99) + assert 99 in names + assert names.index(1) < names.index(0) + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1) + def test_mark_with_wrong_marker(self, pytester: Pytester) -> None: reprec = pytester.inline_runsource( """ From 9808ef459c073ee76e4c05eecabf89f027ee9a19 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Tue, 14 Jul 2026 12:25:14 +0200 Subject: [PATCH 3/3] doc: add changelog fragment for #14329 Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4.6 Co-authored-by: Cursor --- changelog/14329.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/14329.bugfix.rst diff --git a/changelog/14329.bugfix.rst b/changelog/14329.bugfix.rst new file mode 100644 index 00000000000..9307866c032 --- /dev/null +++ b/changelog/14329.bugfix.rst @@ -0,0 +1 @@ +Fixed ``get_closest_marker`` returning a base class marker instead of the subclass marker when test classes use inheritance.