diff --git a/changelog/544.bugfix.rst b/changelog/544.bugfix.rst new file mode 100644 index 00000000..87aa1bc2 --- /dev/null +++ b/changelog/544.bugfix.rst @@ -0,0 +1,6 @@ +Correctly pass :class:`StopIteration` trough hook wrappers. + +Raising a :class:`StopIteration` in a generator triggers a :class:`RuntimeError`. + +If the :class:`RuntimeError` of a generator has the passed in :class:`StopIteration` as cause +resume with that :class:`StopIteration` as normal exception instead of failing with the :class:`RuntimeError`. diff --git a/src/pluggy/_callers.py b/src/pluggy/_callers.py index f4a2aced..3ac24212 100644 --- a/src/pluggy/_callers.py +++ b/src/pluggy/_callers.py @@ -119,7 +119,19 @@ def _multicall( for teardown in reversed(teardowns): try: if exception is not None: - teardown.throw(exception) # type: ignore[union-attr] + try: + teardown.throw(exception) # type: ignore[union-attr] + except RuntimeError as re: + # StopIteration from generator causes RuntimeError + # even for coroutine usage - see #544 + if ( + isinstance(exception, StopIteration) + and re.__cause__ is exception + ): + teardown.close() # type: ignore[union-attr] + continue + else: + raise else: teardown.send(result) # type: ignore[union-attr] # Following is unreachable for a well behaved hook wrapper. @@ -164,7 +176,19 @@ def _multicall( else: try: if outcome._exception is not None: - teardown.throw(outcome._exception) + try: + teardown.throw(outcome._exception) + except RuntimeError as re: + # StopIteration from generator causes RuntimeError + # even for coroutine usage - see #544 + if ( + isinstance(outcome._exception, StopIteration) + and re.__cause__ is outcome._exception + ): + teardown.close() + continue + else: + raise else: teardown.send(outcome._result) # Following is unreachable for a well behaved hook wrapper. diff --git a/testing/test_multicall.py b/testing/test_multicall.py index 7d8d8f28..eb81230b 100644 --- a/testing/test_multicall.py +++ b/testing/test_multicall.py @@ -416,6 +416,36 @@ def m2(): ] +@pytest.mark.parametrize("has_hookwrapper", [True, False]) +def test_wrapper_stopiteration_passtrough(has_hookwrapper: bool) -> None: + out = [] + + @hookimpl(wrapper=True) + def wrap(): + out.append("wrap") + try: + yield + finally: + out.append("wrap done") + + @hookimpl(wrapper=not has_hookwrapper, hookwrapper=has_hookwrapper) + def wrap_path2(): + yield + + @hookimpl + def stop(): + out.append("stop") + raise StopIteration + + with pytest.raises(StopIteration): + try: + MC([stop, wrap, wrap_path2], {}) + finally: + out.append("finally") + + assert out == ["wrap", "stop", "wrap done", "finally"] + + def test_suppress_inner_wrapper_teardown_exc() -> None: out = []