Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: implement legacy hookwrappers in terms of a modern hook" #546

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions changelog/544.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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`.
157 changes: 74 additions & 83 deletions src/pluggy/_callers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
from typing import Mapping
from typing import NoReturn
from typing import Sequence
from typing import Tuple
from typing import Union
import warnings

from ._hooks import HookImpl
Expand All @@ -21,10 +19,34 @@

# Need to distinguish between old- and new-style hook wrappers.
# Wrapping with a tuple is the fastest type-safe way I found to do it.
Teardown = Union[
Tuple[Generator[None, Result[object], None], HookImpl],
Generator[None, object, object],
]
Teardown = Generator[None, object, object]


def run_legacy_hookwrapper(
hook_impl: HookImpl, hook_name: str, args: Sequence[object]
) -> Teardown:
teardown: Teardown = cast(Teardown, hook_impl.function(*args))
try:
next(teardown)
except StopIteration:
_raise_wrapfail(teardown, "did not yield")
try:
res = yield
result = Result(res, None)
except BaseException as exc:
result = Result(None, exc)
try:
teardown.send(result)
except StopIteration:
pass
except BaseException as e:
_warn_teardown_exception(hook_name, hook_impl, e)
raise
else:
_raise_wrapfail(teardown, "has second yield")
finally:

Check warning on line 47 in src/pluggy/_callers.py

View check run for this annotation

Codecov / codecov/patch

src/pluggy/_callers.py#L47

Added line #L47 was not covered by tests
teardown.close()
return result.get_result()


def _raise_wrapfail(
Expand All @@ -47,7 +69,7 @@
msg += f"Plugin: {hook_impl.plugin_name}, Hook: {hook_name}\n"
msg += f"{type(e).__name__}: {e}\n"
msg += "For more information see https://pluggy.readthedocs.io/en/stable/api_reference.html#pluggy.PluggyTeardownRaisedWarning" # noqa: E501
warnings.warn(PluggyTeardownRaisedWarning(msg), stacklevel=5)
warnings.warn(PluggyTeardownRaisedWarning(msg), stacklevel=6)


def _multicall(
Expand All @@ -64,7 +86,6 @@
__tracebackhide__ = True
results: list[object] = []
exception = None
only_new_style_wrappers = True
try: # run impl and wrapper setup functions in a loop
teardowns: list[Teardown] = []
try:
Expand All @@ -79,16 +100,17 @@
)

if hook_impl.hookwrapper:
only_new_style_wrappers = False
try:
# If this cast is not valid, a type error is raised below,
# which is the desired response.
res = hook_impl.function(*args)
wrapper_gen = cast(Generator[None, Result[object], None], res)
next(wrapper_gen) # first yield
teardowns.append((wrapper_gen, hook_impl))
function_gen = run_legacy_hookwrapper(
hook_impl, hook_name, args
)

Check warning on line 109 in src/pluggy/_callers.py

View check run for this annotation

Codecov / codecov/patch

src/pluggy/_callers.py#L108-L109

Added lines #L108 - L109 were not covered by tests
next(function_gen) # first yield
teardowns.append(function_gen)
except StopIteration:
_raise_wrapfail(wrapper_gen, "did not yield")
_raise_wrapfail(function_gen, "did not yield")
elif hook_impl.wrapper:
try:
# If this cast is not valid, a type error is raised below,
Expand All @@ -108,75 +130,44 @@
except BaseException as exc:
exception = exc
finally:
# Fast path - only new-style wrappers, no Result.
if only_new_style_wrappers:
if firstresult: # first result hooks return a single value
result = results[0] if results else None
else:
result = results

# run all wrapper post-yield blocks
for teardown in reversed(teardowns):
try:
if exception is not None:
teardown.throw(exception) # type: ignore[union-attr]
else:
teardown.send(result) # type: ignore[union-attr]
# Following is unreachable for a well behaved hook wrapper.
# Try to force finalizers otherwise postponed till GC action.
# Note: close() may raise if generator handles GeneratorExit.
teardown.close() # type: ignore[union-attr]
except StopIteration as si:
result = si.value
exception = None
continue
except BaseException as e:
exception = e
continue
_raise_wrapfail(teardown, "has second yield") # type: ignore[arg-type]

if exception is not None:
raise exception
else:
return result

# Slow path - need to support old-style wrappers.
if firstresult: # first result hooks return a single value
result = results[0] if results else None
else:
if firstresult: # first result hooks return a single value
outcome: Result[object | list[object]] = Result(
results[0] if results else None, exception
)
else:
outcome = Result(results, exception)

# run all wrapper post-yield blocks
for teardown in reversed(teardowns):
if isinstance(teardown, tuple):
try:
teardown[0].send(outcome)
except StopIteration:
pass
except BaseException as e:
_warn_teardown_exception(hook_name, teardown[1], e)
raise
else:
_raise_wrapfail(teardown[0], "has second yield")
else:
result = results

# run all wrapper post-yield blocks
for teardown in reversed(teardowns):
try:
if exception is not None:
try:
if outcome._exception is not None:
teardown.throw(outcome._exception)
teardown.throw(exception)
except RuntimeError as re:
# StopIteration from generator causes RuntimeError
# even for coroutine usage - see #544
if (
isinstance(exception, StopIteration)
and re.__cause__ is exception
):

Check warning on line 150 in src/pluggy/_callers.py

View check run for this annotation

Codecov / codecov/patch

src/pluggy/_callers.py#L150

Added line #L150 was not covered by tests
teardown.close()
continue
else:
teardown.send(outcome._result)
# Following is unreachable for a well behaved hook wrapper.
# Try to force finalizers otherwise postponed till GC action.
# Note: close() may raise if generator handles GeneratorExit.
teardown.close()
except StopIteration as si:
outcome.force_result(si.value)
continue
except BaseException as e:
outcome.force_exception(e)
continue
_raise_wrapfail(teardown, "has second yield")

return outcome.get_result()
raise
else:

Check warning on line 155 in src/pluggy/_callers.py

View check run for this annotation

Codecov / codecov/patch

src/pluggy/_callers.py#L155

Added line #L155 was not covered by tests
teardown.send(result)
# Following is unreachable for a well behaved hook wrapper.
# Try to force finalizers otherwise postponed till GC action.
# Note: close() may raise if generator handles GeneratorExit.

Check warning on line 159 in src/pluggy/_callers.py

View check run for this annotation

Codecov / codecov/patch

src/pluggy/_callers.py#L158-L159

Added lines #L158 - L159 were not covered by tests
teardown.close()
except StopIteration as si:
result = si.value
exception = None
continue
except BaseException as e:
exception = e
continue
_raise_wrapfail(teardown, "has second yield")

Check warning on line 169 in src/pluggy/_callers.py

View check run for this annotation

Codecov / codecov/patch

src/pluggy/_callers.py#L169

Added line #L169 was not covered by tests
if exception is not None:
raise exception
else:
return result
30 changes: 30 additions & 0 deletions testing/test_multicall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []

Expand Down