|
16 | 16 | from _pytest.runner import runtestprotocol |
17 | 17 | from packaging.version import parse as parse_version |
18 | 18 |
|
| 19 | +try: |
| 20 | + from _pytest.subtests import SubtestReport, failed_subtests_key |
| 21 | +except ImportError: |
| 22 | + if pytest.version_tuple >= (9, 0, 0): |
| 23 | + raise |
| 24 | + failed_subtests_key = None |
| 25 | + SubtestReport = None |
| 26 | + |
19 | 27 | try: |
20 | 28 | from xdist.newhooks import pytest_handlecrashitem |
21 | 29 |
|
@@ -293,6 +301,95 @@ def _remove_failed_setup_state_from_session(item): |
293 | 301 | del setup_state.stack[item] |
294 | 302 |
|
295 | 303 |
|
| 304 | +def _remove_failed_subtests_from_report(item, report): |
| 305 | + """ |
| 306 | + Clean up failed subtests stash entry. |
| 307 | +
|
| 308 | + Note: This function does nothing on pytest versions without subtests support. |
| 309 | + """ |
| 310 | + if failed_subtests_key is None: |
| 311 | + return |
| 312 | + |
| 313 | + failed_subtests = item.config.stash.get(failed_subtests_key, None) |
| 314 | + if failed_subtests is not None and report.nodeid in failed_subtests: |
| 315 | + del failed_subtests[report.nodeid] |
| 316 | + |
| 317 | + |
| 318 | +def _remove_failed_subtest_reports_from_stats(item): |
| 319 | + """ |
| 320 | + Remove already-logged SubtestReports for this item from the terminal reporter's |
| 321 | + stats buckets. |
| 322 | +
|
| 323 | + SubtestReports are logged immediately during runtestprotocol (independent of |
| 324 | + log=False), so when a rerun is triggered they must be retroactively removed |
| 325 | + from all stat categories to avoid double-counting on the subsequent run. |
| 326 | +
|
| 327 | + Concretely: |
| 328 | + - Failed SubtestReports land in tr.stats["failed"]. |
| 329 | + - Passed SubtestReports land in tr.stats["subtests passed"]. |
| 330 | + Both must be removed so the final tally only reflects the last (successful) run. |
| 331 | +
|
| 332 | + Note: This function does nothing on pytest versions without subtests support. |
| 333 | + """ |
| 334 | + if SubtestReport is None: |
| 335 | + return |
| 336 | + |
| 337 | + tr = item.config.pluginmanager.get_plugin("terminalreporter") |
| 338 | + if tr is None: |
| 339 | + return |
| 340 | + |
| 341 | + def _remove_subtest_reports(key): |
| 342 | + """ |
| 343 | + Remove SubtestReports for item.nodeid from tr.stats[key]. |
| 344 | +
|
| 345 | + Returns the number of removed reports, and deletes the key entirely when |
| 346 | + the list becomes empty, because some code just checks the presence of |
| 347 | + the 'failed' key, but doesn't check the content. |
| 348 | + """ |
| 349 | + if key not in tr.stats: |
| 350 | + return 0 |
| 351 | + |
| 352 | + num_items_before = len(tr.stats[key]) |
| 353 | + tr.stats[key] = [ |
| 354 | + r |
| 355 | + for r in tr.stats[key] |
| 356 | + if not isinstance(r, SubtestReport) or r.nodeid != item.nodeid |
| 357 | + ] |
| 358 | + num_items_removed = num_items_before - len(tr.stats[key]) |
| 359 | + |
| 360 | + if not tr.stats[key]: |
| 361 | + del tr.stats[key] |
| 362 | + |
| 363 | + return num_items_removed |
| 364 | + |
| 365 | + failed_removed = _remove_subtest_reports("failed") |
| 366 | + if failed_removed > 0: |
| 367 | + # Decrement session.testsfailed which was incremented when the |
| 368 | + # SubtestReport was originally logged via pytest_runtest_logreport. |
| 369 | + item.session.testsfailed = max(0, item.session.testsfailed - failed_removed) |
| 370 | + |
| 371 | + # When a test is rerun, subtests that already passed on the first attempt |
| 372 | + # will run again and produce a second SUBPASSED report. Remove the first |
| 373 | + # run's SUBPASSED entries so the count reflects each subtest exactly once. |
| 374 | + _remove_subtest_reports("subtests passed") |
| 375 | + |
| 376 | + |
| 377 | +def _get_num_failed_subtests(item, report): |
| 378 | + """ |
| 379 | + Return the number of failed subtests. |
| 380 | +
|
| 381 | + Note: Returns 0 on pytest versions without subtests support. |
| 382 | + """ |
| 383 | + if failed_subtests_key is None: |
| 384 | + return 0 |
| 385 | + |
| 386 | + failed_subtests = item.config.stash.get(failed_subtests_key, None) |
| 387 | + if failed_subtests is not None: |
| 388 | + return failed_subtests.get(report.nodeid, 0) |
| 389 | + |
| 390 | + return 0 |
| 391 | + |
| 392 | + |
296 | 393 | def _get_rerun_filter_regex(item, regex_name): |
297 | 394 | rerun_marker = _get_marker(item) |
298 | 395 |
|
@@ -358,9 +455,13 @@ def _should_not_rerun(item, report, reruns): |
358 | 455 | xfail = hasattr(report, "wasxfail") |
359 | 456 | is_terminal_error = item._terminal_errors[report.when] |
360 | 457 | condition = get_reruns_condition(item) |
| 458 | + has_failed_subtests = ( |
| 459 | + report.when == "call" and _get_num_failed_subtests(item, report) > 0 |
| 460 | + ) |
| 461 | + |
361 | 462 | return ( |
362 | 463 | item.execution_count > reruns |
363 | | - or not report.failed |
| 464 | + or (not report.failed and not has_failed_subtests) |
364 | 465 | or xfail |
365 | 466 | or is_terminal_error |
366 | 467 | or not condition |
@@ -648,6 +749,8 @@ def pytest_runtest_protocol(item, nextitem): |
648 | 749 | # cleanin item's cashed results from any level of setups |
649 | 750 | _remove_cached_results_from_failed_fixtures(item) |
650 | 751 | _remove_failed_setup_state_from_session(item) |
| 752 | + _remove_failed_subtests_from_report(item, report) |
| 753 | + _remove_failed_subtest_reports_from_stats(item) |
651 | 754 |
|
652 | 755 | break # trigger rerun |
653 | 756 | else: |
|
0 commit comments