Repository: pytest-dev/pluggy
Pull request #389 (2023-06-11 21:13:17)

Hook wrapping without `_Result`
This implements the idea from #260. I think it makes pluggy more elegant, and also provides a backward compatible fix for #244 (replaces #257).

bluetech: In #260, @maxnikulin argued against using a "plain" generator function for this, preferring a new `@hookimpl(wrapper=True)` instead, to make it explicit.

My opinion is that implicit is fine, because that's how python functions themselves work, and how e.g. `pytest.fixture` works, and I think they're mostly fine. But I'm willing to be outvoted on this.
bluetech: >  It Will make async a little Harder, but reasonably so

Seems like async generators have the necessary `asend`/`athrow`, the only thing they're missing is `return` support. I wonder why they don't support that.
bluetech: Thanks for the reviews @RonnyPfannschmidt.

I plan to:
- Extract to `force_exception` change to its own PR with own changelog.
- Wait a week or two to allow for discussion.
- Try to port pytest to detect any issues and measure performance in a realistic setting.
bluetech: I've made the following performance measurements:

1. Pluggy benchmark - old pluggy/new pluggy - neutral
2. Pytest benchmarks - old pytetst+old pluggy/new pytest+new pluggy - neutral
3. Pytest benchmarks - old pytetst+old pluggy/old pytest+new pluggy - neutral

