Skip to content

fix: iter_markers/get_closest_marker returns correct closest MRO marker (#14329) - #14630

Open
RonnyPfannschmidt wants to merge 7 commits into
pytest-dev:mainfrom
RonnyPfannschmidt:fix/iter-markers-mro-closest-first
Open

fix: iter_markers/get_closest_marker returns correct closest MRO marker (#14329)#14630
RonnyPfannschmidt wants to merge 7 commits into
pytest-dev:mainfrom
RonnyPfannschmidt:fix/iter-markers-mro-closest-first

Conversation

@RonnyPfannschmidt

@RonnyPfannschmidt RonnyPfannschmidt commented Jun 20, 2026

Copy link
Copy Markdown
Member

Alternative approach to #14332 for fixing #14329 (get_closest_marker returning the wrong marker with class inheritance).

The regression test is Wintreist's, taken from #14332 — thanks for it, and for the patience.

  • Introduces Node._iter_own_markers_closest_first(), overridden by Class to walk the MRO in natural closest-first order while preserving decorator stacking order within each class
  • Does not change own_markers order — it stays in base-first (construction) order
  • Reverses usefixtures marker iteration to maintain farthest-first fixture setup ordering

Key difference from #14332

#14332 reverses the MRO traversal in get_unpacked_marks, which changes own_markers order on Class nodes. This approach keeps own_markers unchanged and fixes the iteration layer (iter_markers / get_closest_marker), which is the actual consumer that needs closest-first semantics.

The concrete difference is keywords. PyobjMixin.obj does
self.keywords.update((mark.name, mark) for mark in self.own_markers) — last wins — so with
base-first own_markers the closest mark is the one that ends up in keywords. Reversing
own_markers silently flips that to the base mark:

@pytest.mark.foo("base")
class TestBase: pass

@pytest.mark.foo("child")
class TestChild(TestBase):
    def test_it(self, request):
        request.node.parent.keywords["foo"].args[0]  # "child" here / on main, "base" with #14332

No test covers this today (test_collection.py uses distinct mark names across levels), so it
goes unnoticed. It is fixable in #14332 with a reversed(), but it is the reason this PR
targets iteration instead.

Behaviour changes to be aware of

Both this PR and #14332 share these — they follow from closest-first iteration itself, not from
either implementation:

  • Inherited class level parametrize composes IDs in the opposite order.
    _genfunctions consumes definition.iter_markers("parametrize"), so a base class
    @pytest.mark.parametrize("a", ...) combined with a subclass @pytest.mark.parametrize("b", ...)
    now yields TestChild::test_it[x-1] where it used to yield TestChild::test_it[1-x].
    Selections pinned to such IDs and the --last-failed cache need regenerating. Documented in the
    changelog.
  • Stacked usefixtures markers on one node request their fixtures in source order.
    _getusefixturesnames reverses the full iter_markers_with_node result, within a node as well
    as across the collection chain. That is deliberate: markers are stored bottom-up, so reversing
    makes two stacked @pytest.mark.usefixtures decorators behave like a single
    usefixtures("a", "b"). Pinned by test_mark_fixture_order_usefixtures_stacking.

Also here: pytestmark accepts any sequence

Answering @nicoddemus' review question — any sequence will do, not just a list:

class TestThings:
    pytestmark = (pytest.mark.a, pytest.mark.b)   # TypeError before, works now

This started as an accident: routing the MRO walk through the new get_mro_mark_groups()
made tuples work for inherited class marks only, while consider_mro=False and
module/function pytestmark still rejected them. Rather than leave the three paths
disagreeing, they now share one _as_mark_sequence() helper. str/bytes are deliberately
excluded, so an accidental pytestmark = "xfail" still reports the string itself rather than
its first character. Separate changelog/14630.improvement.rst.

Test plan

  • test_mark_closest_mro — closest-first MRO marker resolution (from BugFix #14329 #14332)
  • test_mark_closest_mro_with_dynamic_class_markeradd_marker(append=) keeps its position relative to MRO markers
  • test_mark_mro_marker_object_reused_by_add_marker — the same Mark object reaching own_markers via both the MRO and add_marker no longer aborts collection with RuntimeError: generator raised StopIteration
  • test_mark_fixture_order_usefixtures_stacking — stacked usefixtures order
  • Existing test_mark_mro / get_unpacked_marks tests pass unchanged (own_markers order preserved)
  • Full suite green locally (4357 passed), mypy and pre-commit clean
  • Full CI

Interaction with other open PRs

Comment thread src/_pytest/python.py
Comment thread src/_pytest/python.py Outdated
Comment thread src/_pytest/python.py Outdated
@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the fix/iter-markers-mro-closest-first branch from a1f29c3 to 3c7d2d5 Compare July 14, 2026 08:40
@RonnyPfannschmidt
RonnyPfannschmidt marked this pull request as ready for review July 14, 2026 10:25
Copilot AI review requested due to automatic review settings July 14, 2026 10:25
@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided (automation) changelog entry is part of PR label Jul 14, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes marker resolution for inherited test classes so iter_markers/get_closest_marker return the closest (most-derived) class’ marks first, while attempting to preserve backward-compatible own_markers ordering and existing fixture-setup behavior.

Changes:

  • Add Node._iter_own_markers_closest_first() and switch marker iteration to use it for closest-first semantics.
  • Override marker iteration for python.Class to traverse class MRO in closest-first order while preserving per-class decorator stacking.
  • Adjust usefixtures processing to preserve farthest-first fixture requesting, and add regression tests + changelog entry.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
testing/test_mark.py Adds regression tests for closest-first MRO marker resolution and updates a mock-based test for the new iteration hook.
src/_pytest/python.py Implements Class._iter_own_markers_closest_first() and introduces an override decorator compatibility shim.
src/_pytest/nodes.py Routes marker iteration through _iter_own_markers_closest_first() and updates docstrings to state closest-first behavior.
src/_pytest/fixtures.py Reverses usefixtures marker iteration to keep farthest-first fixture request ordering after marker iteration changes.
changelog/14329.bugfix.rst Documents the bugfix for closest marker resolution with class inheritance.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/_pytest/fixtures.py
Comment on lines +1844 to +1848
# 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"))
Comment thread src/_pytest/python.py Outdated
Comment on lines +83 to +89
if sys.version_info >= (3, 12):
from typing import override
else:

def override(func):
return func

Comment thread src/_pytest/python.py Outdated
Comment on lines +781 to +784
# 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
@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the fix/iter-markers-mro-closest-first branch from c18c2f5 to 9808ef4 Compare July 14, 2026 10:34
@Wintreist

Copy link
Copy Markdown

Hi!
Thank you for continuing to work on this PR. I am really looking forward to its appearance in the main branch of pytest. Once again, I ran into the get_closest_marker problem and will have to write a crutch until you finish the PR.

RonnyPfannschmidt and others added 5 commits August 9, 2026 05:49
…O marker

Fix get_closest_marker and iter_markers to return markers in correct
closest-first order when class inheritance (MRO) is involved (pytest-dev#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 pytest-dev#14332 that preserves own_markers
order for backward compatibility.

Co-authored-by: Cursor AI <ai@cursor.sh>
Co-authored-by: Anthropic Claude Opus 4.6 <claude@anthropic.com>
- 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 <ai@cursor.sh>
Co-authored-by: Anthropic Claude Opus 4.6 <claude@anthropic.com>
Co-authored-by: Cursor AI <ai@cursor.sh>
Co-authored-by: Anthropic Claude Opus 4.6 <claude@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Dynamically added markers on a Class collector were always yielded after
the MRO-inherited markers, so add_marker(append=False) no longer put the
marker closest as it does on a plain node.

Iterate own_markers in stored order and substitute the MRO markers in
closest-first order at their slots, keeping dynamic markers in the
position add_marker gave them. Adds a test asserting prepend/append
ordering relative to the MRO markers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address review feedback on pytest-dev#14630:

* `Class._iter_own_markers_closest_first` matched MRO markers in
  `own_markers` by `id()` and consumed a `next()` per hit. When the same
  `Mark` object reached `own_markers` twice -- e.g. a module-level
  `mark = pytest.mark.foo(1)` used as a class decorator *and* passed to
  `add_marker()` -- the iterator ran dry and collection died with
  `RuntimeError: generator raised StopIteration`. Locate the contiguous
  MRO run by identity instead and substitute it wholesale, falling back
  to the stored order when the run is gone.
* Do not force `obj` resolution from marker iteration; without a
  resolved `obj` there are no MRO markers to reorder anyway.
* Extract `get_mro_mark_groups()` so the MRO/`pytestmark` normalization
  lives in one place instead of being duplicated in `python.py`, and
  build `get_unpacked_marks()` on top of it.
* Move the `override` shim to `_pytest.compat`, next to `deprecated`,
  so type checkers see it on Python < 3.12.
* Spell out why `_getusefixturesnames` reverses within a node too, and
  pin it with a test: stacked `usefixtures` decorators now request their
  fixtures in source order, matching `usefixtures("a", "b")`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the fix/iter-markers-mro-closest-first branch from eb15725 to 35cd12e Compare August 9, 2026 06:19
The regression test for pytest-dev#14329 is Wintreist's, taken verbatim from
pytest-dev#14332; carry the attribution and add them to AUTHORS.

Also spell out the parametrize consequence in the changelog: class level
`parametrize` markers are consumed through `iter_markers`, so inherited
ones now compose IDs closest-first (`[1-x]` -> `[x-1]`). That invalidates
pinned ID selections and the `--last-failed` cache, which is worth more
warning than the marker reorder itself.

Co-authored-by: Wintreist <49996562+Wintreist@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RonnyPfannschmidt added a commit to RonnyPfannschmidt/pytest that referenced this pull request Aug 9, 2026
`_lookup_derived` scanned `own_markers` front-to-back and returned the
first name match, but the eager `keywords.update((mark.name, mark) ...)`
it replaces let *later* markers win. A Class stores MRO-inherited
markers base class first, so `keywords["foo"]` silently changed from the
subclass' marker to the base class' one. Scan in reverse instead.

Note this direction is tied to `own_markers` order: it has to flip if
pytest-dev#14332 lands rather than pytest-dev#14630.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@RonnyPfannschmidt RonnyPfannschmidt mentioned this pull request Aug 9, 2026
Answering Bruno's review question: any sequence will do, not just a
list. Previously a tuple worked nowhere, and the closest-first MRO walk
in this branch had quietly started accepting one for inherited class
marks only -- so make it consistent across modules, classes and
functions instead of leaving the paths disagreeing.

`str`/`bytes` are deliberately not treated as sequences, so an
accidental `pytestmark = "xfail"` keeps reporting the string itself
rather than its first character.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided (automation) changelog entry is part of PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants