diff --git a/README.rst b/README.rst index c29dc9c..47fd51a 100644 --- a/README.rst +++ b/README.rst @@ -110,6 +110,11 @@ would only rerun those errors that match ``AssertionError`` or ``ValueError``: $ pytest --reruns 5 --only-rerun AssertionError --only-rerun ValueError +The same matching is applied to each exception in the ``__cause__`` / +``__context__`` chain, so a wrapped error such as +``raise RuntimeError(...) from MemoryError(...)`` is still rerun by +``--only-rerun MemoryError``. + Re-run all failures other than matching certain expressions ----------------------------------------------------------- @@ -128,6 +133,12 @@ would only rerun those errors that does not match with ``AssertionError`` or ``O $ pytest --reruns 5 --rerun-except AssertionError --rerun-except OSError +Matching for ``--rerun-except`` follows explicit ``__cause__`` links +(``raise ... from ...``), so ``raise RuntimeError(...) from ValueError(...)`` +is excluded by ``--rerun-except ValueError``. Implicit ``__context__`` from +``except`` / ``finally`` is not walked, so a ``ConnectionError`` raised inside +``except AssertionError`` is still rerun by ``--rerun-except AssertionError``. + Exclude test paths from re-runs -------------------------------- diff --git a/changes/353.bugfix.rst b/changes/353.bugfix.rst new file mode 100644 index 0000000..117a9da --- /dev/null +++ b/changes/353.bugfix.rst @@ -0,0 +1 @@ +Match ``only_rerun`` against the full exception chain (``__cause__`` and ``__context__``), not only the outermost exception. Match ``rerun_except`` against explicit ``__cause__`` links only, so implicit ``except`` / ``finally`` context does not suppress reruns. diff --git a/src/pytest_rerunfailures.py b/src/pytest_rerunfailures.py index 627287f..0acd7ae 100644 --- a/src/pytest_rerunfailures.py +++ b/src/pytest_rerunfailures.py @@ -596,19 +596,34 @@ def _get_rerun_filter_regex(item, regex_name): def _matches_any_rerun_error(rerun_errors, excinfo): - return _try_match_error(rerun_errors, excinfo) + return _try_match_error(rerun_errors, excinfo, follow_context=True) def _matches_any_rerun_except_error(rerun_except_errors, excinfo): - return _try_match_error(rerun_except_errors, excinfo) + return _try_match_error(rerun_except_errors, excinfo, follow_context=False) + + +def _iter_exception_chain(exc, *, follow_context=True): + seen = set() + while exc is not None and id(exc) not in seen: + seen.add(id(exc)) + yield exc + if exc.__cause__ is not None: + exc = exc.__cause__ + elif follow_context and not getattr(exc, "__suppress_context__", False): + exc = exc.__context__ + else: + exc = None -def _try_match_error(rerun_errors, excinfo): - if excinfo: - err = f"{excinfo.type.__name__}: {excinfo.value}" +def _try_match_error(rerun_errors, excinfo, *, follow_context=True): + if not excinfo: + return False + for exc in _iter_exception_chain(excinfo.value, follow_context=follow_context): + err = f"{type(exc).__name__}: {exc}" for rerun_error in rerun_errors: if isinstance(rerun_error, type) and issubclass(rerun_error, BaseException): - if issubclass(excinfo.type, rerun_error): + if isinstance(exc, rerun_error): return True elif re.search(rerun_error, err): return True diff --git a/tests/test_pytest_rerunfailures.py b/tests/test_pytest_rerunfailures.py index 4b64d1e..d0b7aad 100644 --- a/tests/test_pytest_rerunfailures.py +++ b/tests/test_pytest_rerunfailures.py @@ -1087,6 +1087,122 @@ def test_only_rerun2(): ) +@pytest.mark.parametrize( + "only_rerun,should_rerun", + [ + ("MemoryError", True), + ("out of memory", True), + ("ValueError", False), + ], +) +def test_only_rerun_matches_wrapped_cause(testdir, only_rerun, should_rerun): + testdir.makepyfile( + """ + def test_wrapped(): + try: + raise MemoryError("out of memory") + except MemoryError as error: + raise RuntimeError("something failed") from error + """ + ) + result = testdir.runpytest("--reruns", "1", "--only-rerun", only_rerun) + assert_outcomes(result, passed=0, failed=1, rerun=1 if should_rerun else 0) + + +def test_only_rerun_matches_implicit_context(testdir): + testdir.makepyfile( + """ + def test_wrapped(): + try: + raise MemoryError("out of memory") + except MemoryError: + raise RuntimeError("something failed") + """ + ) + result = testdir.runpytest("--reruns", "1", "--only-rerun", "MemoryError") + assert_outcomes(result, passed=0, failed=1, rerun=1) + + +def test_only_rerun_exception_class_matches_wrapped_cause(testdir): + testdir.makepyfile( + """ + import pytest + + @pytest.mark.flaky(reruns=1, only_rerun=[MemoryError]) + def test_wrapped(): + try: + raise MemoryError("out of memory") + except MemoryError as error: + raise RuntimeError("something failed") from error + """ + ) + result = testdir.runpytest() + assert_outcomes(result, passed=0, failed=1, rerun=1) + + +def test_only_rerun_ignores_suppressed_context(testdir): + testdir.makepyfile( + """ + def test_wrapped(): + try: + raise MemoryError("out of memory") + except MemoryError: + raise RuntimeError("something failed") from None + """ + ) + result = testdir.runpytest("--reruns", "1", "--only-rerun", "MemoryError") + assert_outcomes(result, passed=0, failed=1, rerun=0) + + +def test_rerun_except_matches_explicit_cause(testdir): + testdir.makepyfile( + """ + def test_wrapped(): + try: + raise ValueError("bad value") + except ValueError as error: + raise RuntimeError("something failed") from error + """ + ) + result = testdir.runpytest("--reruns", "1", "--rerun-except", "ValueError") + assert_outcomes(result, passed=0, failed=1, rerun=0) + + +def test_rerun_except_does_not_match_implicit_context(testdir): + testdir.makepyfile( + """ + def test_wrapped(): + try: + assert False, "genuine failure" + except AssertionError: + raise ConnectionError("network blip") + """ + ) + result = testdir.runpytest("--reruns", "2", "--rerun-except", "AssertionError") + assert_outcomes(result, passed=0, failed=1, rerun=2) + + +def test_only_rerun_and_rerun_except_implicit_context(testdir): + testdir.makepyfile( + """ + def test_wrapped(): + try: + assert False, "genuine failure" + except AssertionError: + raise ConnectionError("network blip") + """ + ) + result = testdir.runpytest( + "--reruns", + "2", + "--only-rerun", + "ConnectionError", + "--rerun-except", + "AssertionError", + ) + assert_outcomes(result, passed=0, failed=1, rerun=2) + + def test_no_rerun_on_strict_xfail_with_only_rerun_flag(testdir): testdir.makepyfile( """