From 4751d179532dc72f99c49271c05fe3f0f70c0bb3 Mon Sep 17 00:00:00 2001 From: Jan Mrowiec Date: Sun, 6 Sep 2026 21:27:41 +0200 Subject: [PATCH 1/2] Fix subtest reruns with structured NodeId keys --- src/pytest_rerunfailures.py | 38 ++++++++++++++++++-------- tests/test_pytest_rerunfailures.py | 44 ++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/src/pytest_rerunfailures.py b/src/pytest_rerunfailures.py index 98934bc..279d836 100644 --- a/src/pytest_rerunfailures.py +++ b/src/pytest_rerunfailures.py @@ -26,6 +26,14 @@ _failed_subtests_key: Any = None _SubtestReport: Any = None +try: + from _pytest.nodeid import NodeId +except ImportError: + HAS_PYTEST_NODE_ID = False +else: + HAS_PYTEST_NODE_ID = True + del NodeId + try: from _pytest.subtests import SubtestReport as _SubtestReport from _pytest.subtests import failed_subtests_key as _failed_subtests_key @@ -400,6 +408,18 @@ def _remove_failed_setup_state_from_session(item): del setup_state.stack[item] +def _failed_subtests_lookup_key(node_or_report): + """Return the key used by pytest's private ``failed_subtests`` mapping. + + Newer pytest uses structured ``.id`` while older versions use ``.nodeid``. + Detect the pytest API itself because another plugin may add an unrelated + ``id`` attribute. + """ + if HAS_PYTEST_NODE_ID: + return node_or_report.id + return node_or_report.nodeid + + def _remove_failed_subtests_from_report(item, report): """ Clean up failed subtests stash entry. @@ -410,8 +430,9 @@ def _remove_failed_subtests_from_report(item, report): return failed_subtests = item.config.stash.get(failed_subtests_key, None) - if failed_subtests is not None and report.nodeid in failed_subtests: - del failed_subtests[report.nodeid] + key = _failed_subtests_lookup_key(report) + if failed_subtests is not None and key in failed_subtests: + del failed_subtests[key] def _remove_failed_subtest_reports_from_stats( @@ -485,7 +506,7 @@ def is_matching_subtest_report(report): _remove_subtest_reports("subtests passed") -def _get_num_failed_subtests(item, nodeid): +def _get_num_failed_subtests(item): """ Return the number of failed subtests. @@ -496,7 +517,7 @@ def _get_num_failed_subtests(item, nodeid): failed_subtests = item.config.stash.get(failed_subtests_key, None) if failed_subtests is not None: - return failed_subtests.get(nodeid, 0) + return failed_subtests.get(_failed_subtests_lookup_key(item), 0) return 0 @@ -566,9 +587,7 @@ def _should_not_rerun(item, report, reruns): xfail = hasattr(report, "wasxfail") is_terminal_error = any(item._terminal_errors.values()) condition = get_reruns_condition(item) - has_failed_subtests = ( - report.when == "call" and _get_num_failed_subtests(item, report.nodeid) > 0 - ) + has_failed_subtests = report.when == "call" and _get_num_failed_subtests(item) > 0 return ( item.execution_count > reruns @@ -1004,10 +1023,7 @@ def pytest_runtest_teardown(item, nextitem): # which leaves the call phase itself passing. if ( item.execution_count <= reruns - and ( - any(_test_failed_statuses.values()) - or _get_num_failed_subtests(item, item.nodeid) > 0 - ) + and (any(_test_failed_statuses.values()) or _get_num_failed_subtests(item) > 0) and not any(item._test_xfailed.values()) and not any(item._terminal_errors.values()) and get_reruns_condition(item) diff --git a/tests/test_pytest_rerunfailures.py b/tests/test_pytest_rerunfailures.py index 63f3455..092556f 100644 --- a/tests/test_pytest_rerunfailures.py +++ b/tests/test_pytest_rerunfailures.py @@ -13,6 +13,7 @@ StatusDB, SubtestReport, XDistHooks, + _failed_subtests_lookup_key, ) pytest_plugins = "pytester" @@ -2559,6 +2560,22 @@ def test_fail(): assert_outcomes(result, passed=0, failed=1, rerun=0) +@pytest.mark.parametrize( + ("has_node_id_api", "expected"), + [ + pytest.param(False, mock.sentinel.nodeid, id="legacy-api"), + pytest.param(True, mock.sentinel.id, id="structured-api"), + ], +) +def test_failed_subtests_lookup_key_uses_api_capability( + monkeypatch, has_node_id_api, expected +): + monkeypatch.setattr("pytest_rerunfailures.HAS_PYTEST_NODE_ID", has_node_id_api) + report = SimpleNamespace(nodeid=mock.sentinel.nodeid, id=mock.sentinel.id) + + assert _failed_subtests_lookup_key(report) is expected + + @pytest.mark.skipif(not has_subtests, reason="Only supported on pytest 9.0 and newer") def test_failing_subtests_are_rerun(testdir): testdir.makepyfile( @@ -2576,6 +2593,33 @@ def test_subtests(subtests): assert_outcomes(result, passed=1, rerun=1) +@pytest.mark.skipif(not has_subtests, reason="Only supported on pytest 9.0 and newer") +def test_unrelated_report_id_does_not_prevent_failing_subtest_rerun(testdir): + testdir.makeconftest( + """ + import pytest + + @pytest.hookimpl(wrapper=True, tryfirst=True) + def pytest_runtest_makereport(item, call): + report = yield + if not hasattr(type(report), "id"): + report.id = "unrelated-plugin-id" + return report + """ + ) + testdir.makepyfile( + f""" + def test_subtests(subtests): + with subtests.test("Fails on first attempt"): + {indent(temporary_failure(), " ")} + """ + ) + + result = testdir.runpytest("--reruns", "1") + assert result.ret == 0 + assert_outcomes(result, passed=1, rerun=1) + + @pytest.mark.skipif(not has_subtests, reason="Only supported on pytest 9.0 and newer") def test_too_many_failing_subtests_are_failures(testdir): testdir.makepyfile( From ebcdd8a20a6eaef6b823bf8479f9492e786820ac Mon Sep 17 00:00:00 2001 From: Jan Mrowiec Date: Sun, 6 Sep 2026 21:56:13 +0200 Subject: [PATCH 2/2] Add changelog for #363 --- changes/363.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/363.bugfix.rst diff --git a/changes/363.bugfix.rst b/changes/363.bugfix.rst new file mode 100644 index 0000000..298f2bf --- /dev/null +++ b/changes/363.bugfix.rst @@ -0,0 +1 @@ +Fix subtest reruns with newer pytest versions that use structured ``NodeId`` values internally.