Skip to content

Commit 9cf2eaa

Browse files
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.
1 parent 4ba6441 commit 9cf2eaa

File tree

3 files changed

+62
-2
lines changed

3 files changed

+62
-2
lines changed

changelog/544.bugfix.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Correctly pass :class:`StopIteration` trough hook wrappers.
2+
3+
Raising a :class:`StopIteration` in a generator triggers a :class:`RuntimeError`.
4+
5+
If the :class:`RuntimeError` of a generator has the passed in :class:`StopIteration` as cause
6+
resume with that :class:`StopIteration` as normal exception instead of failing with the :class:`RuntimeError`.

src/pluggy/_callers.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,19 @@ def _multicall(
119119
for teardown in reversed(teardowns):
120120
try:
121121
if exception is not None:
122-
teardown.throw(exception) # type: ignore[union-attr]
122+
try:
123+
teardown.throw(exception) # type: ignore[union-attr]
124+
except RuntimeError as re:
125+
# StopIteration from generator causes RuntimeError
126+
# even for coroutine usage - see #544
127+
if (
128+
isinstance(exception, StopIteration)
129+
and re.__cause__ is exception
130+
):
131+
teardown.close() # type: ignore[union-attr]
132+
continue
133+
else:
134+
raise
123135
else:
124136
teardown.send(result) # type: ignore[union-attr]
125137
# Following is unreachable for a well behaved hook wrapper.
@@ -164,7 +176,19 @@ def _multicall(
164176
else:
165177
try:
166178
if outcome._exception is not None:
167-
teardown.throw(outcome._exception)
179+
try:
180+
teardown.throw(outcome._exception)
181+
except RuntimeError as re:
182+
# StopIteration from generator causes RuntimeError
183+
# even for coroutine usage - see #544
184+
if (
185+
isinstance(outcome._exception, StopIteration)
186+
and re.__cause__ is outcome._exception
187+
):
188+
teardown.close()
189+
continue
190+
else:
191+
raise
168192
else:
169193
teardown.send(outcome._result)
170194
# Following is unreachable for a well behaved hook wrapper.

testing/test_multicall.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,36 @@ def m2():
416416
]
417417

418418

419+
@pytest.mark.parametrize("has_hookwrapper", [True, False])
420+
def test_wrapper_stopiteration_passtrough(has_hookwrapper: bool) -> None:
421+
out = []
422+
423+
@hookimpl(wrapper=True)
424+
def wrap():
425+
out.append("wrap")
426+
try:
427+
yield
428+
finally:
429+
out.append("wrap done")
430+
431+
@hookimpl(wrapper=not has_hookwrapper, hookwrapper=has_hookwrapper)
432+
def wrap_path2():
433+
yield
434+
435+
@hookimpl
436+
def stop():
437+
out.append("stop")
438+
raise StopIteration
439+
440+
with pytest.raises(StopIteration):
441+
try:
442+
MC([stop, wrap, wrap_path2], {})
443+
finally:
444+
out.append("finally")
445+
446+
assert out == ["wrap", "stop", "wrap done", "finally"]
447+
448+
419449
def test_suppress_inner_wrapper_teardown_exc() -> None:
420450
out = []
421451

0 commit comments

Comments
 (0)