diff --git a/CHANGES.rst b/CHANGES.rst index 7960238..4128c23 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,7 +4,13 @@ Changelog 16.4 (unreleased) ----------------- -- Nothing changed yet. +Features +++++++++ + +- Add ``--max-suite-retries`` option to cap the total number of reruns across + the entire test suite. Once the limit is reached, no further reruns occur + regardless of per-test ``--reruns`` or ``@pytest.mark.flaky`` settings. + Fixes `#298 `_. 16.3 (2026-05-22) diff --git a/README.rst b/README.rst index 30d2669..d8d9a6a 100644 --- a/README.rst +++ b/README.rst @@ -220,6 +220,21 @@ setting. To make them additive instead, pass ``--reruns-mode=append``. With $ pytest --reruns 4 --reruns-mode append +Limit total reruns across the suite +------------------------------------ + +To cap the total number of reruns across the entire test suite regardless of +how many individual tests fail, pass ``--max-suite-retries``. Once the limit +is reached, no further reruns occur even if individual tests have remaining +retries: + +.. code-block:: bash + + $ pytest --reruns 3 --max-suite-retries 10 + +This is useful in large test suites to bound resource usage when many tests +are flaky at the same time. + Show tracebacks for retried failures ------------------------------------ diff --git a/pyproject.toml b/pyproject.toml index 4d6aa3d..a5afddc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,3 +72,8 @@ lint.pydocstyle.convention = "google" [tool.check-manifest] ignore = [ ".pre-commit-config.yaml" ] + +[dependency-groups] +dev = [ + "mypy>=2.1.0", +] diff --git a/src/pytest_rerunfailures.py b/src/pytest_rerunfailures.py index 127cdc9..a074976 100644 --- a/src/pytest_rerunfailures.py +++ b/src/pytest_rerunfailures.py @@ -10,6 +10,7 @@ import traceback import warnings from contextlib import suppress +from typing import Any import pytest from _pytest.outcomes import fail @@ -120,6 +121,15 @@ def pytest_addoption(parser): "'rerun test summary info' section, which is emitted automatically " "when this flag is set.", ) + group.addoption( + "--max-suite-retries", + action="store", + dest="max_suite_retries", + type=int, + default=None, + help="Maximum total number of reruns across the entire test suite. " + "Once this limit is reached, no further reruns will occur.", + ) arg_type = "string" parser.addini("reruns", RERUNS_DESC, type=arg_type) @@ -430,6 +440,25 @@ class StatusDB: def __init__(self): self.delim = b"\n" self.hmap = {} + self._suite_rerun_count = 0 + self._suite_lock = threading.Lock() + + def increment_suite_reruns(self) -> int: + """Atomically increment the suite-wide rerun counter; return new total.""" + with self._suite_lock: + self._suite_rerun_count += 1 + return self._suite_rerun_count + + def try_increment_suite_reruns(self, max_cap: int) -> bool: + with self._suite_lock: + if self._suite_rerun_count < max_cap: + self._suite_rerun_count += 1 + return True + return False + + def get_suite_reruns(self) -> int: + """Return the current suite-wide rerun count.""" + return self._suite_rerun_count def _hash(self, crashitem: str) -> str: if crashitem not in self.hmap: @@ -514,6 +543,19 @@ def run_connection(self, conn): self._set(i, k, int(v)) elif op == "get": self._sock_send(conn, str(self._get(i, k))) + elif op == "inc": + with self._suite_lock: + new_v = self._get(i, k) + 1 + self._set(i, k, new_v) + self._sock_send(conn, str(new_v)) + elif op == "try_inc": + with self._suite_lock: + current = self._get(i, k) + if current < int(v): + self._set(i, k, current + 1) + self._sock_send(conn, "1") + else: + self._sock_send(conn, "0") def _set(self, i: str, k: str, v: int): if i not in self.rerunfailures_db: @@ -526,6 +568,25 @@ def _get(self, i: str, k: str) -> int: except KeyError: return 0 + def increment_suite_reruns(self) -> int: + """Atomically increment the suite-wide rerun counter; return new total.""" + with self._suite_lock: + new_v = self._get("__suite__", "r") + 1 + self._set("__suite__", "r", new_v) + return new_v + + def try_increment_suite_reruns(self, max_cap: int) -> bool: + with self._suite_lock: + current = self._get("__suite__", "r") + if current < max_cap: + self._set("__suite__", "r", current + 1) + return True + return False + + def get_suite_reruns(self) -> int: + """Return the current suite-wide rerun count.""" + return self._get("__suite__", "r") + class ClientStatusDB(SocketDB): def __init__(self, sock_port): @@ -539,8 +600,23 @@ def _get(self, i: str, k: str) -> int: self._sock_send(self.sock, "|".join(("get", i, k, ""))) return int(self._sock_recv(self.sock)) + def increment_suite_reruns(self) -> int: + """Atomically increment the suite-wide rerun counter; return new total.""" + self._sock_send(self.sock, "|".join(("inc", "__suite__", "r", ""))) + return int(self._sock_recv(self.sock)) + + def try_increment_suite_reruns(self, max_cap: int) -> bool: + self._sock_send( + self.sock, "|".join(("try_inc", "__suite__", "r", str(max_cap))) + ) + return self._sock_recv(self.sock) == "1" -suspended_finalizers = {} + def get_suite_reruns(self) -> int: + """Return the current suite-wide rerun count.""" + return self._get("__suite__", "r") + + +suspended_finalizers: dict[Any, Any] = {} def pytest_runtest_teardown(item, nextitem): @@ -638,6 +714,13 @@ def pytest_runtest_protocol(item, nextitem): item.ihook.pytest_runtest_logreport(report=report) else: # failure detected and reruns not exhausted, since i < reruns + max_suite_reruns = item.session.config.option.max_suite_retries + if max_suite_reruns is not None: + if not db.try_increment_suite_reruns(max_suite_reruns): + # suite-wide limit exhausted — log as final failure + item.ihook.pytest_runtest_logreport(report=report) + continue + report.outcome = "rerun" time.sleep(delay) diff --git a/tests/test_pytest_rerunfailures.py b/tests/test_pytest_rerunfailures.py index b60716c..6f3f398 100644 --- a/tests/test_pytest_rerunfailures.py +++ b/tests/test_pytest_rerunfailures.py @@ -1526,3 +1526,75 @@ def test_pass(): result = testdir.runpytest("--reruns-mode", "bogus") assert result.ret != 0 + + +def test_max_suite_retries_caps_total_reruns(testdir): + """Suite limit stops reruns once the total across all tests is reached.""" + testdir.makepyfile( + """ + def test_fail_1(): + assert False + + def test_fail_2(): + assert False + + def test_fail_3(): + assert False + """ + ) + # 3 tests each allowed up to 3 reruns, but suite cap is 4 total + result = testdir.runpytest("--reruns", "3", "--max-suite-retries", "4") + outcomes = result.parseoutcomes() + assert outcomes.get("rerun", 0) == 4 + assert outcomes.get("failed", 0) == 3 + + +def test_max_suite_retries_does_not_limit_when_sufficient(testdir): + """Suite limit has no effect when total reruns stay below the cap.""" + testdir.makepyfile( + """ + def test_fail(): + assert False + """ + ) + result = testdir.runpytest("--reruns", "2", "--max-suite-retries", "10") + assert_outcomes(result, passed=0, failed=1, rerun=2) + + +def test_max_suite_retries_zero_disables_all_reruns(testdir): + """Suite limit of 0 prevents any reruns from occurring.""" + testdir.makepyfile( + """ + def test_fail(): + assert False + """ + ) + result = testdir.runpytest("--reruns", "3", "--max-suite-retries", "0") + assert_outcomes(result, passed=0, failed=1, rerun=0) + + +def test_max_suite_retries_works_with_passing_tests(testdir): + """Suite limit only counts actual reruns, not passing test runs.""" + testdir.makepyfile( + """ + def test_pass(): + assert True + + def test_fail(): + assert False + """ + ) + result = testdir.runpytest("--reruns", "3", "--max-suite-retries", "2") + assert_outcomes(result, passed=1, failed=1, rerun=2) + + +def test_max_suite_retries_without_reruns_has_no_effect(testdir): + """--max-suite-retries alone (without --reruns) does not break anything.""" + testdir.makepyfile( + """ + def test_fail(): + assert False + """ + ) + result = testdir.runpytest("--max-suite-retries", "5") + assert_outcomes(result, passed=0, failed=1, rerun=0)