Repository: pytest-dev/pluggy (python)

Issue:
StopIteration passtrough not handled correctly
[cpython impl ref in contextmanager](https://github.com/python/cpython/blob/ac00bf0e96d93adf20879217dc7022c7c3a5dc89/Lib/contextlib.py#L173C65-L184)


a test in one of my testsuites failed with a StopIteration as exception

this ran into issues as new style hook wrappers dont manage the RuntimeError of stopIteration

```
>>> def mygen(): yield
... 
>>> gen = mygen()
>>> next(gen)
>>> e = StopIteration()
>>> try:
...   gen.throw(e)
... except Exception as err:
...   runtime = err
... 
>>> runtime
RuntimeError('generator raised StopIteration')
>>> runtime.
runtime.add_note(        runtime.args             runtime.with_traceback(  
>>> runtime.a
runtime.add_note(  runtime.args       
>>> runtime.args
('generator raised StopIteration',)
>>> runtime.__cause__
StopIteration()
>>> runtime.__cause__ is e
True
>>> 


Pull request description:
fix #544: Correctly pass StopIteration trough wrappers
Raising a StopIteration in a generator triggers a RuntimeError.
If the RuntimeError of a generator has the passed in StopIteration as cause
resume with that StopIteration as normal exception instead of failing with the RuntimeError.

closes #544 


Patch:
diff --git a/changelog/544.bugfix.rst b/changelog/544.bugfix.rst
new file mode 100644
index 0000000..87aa1bc
--- /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 f4a2ace..3ac2421 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.


Test patch:
diff --git a/testing/test_multicall.py b/testing/test_multicall.py
index 7d8d8f2..eb81230 100644
--- a/testing/test_multicall.py
+++ b/testing/test_multicall.py
@@ -416,6 +416,36 @@ def test_unwind_inner_wrapper_teardown_exc() -> None:
     ]
 
 
+@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 = []