Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/363.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix subtest reruns with newer pytest versions that use structured ``NodeId`` values internally.
38 changes: 27 additions & 11 deletions src/pytest_rerunfailures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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(
Expand Down Expand Up @@ -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.

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
44 changes: 44 additions & 0 deletions tests/test_pytest_rerunfailures.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
StatusDB,
SubtestReport,
XDistHooks,
_failed_subtests_lookup_key,
)

pytest_plugins = "pytester"
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down