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>
@Wintreist

Copy link
Copy Markdown

Hi! Is this Pull Request not forgotten?

@RonnyPfannschmidt

Copy link
Copy Markdown
Member Author

Im just mixed capacity as im at a family vacation atm

@Wintreist

Copy link
Copy Markdown

Oh, I’m sorry.
It just seemed to me that this PR was coming to an end, and everything had been put on hold for three weeks.
Have a good rest! I’m looking forward to your return.

@RonnyPfannschmidt

Copy link
Copy Markdown
Member Author

Np it's absolutely fine to gently ask

This started t the pytest sprint and I intend to complete it soon

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