From 4c8090b63b631e905c19d6910159d3d8b93a21c8 Mon Sep 17 00:00:00 2001 From: Louis Deconinck Date: Thu, 17 Sep 2026 21:28:16 +0200 Subject: [PATCH 1/2] Disable reruns with a warning instead of erroring under --pdb Combining --pdb with --reruns/--force-reruns/a flaky marker previously raised a UsageError. Someone reaching for the debugger almost never wants reruns, so warn and run each test once instead. Closes #248. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.rst | 3 +- changes/248.feature.rst | 3 ++ src/pytest_rerunfailures.py | 43 ++++++++++++++----- tests/test_pytest_rerunfailures.py | 69 +++++++++++++++++++++++------- 4 files changed, 91 insertions(+), 27 deletions(-) create mode 100644 changes/248.feature.rst diff --git a/README.rst b/README.rst index 747072ee..675bc789 100644 --- a/README.rst +++ b/README.rst @@ -353,7 +353,8 @@ Compatibility ------------- * This plugin is *not* compatible with pytest-xdist's --looponfail flag. -* This plugin is *not* compatible with the core --pdb flag. +* When the core --pdb flag is used, reruns are disabled (a warning is + emitted and tests are run once, as if ``--reruns 0`` was passed). * This plugin is *not* compatible with the plugin `flaky `_, you can only have ``pytest-rerunfailures`` or ``flaky`` but not both. diff --git a/changes/248.feature.rst b/changes/248.feature.rst new file mode 100644 index 00000000..8a92778b --- /dev/null +++ b/changes/248.feature.rst @@ -0,0 +1,3 @@ +Emit a warning and disable reruns instead of raising a ``UsageError`` when +``--pdb`` is combined with ``--reruns``, ``--force-reruns`` or the ``flaky`` +marker. diff --git a/src/pytest_rerunfailures.py b/src/pytest_rerunfailures.py index 616e658b..d5fd7679 100644 --- a/src/pytest_rerunfailures.py +++ b/src/pytest_rerunfailures.py @@ -229,7 +229,7 @@ def check_options(config): reruns = config.getoption("force_reruns") or _get_global_reruns(config) if not config.getoption("collectonly") and reruns: if config.option.usepdb: # a core option - raise pytest.UsageError("--reruns incompatible with --pdb") + _warn_pdb_disables_reruns(config) for name in ("only_rerun", "rerun_except"): for pattern in getattr(config.option, name) or config.getini(name): @@ -245,9 +245,30 @@ def _get_marker(item): return item.get_closest_marker("flaky") +def _warn_pdb_disables_reruns(config, item=None): + """Warn once that --pdb disables reruns. + + Called at config time for command-line/ini reruns and per item for + marker-requested reruns, so emit through the channel that pytest records + in each case. + """ + if getattr(config, "_pdb_reruns_warning_issued", False): + return + config._pdb_reruns_warning_issued = True + warning = pytest.PytestWarning( + "--reruns incompatible with --pdb: reruns are disabled" + ) + if item is not None: + item.warn(warning) + else: + config.issue_config_time_warning(warning, stacklevel=2) + + def get_reruns_count(item): reruns = item.session.config.getoption("force_reruns") if reruns is not None: + if item.session.config.option.usepdb: + return 0 return reruns rerun_marker = _get_marker(item) @@ -265,10 +286,18 @@ def get_reruns_count(item): if item.session.config.getoption("reruns_mode") == "append": global_reruns = _get_global_reruns(item.session.config) if global_reruns is not None: - return marker_reruns + global_reruns - return marker_reruns + marker_reruns += global_reruns + reruns = marker_reruns + else: + reruns = _get_global_reruns(item.session.config) - return _get_global_reruns(item.session.config) + if reruns and item.session.config.option.usepdb: + # --pdb disables reruns; the global/force-reruns case already warned + # in check_options(), this catches marker-requested reruns. + _warn_pdb_disables_reruns(item.session.config, item=item) + return 0 + + return reruns def get_reruns_delay(item): @@ -1241,12 +1270,6 @@ def pytest_runtest_protocol(item, nextitem): # flaky return - if reruns and item.session.config.option.usepdb: - # the global options are already rejected in check_options(); this - # catches reruns requested via the flaky marker, which are only - # known once the item is available - raise pytest.UsageError("--reruns incompatible with --pdb") - delay = get_reruns_delay(item) delay_backoff_factor = get_reruns_delay_backoff_factor(item) parallel = not is_master(item.config) diff --git a/tests/test_pytest_rerunfailures.py b/tests/test_pytest_rerunfailures.py index a4208c50..e0c9101e 100644 --- a/tests/test_pytest_rerunfailures.py +++ b/tests/test_pytest_rerunfailures.py @@ -91,10 +91,33 @@ def assert_outcomes( check_outcome_field(outcomes, "rerun", rerun) -def test_error_when_run_with_pdb(testdir): - testdir.makepyfile("def test_pass(): pass") - result = testdir.runpytest("--reruns", "1", "--pdb") - result.stderr.fnmatch_lines_random("ERROR: --reruns incompatible with --pdb") +def make_dummy_pdb(testdir): + """Create a pdb class that does not enter an interactive session.""" + testdir.makepyfile( + nopdb=""" + class DummyPdb: + quitting = False + + def __init__(self, *args, **kwargs): + pass + + def reset(self): + pass + + def interaction(self, *args, **kwargs): + pass + """ + ) + + +def test_pdb_disables_reruns(testdir): + make_dummy_pdb(testdir) + testdir.makepyfile("def test_fail(): assert False") + result = testdir.runpytest("--reruns", "1", "--pdb", "--pdbcls=nopdb:DummyPdb") + assert_outcomes(result, passed=0, failed=1, rerun=0) + result.stdout.fnmatch_lines_random( + "*--reruns incompatible with --pdb: reruns are disabled" + ) def test_no_error_when_run_with_pdb_without_reruns(testdir): @@ -109,30 +132,44 @@ def test_no_error_when_run_with_pdb_and_zero_reruns(testdir): assert_outcomes(result) -def test_error_when_run_with_pdb_and_reruns_ini(testdir): - testdir.makepyfile("def test_pass(): pass") +def test_pdb_disables_reruns_ini(testdir): + make_dummy_pdb(testdir) + testdir.makepyfile("def test_fail(): assert False") testdir.makeini("[pytest]\nreruns = 1\n") - result = testdir.runpytest("--pdb") - result.stderr.fnmatch_lines_random("ERROR: --reruns incompatible with --pdb") + result = testdir.runpytest("--pdb", "--pdbcls=nopdb:DummyPdb") + assert_outcomes(result, passed=0, failed=1, rerun=0) + result.stdout.fnmatch_lines_random( + "*--reruns incompatible with --pdb: reruns are disabled" + ) -def test_error_when_run_with_pdb_and_force_reruns(testdir): - testdir.makepyfile("def test_pass(): pass") - result = testdir.runpytest("--force-reruns", "1", "--pdb") - result.stderr.fnmatch_lines_random("ERROR: --reruns incompatible with --pdb") +def test_pdb_disables_force_reruns(testdir): + make_dummy_pdb(testdir) + testdir.makepyfile("def test_fail(): assert False") + result = testdir.runpytest( + "--force-reruns", "1", "--pdb", "--pdbcls=nopdb:DummyPdb" + ) + assert_outcomes(result, passed=0, failed=1, rerun=0) + result.stdout.fnmatch_lines_random( + "*--reruns incompatible with --pdb: reruns are disabled" + ) -def test_error_when_run_with_pdb_and_flaky_marker(testdir): +def test_pdb_disables_reruns_with_flaky_marker(testdir): testdir.makepyfile( """ import pytest @pytest.mark.flaky(reruns=1) - def test_pass(): pass + def test_fail(): assert False """ ) - result = testdir.runpytest("--pdb") - result.stderr.fnmatch_lines_random("*--reruns incompatible with --pdb") + make_dummy_pdb(testdir) + result = testdir.runpytest("--pdb", "--pdbcls=nopdb:DummyPdb") + assert_outcomes(result, passed=0, failed=1, rerun=0) + result.stdout.fnmatch_lines_random( + "*--reruns incompatible with --pdb: reruns are disabled" + ) def test_no_rerun_on_pass(testdir): From 3c0ae797d6add5131e68f27547f4f872b5838348 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 06:32:26 +0000 Subject: [PATCH 2/2] Do not let -W error escalate the --pdb reruns warning Escalated PytestWarning instances (from -W error or filterwarnings = error) are caught and re-emitted under an "always" filter so the run continues with reruns disabled instead of aborting with an INTERNALERROR, and the warning is still recorded for the warnings summary. Also bump the issue_config_time_warning stacklevel to 3 so the warning points at check_options() rather than the helper's internals. Co-Authored-By: Louis Deconinck --- src/pytest_rerunfailures.py | 26 +++++++++++++++++++----- tests/test_pytest_rerunfailures.py | 32 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/pytest_rerunfailures.py b/src/pytest_rerunfailures.py index d5fd7679..c2103fd4 100644 --- a/src/pytest_rerunfailures.py +++ b/src/pytest_rerunfailures.py @@ -250,7 +250,9 @@ def _warn_pdb_disables_reruns(config, item=None): Called at config time for command-line/ini reruns and per item for marker-requested reruns, so emit through the channel that pytest records - in each case. + in each case. ``-W error`` / ``filterwarnings = error`` must not escalate + the warning into an INTERNALERROR, so an escalated warning is re-emitted + under an "always" filter and recorded through pytest's warning hook. """ if getattr(config, "_pdb_reruns_warning_issued", False): return @@ -258,10 +260,24 @@ def _warn_pdb_disables_reruns(config, item=None): warning = pytest.PytestWarning( "--reruns incompatible with --pdb: reruns are disabled" ) - if item is not None: - item.warn(warning) - else: - config.issue_config_time_warning(warning, stacklevel=2) + try: + if item is not None: + item.warn(warning) + else: + config.issue_config_time_warning(warning, stacklevel=3) + except Warning: + with warnings.catch_warnings(record=True) as records: + warnings.simplefilter("always", type(warning)) + warnings.warn(warning, stacklevel=3) + for record in records: + config.hook.pytest_warning_recorded.call_historic( + kwargs=dict( + warning_message=record, + when="config" if item is None else "runtest", + nodeid="" if item is None else item.nodeid, + location=None, + ) + ) def get_reruns_count(item): diff --git a/tests/test_pytest_rerunfailures.py b/tests/test_pytest_rerunfailures.py index e0c9101e..892b5139 100644 --- a/tests/test_pytest_rerunfailures.py +++ b/tests/test_pytest_rerunfailures.py @@ -172,6 +172,38 @@ def test_fail(): assert False ) +def test_pdb_disables_reruns_with_warnings_as_errors(testdir): + """`-W error` must not escalate the warning into an INTERNALERROR.""" + make_dummy_pdb(testdir) + testdir.makepyfile("def test_fail(): assert False") + result = testdir.runpytest( + "-W", "error", "--reruns", "1", "--pdb", "--pdbcls=nopdb:DummyPdb" + ) + assert_outcomes(result, passed=0, failed=1, rerun=0) + result.stdout.no_fnmatch_line("INTERNALERROR*") + result.stdout.fnmatch_lines_random( + "*--reruns incompatible with --pdb: reruns are disabled" + ) + + +def test_pdb_disables_reruns_marker_with_warnings_as_errors(testdir): + make_dummy_pdb(testdir) + testdir.makepyfile( + """ + import pytest + + @pytest.mark.flaky(reruns=1) + def test_fail(): assert False + """ + ) + result = testdir.runpytest("-W", "error", "--pdb", "--pdbcls=nopdb:DummyPdb") + assert_outcomes(result, passed=0, failed=1, rerun=0) + result.stdout.no_fnmatch_line("INTERNALERROR*") + result.stdout.fnmatch_lines_random( + "*--reruns incompatible with --pdb: reruns are disabled" + ) + + def test_no_rerun_on_pass(testdir): testdir.makepyfile("def test_pass(): pass") result = testdir.runpytest("--reruns", "1")