I also made an experiment where I removed `hookwrapper=True` support entirely from `_multicall`  (removing `_Result` etc.) and this gets a ~15% improvement in pluggy benchmark. But we don't have to drop old-style wrappers to get this improvement; it is easy to add a fast path which specializes for only new-style case - see [here](https://github.com/bluetech/pluggy/commits/un-result-fast-path) (better to view ignoring whitespace). Will maybe submit this as a separate PR if this one gets merged.
RonnyPfannschmidt: How expensive would it be to have legacy hook wrappers be implemented in terms off The new wrappers thus making them slower but allowing the new wrappers also Bugfix the exception escape 
bluetech: I'm not sure, but I generally think it'd be better to keep the existing hookwrapper=True semantics as-is, to avoid breaking things (https://xkcd.com/1172/ situation). Switching to the new style would also be opting in to the new semantics.
nicoddemus: Great work @bluetech! 

The idea seems great, I will take a look in the code in the next few days! :+1: 

Patch:
diff --git a/.coveragerc b/.coveragerc
index e0628b2..c5e883c 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -19,6 +19,7 @@ exclude_lines =
     pragma: no cover
 
     if TYPE_CHECKING:
+    if False:
 
     if __name__ == .__main__.:
 
diff --git a/changelog/260.feature.rst b/changelog/260.feature.rst
new file mode 100644
index 0000000..8b23d3b
--- /dev/null
+++ b/changelog/260.feature.rst
@@ -0,0 +1,10 @@
+Add "new-style" hook wrappers, a simpler but equally powerful alternative to the existing ``hookwrapper=True`` wrappers.
+
+New-style wrappers are generator functions, similarly to ``hookwrapper``, but do away with the :class:`result <pluggy._callers._Result>` object.
+Instead, the return value is sent directly to the ``yield`` statement, or, if inner calls raised an exception, it is raised from the ``yield``.
+The wrapper is excepted to return a value or raise an exception, which will become the result of the hook call.
+
+New-style wrappers are fully interoperable with old-style wrappers.
+We encourage users to use the new style, however we do not intend to deprecate the old style any time soon.
+
+See :ref:`hookwrappers` for the full documentation.
diff --git a/docs/index.rst b/docs/index.rst
index 28270df..e68901e 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -362,13 +362,73 @@ For another example see the :ref:`pytest:plugin-hookorder` section of the
 
 Wrappers
 ^^^^^^^^
-A *hookimpl* can be marked with a ``"hookwrapper"`` option which indicates that
-the function will be called to *wrap* (or surround) all other normal *hookimpl*
-calls. A *hookwrapper* can thus execute some code ahead and after the execution
-of all corresponding non-wrappper *hookimpls*.
 
-Much in the same way as a :py:func:`@contextlib.contextmanager <python:contextlib.contextmanager>`, *hookwrappers* must
-be implemented as generator function with a single ``yield`` in its body:
+.. note::
+    This section describes "new-style hook wrappers", which were added in Pluggy
+    1.1. For earlier versions, see the "old-style hook wrappers" section below.
+    The two styles are fully interoperable.
+
+A *hookimpl* can be a generator function, which indicates that the function will
+be called to *wrap* (or surround) all other normal *hookimpl* calls. A *hook
+wrapper* can thus execute some code ahead and after the execution of all
+corresponding non-wrappper *hookimpls*.
+
+Much in the same way as a :py:func:`@contextlib.contextmanager <python:contextlib.contextmanager>`,
+*hook wrappers* must be implemented as generator function with a single ``yield`` in its body:
+
+.. code-block:: python
+
+    @hookimpl
+    def setup_project(config, args):
+        """Wrap calls to ``setup_project()`` implementations which
+        should return json encoded config options.
+        """
+        # get initial default config
+        defaults = config.tojson()
+
+        if config.debug:
+            print("Pre-hook config is {}".format(config.tojson()))
+
+        # all corresponding hookimpls are invoked here
+        result = yield
+
+        for item in result:
+            print("JSON config override is {}".format(item))
+
+        if config.debug:
+            print("Post-hook config is {}".format(config.tojson()))
+
+        if config.use_defaults:
+            return defaults
+        else:
+            return result
+
+The generator is :py:meth:`sent <python:generator.send>` the return value
+of the hook thus far, or, if the previous calls raised an exception, it is
+:py:meth:`thrown <python:generator.throw>` the exception.
+
+The function should do one of two things:
+- Return a value, which can be the same value as received from the ``yield``, or
+something else entirely.
+- Raise an exception.
+The return value or exception propagate to further hook wrappers, and finally
+to the hook caller.
+
+Also see the :ref:`pytest:hookwrapper` section in the ``pytest`` docs.
+
+Old-style wrappers
+^^^^^^^^^^^^^^^^^^
+
+.. note::
+    Prefer to use new-style hook wrappers, unless you need to support Pluggy
+    versions before 1.1.
+
+A *hookimpl* can be marked with the ``"hookwrapper"`` option, which indicates
+that the function will be called to *wrap* (or surround) all other normal
+*hookimpl* calls. A *hookwrapper* can thus execute some code ahead and after the
+execution of all corresponding non-wrappper *hookimpls*.
+
+*hookwrappers* must be implemented as generator function with a single ``yield`` in its body:
 
 
 .. code-block:: python
@@ -411,16 +471,14 @@ the exception using the :py:meth:`~pluggy._callers._Result.force_exception`
 method.
 
 .. note::
-    Hookwrappers can **not** return results; they can only modify them using
-    the :py:meth:`~pluggy._callers._Result.force_result` API.
+    Old-style hook wrappers can **not** return results; they can only modify
+    them using the :py:meth:`~pluggy._callers._Result.force_result` API.
 
-    Hookwrappers should **not** raise exceptions; this will cause further
-    hookwrappers to be skipped. They should use
+    Old-style Hook wrappers should **not** raise exceptions; this will cause
+    further hookwrappers to be skipped. They should use
     :py:meth:`~pluggy._callers._Result.force_exception` to adjust the
     exception.
 
-Also see the :ref:`pytest:hookwrapper` section in the ``pytest`` docs.
-
 .. _specs:
 
 Specifications
@@ -538,7 +596,7 @@ than ``None``.
 This can be useful for optimizing a call loop for which you are only
 interested in a single core *hookimpl*. An example is the
 :func:`~_pytest.hookspec.pytest_cmdline_main` central routine of ``pytest``.
-Note that all ``hookwrappers`` are still invoked with the first result.
+Note that all hook wrappers are still invoked with the first result.
 
 Also see the :ref:`pytest:firstresult` section in the ``pytest`` docs.
 
@@ -750,10 +808,9 @@ single value (which is not ``None``) will be returned.
 
 Exception handling
 ------------------
-If any *hookimpl* errors with an exception no further callbacks
-are invoked and the exception is packaged up and delivered to
-any :ref:`wrappers <hookwrappers>` before being re-raised at the
-hook invocation point:
+If any *hookimpl* errors with an exception no further callbacks are invoked and
+the exception is delivered to any :ref:`wrappers <hookwrappers>` before being
+re-raised at the hook invocation point:
 
 .. code-block:: python
 
@@ -780,15 +837,14 @@ hook invocation point:
             return 3
 
 
-    @hookimpl(hookwrapper=True)
+    @hookimpl
     def myhook(self, args):
-        outcome = yield
-
         try:
-            outcome.get_result()
-        except RuntimeError:
-            # log the error details
-            print(outcome.exception)
+            return (yield)
+        except RuntimeError as exc:
+            # log runtime error details
+            print(exc)
+            raise
 
 
     pm = PluginManager("myproject")
diff --git a/src/pluggy/_callers.py b/src/pluggy/_callers.py
index c90076e..d8f0dba 100644
--- a/src/pluggy/_callers.py
+++ b/src/pluggy/_callers.py
@@ -7,7 +7,9 @@ from typing import cast
 from typing import Generator
 from typing import Mapping
 from typing import Sequence
+from typing import Tuple
 from typing import TYPE_CHECKING
+from typing import Union
 
 from ._result import _raise_wrapfail
 from ._result import _Result
@@ -17,6 +19,14 @@ if TYPE_CHECKING:
     from ._hooks import HookImpl
 
 
+# Need to distinguish between old- and new-style hook wrappers.
+# Wrapping one a singleton tuple is the fastest type-safe way I found to do it.
+Teardown = Union[
+    Tuple[Generator[None, _Result[object], None]],
+    Generator[None, object, object],
+]
+
+
 def _multicall(
     hook_name: str,
     hook_impls: Sequence[HookImpl],
@@ -32,7 +42,7 @@ def _multicall(
     results: list[object] = []
     exception = None
     try:  # run impl and wrapper setup functions in a loop
-        teardowns = []
+        teardowns: list[Teardown] = []
         try:
             for hook_impl in reversed(hook_impls):
                 try:
@@ -49,11 +59,21 @@ def _multicall(
                         # If this cast is not valid, a type error is raised below,
                         # which is the desired response.
                         res = hook_impl.function(*args)
-                        gen = cast(Generator[None, _Result[object], None], res)
-                        next(gen)  # first yield
-                        teardowns.append(gen)
+                        wrapper_gen = cast(Generator[None, _Result[object], None], res)
+                        next(wrapper_gen)  # first yield
+                        teardowns.append((wrapper_gen,))
                     except StopIteration:
-                        _raise_wrapfail(gen, "did not yield")
+                        _raise_wrapfail(wrapper_gen, "did not yield")
+                elif hook_impl.isgeneratorfunction:
+                    try:
+                        # If this cast is not valid, a type error is raised below,
+                        # which is the desired response.
+                        res = hook_impl.function(*args)
+                        function_gen = cast(Generator[None, object, object], res)
+                        next(function_gen)  # first yield
+                        teardowns.append(function_gen)
+                    except StopIteration:
+                        _raise_wrapfail(function_gen, "did not yield")
                 else:
                     res = hook_impl.function(*args)
                     if res is not None:
@@ -71,11 +91,29 @@ def _multicall(
             outcome = _Result(results, exception)
 
         # run all wrapper post-yield blocks
-        for gen in reversed(teardowns):
-            try:
-                gen.send(outcome)
-                _raise_wrapfail(gen, "has second yield")
-            except StopIteration:
-                pass
+        for teardown in reversed(teardowns):
+            if isinstance(teardown, tuple):
+                try:
+                    teardown[0].send(outcome)
+                    _raise_wrapfail(teardown[0], "has second yield")
+                except StopIteration:
+                    pass
+            else:
+                try:
+                    if outcome._exception is not None:
+                        teardown.throw(outcome._exception)
+                    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()
diff --git a/src/pluggy/_hooks.py b/src/pluggy/_hooks.py
index 4054216..e124d05 100644
--- a/src/pluggy/_hooks.py
+++ b/src/pluggy/_hooks.py
@@ -185,17 +185,30 @@ class HookimplMarker:
         If ``trylast`` is ``True``, this hook implementation will run as late as
         possible in the chain of N hook implementations.
 
-        If ``hookwrapper`` is ``True``, the hook implementations needs to
-        execute exactly one ``yield``. The code before the ``yield`` is run
-        early before any non-hookwrapper function is run. The code after the
-        ``yield`` is run after all non-hookwrapper function have run  The
-        ``yield`` receives a :class:`_Result` object representing the exception
-        or result outcome of the inner calls (including other hookwrapper
-        calls).
+        If the hook implementation is a generator function, and ``hookwrapper``
+        is ``False`` or not set ("new-style hook wrapper"), the hook
+        implementation needs to execute exactly one ``yield``. The code before
+        the ``yield`` is run early before any non-hook-wrapper function is run.
+        The code after the ``yield`` is run after all non-hook-wrapper functions
+        have run. The ``yield`` receives the result value of the inner calls, or
+        raises the exception of inner calls (including earlier hook wrapper
+        calls). The return value of the function becomes the return value of the
+        hook, and a raised exception becomes the exception of the hook.
+
+        If ``hookwrapper`` is ``True`` ("old-style hook wrapper"), the hook
+        implementation needs to execute exactly one ``yield``. The code before
+        the ``yield`` is run early before any non-hook-wrapper function is run.
+        The code after the ``yield`` is run after all non-hook-wrapper function
+        have run  The ``yield`` receives a :class:`_Result` object representing
+        the exception or result outcome of the inner calls (including earlier
+        hook wrapper calls).
 
         If ``specname`` is provided, it will be used instead of the function
         name when matching this hook implementation to a hook specification
         during registration.
+
+        .. versionadded:: 1.1
+            New-style hook wrappers.
         """
 
         def setattr_hookimpl_opts(func: _F) -> _F:
@@ -360,12 +373,12 @@ class _HookCaller:
     def _add_hookimpl(self, hookimpl: HookImpl) -> None:
         """Add an implementation to the callback chain."""
         for i, method in enumerate(self._hookimpls):
-            if method.hookwrapper:
+            if method.hookwrapper or method.isgeneratorfunction:
                 splitpoint = i
                 break
         else:
             splitpoint = len(self._hookimpls)
-        if hookimpl.hookwrapper:
+        if hookimpl.hookwrapper or hookimpl.isgeneratorfunction:
             start, end = splitpoint, len(self._hookimpls)
         else:
             start, end = 0, splitpoint
@@ -455,7 +468,11 @@ class _HookCaller:
             hookimpl = HookImpl(None, "<temp>", method, opts)
             # Find last non-tryfirst nonwrapper method.
             i = len(hookimpls) - 1
-            while i >= 0 and hookimpls[i].tryfirst and not hookimpls[i].hookwrapper:
+            while (
+                i >= 0
+                and hookimpls[i].tryfirst
+                and not (hookimpls[i].hookwrapper or hookimpls[i].isgeneratorfunction)
+            ):
                 i -= 1
             hookimpls.insert(i + 1, hookimpl)
         firstresult = self.spec.opts.get("firstresult", False) if self.spec else False
@@ -528,6 +545,7 @@ class HookImpl:
         "plugin",
         "opts",
         "plugin_name",
+        "isgeneratorfunction",
         "hookwrapper",
         "optionalhook",
         "tryfirst",
@@ -546,6 +564,7 @@ class HookImpl:
         self.plugin = plugin
         self.opts = hook_impl_opts
         self.plugin_name = plugin_name
+        self.isgeneratorfunction = inspect.isgeneratorfunction(self.function)
         self.hookwrapper = hook_impl_opts["hookwrapper"]
         self.optionalhook = hook_impl_opts["optionalhook"]
         self.tryfirst = hook_impl_opts["tryfirst"]
diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py
index 0fd8cef..21346eb 100644
--- a/src/pluggy/_manager.py
+++ b/src/pluggy/_manager.py
@@ -290,10 +290,12 @@ class PluginManager:
         return None
 
     def _verify_hook(self, hook: _HookCaller, hookimpl: HookImpl) -> None:
-        if hook.is_historic() and hookimpl.hookwrapper:
+        if hook.is_historic() and (
+            hookimpl.hookwrapper or hookimpl.isgeneratorfunction
+        ):
             raise PluginValidationError(
                 hookimpl.plugin,
-                "Plugin %r\nhook %r\nhistoric incompatible to hookwrapper"
+                "Plugin %r\nhook %r\nhistoric incompatible with yield/hookwrapper"
                 % (hookimpl.plugin_name, hook.name),
             )
 
diff --git a/src/pluggy/_result.py b/src/pluggy/_result.py
index ffcf996..65475de 100644
--- a/src/pluggy/_result.py
+++ b/src/pluggy/_result.py
@@ -23,7 +23,10 @@ _T = TypeVar("_T")
 
 
 def _raise_wrapfail(
-    wrap_controller: Generator[None, _Result[_T], None], msg: str
+    wrap_controller: (
+        Generator[None, _Result[_T], None] | Generator[None, object, object]
+    ),
+    msg: str,
 ) -> NoReturn:
     co = wrap_controller.gi_code
     raise RuntimeError(


Test patch:
diff --git a/testing/benchmark.py b/testing/benchmark.py
index 5e28e54..636c3ba 100644
--- a/testing/benchmark.py
+++ b/testing/benchmark.py
@@ -19,9 +19,9 @@ def hook(arg1, arg2, arg3):
     return arg1, arg2, arg3
 
 
-@hookimpl(hookwrapper=True)
+@hookimpl
 def wrapper(arg1, arg2, arg3):
-    yield
+    return (yield)
 
 
 @pytest.fixture(params=[10, 100], ids="hooks={}".format)
@@ -70,7 +70,7 @@ def test_call_hook(benchmark, plugins, wrappers, nesting):
     class HookSpec:
         @hookspec
         def fun(self, hooks, nesting: int):
-            yield
+            pass
 
     class Plugin:
         def __init__(self, num: int) -> None:
@@ -91,9 +91,9 @@ def test_call_hook(benchmark, plugins, wrappers, nesting):
         def __repr__(self) -> str:
             return f"<PluginWrap {self.num}>"
 
-        @hookimpl(hookwrapper=True)
+        @hookimpl
         def fun(self):
-            yield
+            return (yield)
 
     pm.add_hookspecs(HookSpec)
 
diff --git a/testing/test_details.py b/testing/test_details.py
index f8283f3..95fba81 100644
--- a/testing/test_details.py
+++ b/testing/test_details.py
@@ -25,6 +25,10 @@ def test_parse_hookimpl_override() -> None:
         def x1meth2(self):
             yield  # pragma: no cover
 
+        @hookimpl(trylast=True)
+        def x1meth3(self):
+            return (yield)  # pragma: no cover
+
     class Spec:
         @hookspec
         def x1meth(self):
@@ -34,6 +38,10 @@ def test_parse_hookimpl_override() -> None:
         def x1meth2(self):
             pass
 
+        @hookspec
+        def x1meth3(self):
+            pass
+
     pm = MyPluginManager(hookspec.project_name)
     pm.register(Plugin())
     pm.add_hookspecs(Spec)
@@ -41,6 +49,7 @@ def test_parse_hookimpl_override() -> None:
     hookimpls = pm.hook.x1meth.get_hookimpls()
     assert len(hookimpls) == 1
     assert not hookimpls[0].hookwrapper
+    assert not hookimpls[0].isgeneratorfunction
     assert not hookimpls[0].tryfirst
     assert not hookimpls[0].trylast
     assert not hookimpls[0].optionalhook
@@ -48,8 +57,16 @@ def test_parse_hookimpl_override() -> None:
     hookimpls = pm.hook.x1meth2.get_hookimpls()
     assert len(hookimpls) == 1
     assert hookimpls[0].hookwrapper
+    assert hookimpls[0].isgeneratorfunction
     assert hookimpls[0].tryfirst
 
+    hookimpls = pm.hook.x1meth3.get_hookimpls()
+    assert len(hookimpls) == 1
+    assert not hookimpls[0].hookwrapper
+    assert hookimpls[0].isgeneratorfunction
+    assert not hookimpls[0].tryfirst
+    assert hookimpls[0].trylast
+
 
 def test_warn_when_deprecated_specified(recwarn) -> None:
     warning = DeprecationWarning("foo is deprecated")
diff --git a/testing/test_hookcaller.py b/testing/test_hookcaller.py
index 5e69a00..4231f29 100644
--- a/testing/test_hookcaller.py
+++ b/testing/test_hookcaller.py
@@ -147,42 +147,56 @@ def test_adding_nonwrappers_tryfirst(hc: _HookCaller, addmeth: AddMeth) -> None:
 
 def test_adding_wrappers_ordering(hc: _HookCaller, addmeth: AddMeth) -> None:
     @addmeth(hookwrapper=True)
-    def he_method1() -> None:
-        pass
+    def he_method1():
+        yield
 
     @addmeth()
-    def he_method1_middle() -> None:
-        pass
+    def he_method1_fun():
+        yield
+
+    @addmeth()
+    def he_method1_middle():
+        return
 
     @addmeth(hookwrapper=True)
-    def he_method3() -> None:
-        pass
+    def he_method3_fun():
+        yield
+
+    @addmeth(hookwrapper=True)
+    def he_method3():
+        yield
 
     assert funcs(hc.get_hookimpls()) == [
         he_method1_middle,
         he_method1,
+        he_method1_fun,
+        he_method3_fun,
         he_method3,
     ]
 
 
 def test_adding_wrappers_ordering_tryfirst(hc: _HookCaller, addmeth: AddMeth) -> None:
     @addmeth(hookwrapper=True, tryfirst=True)
-    def he_method1() -> None:
-        pass
+    def he_method1():
+        yield
 
     @addmeth(hookwrapper=True)
-    def he_method2() -> None:
-        pass
+    def he_method2():
+        yield
+
+    @addmeth(tryfirst=True)
+    def he_method3():
+        yield
 
-    assert funcs(hc.get_hookimpls()) == [he_method2, he_method1]
+    assert funcs(hc.get_hookimpls()) == [he_method2, he_method1, he_method3]
 
 
 def test_adding_wrappers_complex(hc: _HookCaller, addmeth: AddMeth) -> None:
     assert funcs(hc.get_hookimpls()) == []
 
     @addmeth(hookwrapper=True, trylast=True)
-    def m1() -> None:
-        ...
+    def m1():
+        yield
 
     assert funcs(hc.get_hookimpls()) == [m1]
 
@@ -204,9 +218,9 @@ def test_adding_wrappers_complex(hc: _HookCaller, addmeth: AddMeth) -> None:
 
     assert funcs(hc.get_hookimpls()) == [m3, m2, m1, m4]
 
-    @addmeth(hookwrapper=True, tryfirst=True)
-    def m5() -> None:
-        ...
+    @addmeth(tryfirst=True)
+    def m5():
+        yield
 
     assert funcs(hc.get_hookimpls()) == [m3, m2, m1, m4, m5]
 
@@ -222,9 +236,9 @@ def test_adding_wrappers_complex(hc: _HookCaller, addmeth: AddMeth) -> None:
 
     assert funcs(hc.get_hookimpls()) == [m3, m2, m7, m6, m1, m4, m5]
 
-    @addmeth(hookwrapper=True)
-    def m8() -> None:
-        ...
+    @addmeth()
+    def m8():
+        yield
 
     assert funcs(hc.get_hookimpls()) == [m3, m2, m7, m6, m1, m4, m8, m5]
 
@@ -246,9 +260,9 @@ def test_adding_wrappers_complex(hc: _HookCaller, addmeth: AddMeth) -> None:
 
     assert funcs(hc.get_hookimpls()) == [m9, m3, m2, m7, m6, m10, m11, m1, m4, m8, m5]
 
-    @addmeth(hookwrapper=True)
-    def m12() -> None:
-        ...
+    @addmeth()
+    def m12():
+        yield
 
     assert funcs(hc.get_hookimpls()) == [
         m9,
diff --git a/testing/test_invocations.py b/testing/test_invocations.py
index 707810d..6aac1dc 100644
--- a/testing/test_invocations.py
+++ b/testing/test_invocations.py
@@ -103,10 +103,19 @@ def test_call_order(pm: PluginManager) -> None:
             assert outcome.exception is None
             assert outcome.excinfo is None
 
+    class Plugin5:
+        @hookimpl
+        def hello(self, arg):
+            assert arg == 0
+            outcome = yield
+            assert outcome == [3, 2, 1]
+            return outcome
+
     pm.register(Plugin1())
     pm.register(Plugin2())
     pm.register(Plugin3())
     pm.register(Plugin4())  # hookwrapper should get same list result
+    pm.register(Plugin5())  # hookwrapper should get same list result
     res = pm.hook.hello(arg=0)
     assert res == [3, 2, 1]
 
@@ -135,6 +144,13 @@ def test_firstresult_definition(pm: PluginManager) -> None:
             return None
 
     class Plugin4:
+        def hello(self, arg):
+            assert arg == 3
+            outcome = yield
+            assert outcome == 2
+            return outcome
+
+    class Plugin5:
         @hookimpl(hookwrapper=True)
         def hello(self, arg):
             assert arg == 3
@@ -145,11 +161,12 @@ def test_firstresult_definition(pm: PluginManager) -> None:
     pm.register(Plugin2())  # used as result
     pm.register(Plugin3())  # None result is ignored
     pm.register(Plugin4())  # hookwrapper should get same non-list result
+    pm.register(Plugin5())  # hookwrapper should get same non-list result
     res = pm.hook.hello(arg=3)
     assert res == 2
 
 
-def test_firstresult_force_result(pm: PluginManager) -> None:
+def test_firstresult_force_result_hookwrapper(pm: PluginManager) -> None:
     """Verify forcing a result in a wrapper."""
 
     class Api:
@@ -184,6 +201,41 @@ def test_firstresult_force_result(pm: PluginManager) -> None:
     assert res == 0  # this result is forced and not a list
 
 
+def test_firstresult_force_result(pm: PluginManager) -> None:
+    """Verify forcing a result in a wrapper."""
+
+    class Api:
+        @hookspec(firstresult=True)
+        def hello(self, arg):
+            "api hook 1"
+
+    pm.add_hookspecs(Api)
+
+    class Plugin1:
+        @hookimpl
+        def hello(self, arg):
+            return arg + 1
+
+    class Plugin2:
+        @hookimpl
+        def hello(self, arg):
+            assert arg == 3
+            outcome = yield
+            assert outcome == 4
+            return 0
+
+    class Plugin3:
+        @hookimpl
+        def hello(self, arg):
+            return None
+
+    pm.register(Plugin1())
+    pm.register(Plugin2())  # wrapper
+    pm.register(Plugin3())  # ignored since returns None
+    res = pm.hook.hello(arg=3)
+    assert res == 0  # this result is forced and not a list
+
+
 def test_firstresult_returns_none(pm: PluginManager) -> None:
     """If None results are returned by underlying implementations ensure
     the multi-call loop returns a None value.
diff --git a/testing/test_multicall.py b/testing/test_multicall.py
index 9ec21ac..851f1e7 100644
--- a/testing/test_multicall.py
+++ b/testing/test_multicall.py
@@ -101,6 +101,50 @@ def test_hookwrapper() -> None:
     assert out == ["m1 init", "m2", "m1 finish"]
 
 
+def test_hookwrapper_two_yields() -> None:
+    @hookimpl(hookwrapper=True)
+    def m():
+        yield
+        yield
+
+    with pytest.raises(RuntimeError, match="has second yield"):
+        MC([m], {})
+
+
+def test_generator_fun() -> None:
+    out = []
+
+    @hookimpl
+    def m1():
+        out.append("m1 init")
+        result = yield
+        out.append("m1 finish")
+        return result * 2
+
+    @hookimpl
+    def m2():
+        out.append("m2")
+        return 2
+
+    res = MC([m2, m1], {})
+    assert res == [2, 2]
+    assert out == ["m1 init", "m2", "m1 finish"]
+    out[:] = []
+    res = MC([m2, m1], {}, firstresult=True)
+    assert res == 4
+    assert out == ["m1 init", "m2", "m1 finish"]
+
+
+def test_generator_fun_two_yields() -> None:
+    @hookimpl
+    def m():
+        yield
+        yield
+
+    with pytest.raises(RuntimeError, match="has second yield"):
+        MC([m], {})
+
+
 def test_hookwrapper_order() -> None:
     out = []
 
@@ -110,15 +154,37 @@ def test_hookwrapper_order() -> None:
         yield 1
         out.append("m1 finish")
 
-    @hookimpl(hookwrapper=True)
+    @hookimpl
     def m2():
         out.append("m2 init")
-        yield 2
+        result = yield 2
         out.append("m2 finish")
+        return result
 
-    res = MC([m2, m1], {})
+    @hookimpl(hookwrapper=True)
+    def m3():
+        out.append("m3 init")
+        yield 3
+        out.append("m3 finish")
+
+    @hookimpl(hookwrapper=True)
+    def m4():
+        out.append("m4 init")
+        yield 4
+        out.append("m4 finish")
+
+    res = MC([m4, m3, m2, m1], {})
     assert res == []
-    assert out == ["m1 init", "m2 init", "m2 finish", "m1 finish"]
+    assert out == [
+        "m1 init",
+        "m2 init",
+        "m3 init",
+        "m4 init",
+        "m4 finish",
+        "m3 finish",
+        "m2 finish",
+        "m1 finish",
+    ]
 
 
 def test_hookwrapper_not_yield() -> None:
@@ -130,6 +196,16 @@ def test_hookwrapper_not_yield() -> None:
         MC([m1], {})
 
 
+def test_hookwrapper_yield_not_executed() -> None:
+    @hookimpl(hookwrapper=True)
+    def m1():
+        if False:
+            yield  # type: ignore[unreachable]
+
+    with pytest.raises(RuntimeError, match="did not yield"):
+        MC([m1], {})
+
+
 def test_hookwrapper_too_many_yield() -> None:
     @hookimpl(hookwrapper=True)
     def m1():
@@ -142,6 +218,37 @@ def test_hookwrapper_too_many_yield() -> None:
     assert (__file__ + ":") in str(ex.value)
 
 
+def test_generator_fun_yield_not_executed() -> None:
+    @hookimpl
+    def m1():
+        if False:
+            yield  # type: ignore[unreachable]
+
+    with pytest.raises(RuntimeError, match="did not yield"):
+        MC([m1], {})
+
+
+def test_generator_fun_too_many_yield() -> None:
+    out = []
+
+    @hookimpl
+    def m1():
+        try:
+            yield 1
+            yield 2
+        finally:
+            out.append("cleanup")
+
+    with pytest.raises(RuntimeError) as ex:
+        try:
+            MC([m1], {})
+        finally:
+            out.append("finally")
+    assert "m1" in str(ex.value)
+    assert (__file__ + ":") in str(ex.value)
+    assert out == ["cleanup", "finally"]
+
+
 @pytest.mark.parametrize("exc", [ValueError, SystemExit])
 def test_hookwrapper_exception(exc: "Type[BaseException]") -> None:
     out = []
@@ -210,3 +317,138 @@ def test_hookwrapper_force_exception() -> None:
     ]
     assert excinfo.value.__cause__ is not None
     assert str(excinfo.value.__cause__) == "m4"
+
+
+@pytest.mark.parametrize("exc", [ValueError, SystemExit])
+def test_generator_fun_exception(exc: "Type[BaseException]") -> None:
+    out = []
+
+    @hookimpl
+    def m1():
+        out.append("m1 init")
+        try:
+            result = yield
+        except BaseException as e:
+            assert isinstance(e, exc)
+            raise
+        finally:
+            out.append("m1 finish")
+        return result
+
+    @hookimpl
+    def m2():
+        out.append("m2 init")
+        raise exc
+
+    with pytest.raises(exc):
+        MC([m2, m1], {})
+    assert out == ["m1 init", "m2 init", "m1 finish"]
+
+
+def test_generator_fun_exception_chaining() -> None:
+    @hookimpl
+    def m1():
+        raise Exception("m1")
+
+    @hookimpl
+    def m2():
+        try:
+            yield
+        except Exception:
+            raise Exception("m2")
+
+    @hookimpl
+    def m3():
+        yield
+        return 10
+
+    @hookimpl
+    def m4():
+        try:
+            yield
+        except Exception as e:
+            raise Exception("m4") from e
+
+    with pytest.raises(Exception) as excinfo:
+        MC([m1, m2, m3, m4], {})
+    assert str(excinfo.value) == "m4"
+    assert excinfo.value.__cause__ is not None
+    assert str(excinfo.value.__cause__) == "m2"
+    assert excinfo.value.__cause__.__context__ is not None
+    assert str(excinfo.value.__cause__.__context__) == "m1"
+
+
+def test_unwind_inner_wrapper_teardown_exc() -> None:
+    out = []
+
+    @hookimpl
+    def m1():
+        out.append("m1 init")
+        try:
+            yield
+            out.append("m1 unreachable")
+        except BaseException:
+            out.append("m1 teardown")
+            raise
+        finally:
+            out.append("m1 cleanup")
+
+    @hookimpl
+    def m2():
+        out.append("m2 init")
+        yield
+        out.append("m2 raise")
+        raise ValueError()
+
+    with pytest.raises(ValueError):
+        try:
+            MC([m2, m1], {})
+        finally:
+            out.append("finally")
+
+    assert out == [
+        "m1 init",
+        "m2 init",
+        "m2 raise",
+        "m1 teardown",
+        "m1 cleanup",
+        "finally",
+    ]
+
+
+def test_suppress_inner_wrapper_teardown_exc() -> None:
+    out = []
+
+    @hookimpl
+    def m1():
+        out.append("m1 init")
+        result = yield
+        out.append("m1 finish")
+        return result
+
+    @hookimpl
+    def m2():
+        out.append("m2 init")
+        try:
+            yield
+            out.append("m2 unreachable")
+        except ValueError:
+            out.append("m2 suppress")
+            return 22
+
+    @hookimpl
+    def m3():
+        out.append("m3 init")
+        yield
+        out.append("m3 raise")
+        raise ValueError()
+
+    assert MC([m3, m2, m1], {}) == 22
+    assert out == [
+        "m1 init",
+        "m2 init",
+        "m3 init",
+        "m3 raise",
+        "m2 suppress",
+        "m1 finish",
+    ]
diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py
index 97aa343..64ed514 100644
--- a/testing/test_pluginmanager.py
+++ b/testing/test_pluginmanager.py
@@ -360,6 +360,23 @@ def test_register_historic_incompat_hookwrapper(pm: PluginManager) -> None:
         pm.register(Plugin())
 
 
+def test_register_historic_incompat_generator_fun(pm: PluginManager) -> None:
+    class Hooks:
+        @hookspec(historic=True)
+        def he_method1(self, arg):
+            pass
+
+    pm.add_hookspecs(Hooks)
+
+    class Plugin:
+        @hookimpl()
+        def he_method1(self, arg):
+            yield
+
+    with pytest.raises(PluginValidationError):
+        pm.register(Plugin())
+
+
 def test_call_extra(pm: PluginManager) -> None:
     class Hooks:
         @hookspec