Skip to content

Commit 1e64428

Browse files
committed
Add hook wrapping without _Result
Fix #260.
1 parent f6ea668 commit 1e64428

File tree

12 files changed

+441
-80
lines changed

12 files changed

+441
-80
lines changed

changelog/260.feature.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Add "new-style" hook wrappers, a simpler but equally powerful alternative to the existing ``hookwrapper=True`` wrappers.
2+
3+
New-style wrappers are generator functions, similarly to ``hookwrapper``, but do away with the :class:`result <pluggy._callers._Result>` object.
4+
Instead, the return value is sent directly to the ``yield`` statement, or, if inner calls raised an exception, it is raised from the ``yield``.
5+
The wrapper is excepted to return a value or raise an exception, which will become the result of the hook call.
6+
7+
New-style wrappers are fully interoperable with old-style wrappers.
8+
We encourage users to use the new style, however we do not intend to deprecate the old style any time soon.
9+
10+
See :ref:`hookwrappers` for the full documentation.

docs/index.rst

Lines changed: 80 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -362,13 +362,73 @@ For another example see the :ref:`pytest:plugin-hookorder` section of the
362362

363363
Wrappers
364364
^^^^^^^^
365-
A *hookimpl* can be marked with a ``"hookwrapper"`` option which indicates that
366-
the function will be called to *wrap* (or surround) all other normal *hookimpl*
367-
calls. A *hookwrapper* can thus execute some code ahead and after the execution
368-
of all corresponding non-wrappper *hookimpls*.
369365

370-
Much in the same way as a :py:func:`@contextlib.contextmanager <python:contextlib.contextmanager>`, *hookwrappers* must
371-
be implemented as generator function with a single ``yield`` in its body:
366+
.. note::
367+
This section describes "new-style hook wrappers", which were added in Pluggy
368+
1.1. For earlier versions, see the "old-style hook wrappers" section below.
369+
The two styles are fully interoperable.
370+
371+
A *hookimpl* can be a generator function, which indicates that the function will
372+
be called to *wrap* (or surround) all other normal *hookimpl* calls. A *hook
373+
wrapper* can thus execute some code ahead and after the execution of all
374+
corresponding non-wrappper *hookimpls*.
375+
376+
Much in the same way as a :py:func:`@contextlib.contextmanager <python:contextlib.contextmanager>`,
377+
*hook wrappers* must be implemented as generator function with a single ``yield`` in its body:
378+
379+
.. code-block:: python
380+
381+
@hookimpl
382+
def setup_project(config, args):
383+
"""Wrap calls to ``setup_project()`` implementations which
384+
should return json encoded config options.
385+
"""
386+
# get initial default config
387+
defaults = config.tojson()
388+
389+
if config.debug:
390+
print("Pre-hook config is {}".format(config.tojson()))
391+
392+
# all corresponding hookimpls are invoked here
393+
result = yield
394+
395+
for item in result:
396+
print("JSON config override is {}".format(item))
397+
398+
if config.debug:
399+
print("Post-hook config is {}".format(config.tojson()))
400+
401+
if config.use_defaults:
402+
return defaults
403+
else:
404+
return result
405+
406+
The generator is :py:meth:`sent <python:generator.send>` the return value
407+
of the hook thus far, or, if the previous calls raised an exception, it is
408+
:py:meth:`thrown <python:generator.throw>` the exception.
409+
410+
The function should do one of two things:
411+
- Return a value, which can be the same value as received from the ``yield``, or
412+
something else entirely.
413+
- Raise an exception.
414+
The return value or exception propagate to further hook wrappers, and finally
415+
to the hook caller.
416+
417+
Also see the :ref:`pytest:hookwrapper` section in the ``pytest`` docs.
418+
419+
Old-style wrappers
420+
^^^^^^^^^^^^^^^^^^
421+
422+
.. note::
423+
Prefer to use new-style hook wrappers, unless you need to support Pluggy
424+
versions before 1.1.
425+
426+
A *hookimpl* can be marked with the ``"hookwrapper"`` option, which indicates
427+
that the function will be called to *wrap* (or surround) all other normal
428+
*hookimpl* calls. A *hookwrapper* can thus execute some code ahead and after the
429+
execution of all corresponding non-wrappper *hookimpls*.
430+
431+
*hookwrappers* must be implemented as generator function with a single ``yield`` in its body:
372432

373433

374434
.. code-block:: python
@@ -411,16 +471,14 @@ the exception using the :py:meth:`~pluggy._callers._Result.force_exception`
411471
method.
412472

413473
.. note::
414-
Hookwrappers can **not** return results; they can only modify them using
415-
the :py:meth:`~pluggy._callers._Result.force_result` API.
474+
Old-style hook wrappers can **not** return results; they can only modify
475+
them using the :py:meth:`~pluggy._callers._Result.force_result` API.
416476

417-
Hookwrappers should **not** raise exceptions; this will cause further
418-
hookwrappers to be skipped. They should use
477+
Old-style Hook wrappers should **not** raise exceptions; this will cause
478+
further hookwrappers to be skipped. They should use
419479
:py:meth:`~pluggy._callers._Result.force_exception` to adjust the
420480
exception.
421481

422-
Also see the :ref:`pytest:hookwrapper` section in the ``pytest`` docs.
423-
424482
.. _specs:
425483

426484
Specifications
@@ -538,7 +596,7 @@ than ``None``.
538596
This can be useful for optimizing a call loop for which you are only
539597
interested in a single core *hookimpl*. An example is the
540598
:func:`~_pytest.hookspec.pytest_cmdline_main` central routine of ``pytest``.
541-
Note that all ``hookwrappers`` are still invoked with the first result.
599+
Note that all hook wrappers are still invoked with the first result.
542600

543601
Also see the :ref:`pytest:firstresult` section in the ``pytest`` docs.
544602

@@ -750,10 +808,9 @@ single value (which is not ``None``) will be returned.
750808

751809
Exception handling
752810
------------------
753-
If any *hookimpl* errors with an exception no further callbacks
754-
are invoked and the exception is packaged up and delivered to
755-
any :ref:`wrappers <hookwrappers>` before being re-raised at the
756-
hook invocation point:
811+
If any *hookimpl* errors with an exception no further callbacks are invoked and
812+
the exception is delivered to any :ref:`wrappers <hookwrappers>` before being
813+
re-raised at the hook invocation point:
757814

758815
.. code-block:: python
759816
@@ -780,15 +837,14 @@ hook invocation point:
780837
return 3
781838
782839
783-
@hookimpl(hookwrapper=True)
840+
@hookimpl
784841
def myhook(self, args):
785-
outcome = yield
786-
787842
try:
788-
outcome.get_result()
789-
except RuntimeError:
790-
# log the error details
791-
print(outcome.exception)
843+
return (yield)
844+
except RuntimeError as exc:
845+
# log runtime error details
846+
print(exc)
847+
raise
792848
793849
794850
pm = PluginManager("myproject")

src/pluggy/_callers.py

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
from typing import Generator
88
from typing import Mapping
99
from typing import Sequence
10+
from typing import Tuple
1011
from typing import TYPE_CHECKING
12+
from typing import Union
1113

1214
from ._result import _raise_wrapfail
1315
from ._result import _Result
@@ -17,6 +19,14 @@
1719
from ._hooks import HookImpl
1820

1921

22+
# Need to distinguish between old- and new-style hook wrappers.
23+
# Wrapping one a singleton tuple is the fastest type-safe way I found to do it.
24+
Teardown = Union[
25+
Tuple[Generator[None, _Result[object], None]],
26+
Generator[None, object, object],
27+
]
28+
29+
2030
def _multicall(
2131
hook_name: str,
2232
hook_impls: Sequence[HookImpl],
@@ -32,7 +42,7 @@ def _multicall(
3242
results: list[object] = []
3343
exception = None
3444
try: # run impl and wrapper setup functions in a loop
35-
teardowns = []
45+
teardowns: list[Teardown] = []
3646
try:
3747
for hook_impl in reversed(hook_impls):
3848
try:
@@ -49,11 +59,21 @@ def _multicall(
4959
# If this cast is not valid, a type error is raised below,
5060
# which is the desired response.
5161
res = hook_impl.function(*args)
52-
gen = cast(Generator[None, _Result[object], None], res)
53-
next(gen) # first yield
54-
teardowns.append(gen)
62+
wrapper_gen = cast(Generator[None, _Result[object], None], res)
63+
next(wrapper_gen) # first yield
64+
teardowns.append((wrapper_gen,))
5565
except StopIteration:
56-
_raise_wrapfail(gen, "did not yield")
66+
_raise_wrapfail(wrapper_gen, "did not yield")
67+
elif hook_impl.isgeneratorfunction:
68+
try:
69+
# If this cast is not valid, a type error is raised below,
70+
# which is the desired response.
71+
res = hook_impl.function(*args)
72+
function_gen = cast(Generator[None, object, object], res)
73+
next(function_gen) # first yield
74+
teardowns.append(function_gen)
75+
except StopIteration:
76+
_raise_wrapfail(function_gen, "did not yield")
5777
else:
5878
res = hook_impl.function(*args)
5979
if res is not None:
@@ -71,11 +91,25 @@ def _multicall(
7191
outcome = _Result(results, exception)
7292

7393
# run all wrapper post-yield blocks
74-
for gen in reversed(teardowns):
75-
try:
76-
gen.send(outcome)
77-
_raise_wrapfail(gen, "has second yield")
78-
except StopIteration:
79-
pass
94+
for teardown in reversed(teardowns):
95+
if isinstance(teardown, tuple):
96+
try:
97+
teardown[0].send(outcome)
98+
_raise_wrapfail(teardown[0], "has second yield")
99+
except StopIteration:
100+
pass
101+
else:
102+
try:
103+
if outcome._exception is not None:
104+
teardown.throw(outcome._exception)
105+
else:
106+
teardown.send(outcome._result)
107+
except StopIteration as si:
108+
outcome.force_result(si.value)
109+
continue
110+
except BaseException as e:
111+
outcome.force_exception(e)
112+
continue
113+
_raise_wrapfail(teardown, "has second yield")
80114

81115
return outcome.get_result()

src/pluggy/_hooks.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -185,17 +185,30 @@ def __call__( # noqa: F811
185185
If ``trylast`` is ``True``, this hook implementation will run as late as
186186
possible in the chain of N hook implementations.
187187
188-
If ``hookwrapper`` is ``True``, the hook implementations needs to
189-
execute exactly one ``yield``. The code before the ``yield`` is run
190-
early before any non-hookwrapper function is run. The code after the
191-
``yield`` is run after all non-hookwrapper function have run The
192-
``yield`` receives a :class:`_Result` object representing the exception
193-
or result outcome of the inner calls (including other hookwrapper
194-
calls).
188+
If the hook implementation is a generator function, and ``hookwrapper``
189+
is ``False`` or not set ("new-style hook wrapper"), the hook
190+
implementation needs to execute exactly one ``yield``. The code before
191+
the ``yield`` is run early before any non-hook-wrapper function is run.
192+
The code after the ``yield`` is run after all non-hook-wrapper functions
193+
have run. The ``yield`` receives the result value of the inner calls, or
194+
raises the exception of inner calls (including earlier hook wrapper
195+
calls). The return value of the function becomes the return value of the
196+
hook, and a raised exception becomes the exception of the hook.
197+
198+
If ``hookwrapper`` is ``True`` ("old-style hook wrapper"), the hook
199+
implementation needs to execute exactly one ``yield``. The code before
200+
the ``yield`` is run early before any non-hook-wrapper function is run.
201+
The code after the ``yield`` is run after all non-hook-wrapper function
202+
have run The ``yield`` receives a :class:`_Result` object representing
203+
the exception or result outcome of the inner calls (including earlier
204+
hook wrapper calls).
195205
196206
If ``specname`` is provided, it will be used instead of the function
197207
name when matching this hook implementation to a hook specification
198208
during registration.
209+
210+
.. versionadded:: 1.1
211+
New-style hook wrappers.
199212
"""
200213

201214
def setattr_hookimpl_opts(func: _F) -> _F:
@@ -360,12 +373,12 @@ def get_hookimpls(self) -> list[HookImpl]:
360373
def _add_hookimpl(self, hookimpl: HookImpl) -> None:
361374
"""Add an implementation to the callback chain."""
362375
for i, method in enumerate(self._hookimpls):
363-
if method.hookwrapper:
376+
if method.hookwrapper or method.isgeneratorfunction:
364377
splitpoint = i
365378
break
366379
else:
367380
splitpoint = len(self._hookimpls)
368-
if hookimpl.hookwrapper:
381+
if hookimpl.hookwrapper or hookimpl.isgeneratorfunction:
369382
start, end = splitpoint, len(self._hookimpls)
370383
else:
371384
start, end = 0, splitpoint
@@ -455,7 +468,11 @@ def call_extra(
455468
hookimpl = HookImpl(None, "<temp>", method, opts)
456469
# Find last non-tryfirst nonwrapper method.
457470
i = len(hookimpls) - 1
458-
while i >= 0 and hookimpls[i].tryfirst and not hookimpls[i].hookwrapper:
471+
while (
472+
i >= 0
473+
and hookimpls[i].tryfirst
474+
and not (hookimpls[i].hookwrapper or hookimpls[i].isgeneratorfunction)
475+
):
459476
i -= 1
460477
hookimpls.insert(i + 1, hookimpl)
461478
firstresult = self.spec.opts.get("firstresult", False) if self.spec else False
@@ -528,6 +545,7 @@ class HookImpl:
528545
"plugin",
529546
"opts",
530547
"plugin_name",
548+
"isgeneratorfunction",
531549
"hookwrapper",
532550
"optionalhook",
533551
"tryfirst",
@@ -546,6 +564,7 @@ def __init__(
546564
self.plugin = plugin
547565
self.opts = hook_impl_opts
548566
self.plugin_name = plugin_name
567+
self.isgeneratorfunction = inspect.isgeneratorfunction(self.function)
549568
self.hookwrapper = hook_impl_opts["hookwrapper"]
550569
self.optionalhook = hook_impl_opts["optionalhook"]
551570
self.tryfirst = hook_impl_opts["tryfirst"]

src/pluggy/_manager.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,10 +290,12 @@ def get_name(self, plugin: _Plugin) -> str | None:
290290
return None
291291

292292
def _verify_hook(self, hook: _HookCaller, hookimpl: HookImpl) -> None:
293-
if hook.is_historic() and hookimpl.hookwrapper:
293+
if hook.is_historic() and (
294+
hookimpl.hookwrapper or hookimpl.isgeneratorfunction
295+
):
294296
raise PluginValidationError(
295297
hookimpl.plugin,
296-
"Plugin %r\nhook %r\nhistoric incompatible to hookwrapper"
298+
"Plugin %r\nhook %r\nhistoric incompatible with yield/hookwrapper"
297299
% (hookimpl.plugin_name, hook.name),
298300
)
299301

src/pluggy/_result.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@
2323

2424

2525
def _raise_wrapfail(
26-
wrap_controller: Generator[None, _Result[_T], None], msg: str
26+
wrap_controller: (
27+
Generator[None, _Result[_T], None] | Generator[None, object, object]
28+
),
29+
msg: str,
2730
) -> NoReturn:
2831
co = wrap_controller.gi_code
2932
raise RuntimeError(

testing/benchmark.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ def hook(arg1, arg2, arg3):
1919
return arg1, arg2, arg3
2020

2121

22-
@hookimpl(hookwrapper=True)
22+
@hookimpl
2323
def wrapper(arg1, arg2, arg3):
24-
yield
24+
return (yield)
2525

2626

2727
@pytest.fixture(params=[10, 100], ids="hooks={}".format)
@@ -70,7 +70,7 @@ def test_call_hook(benchmark, plugins, wrappers, nesting):
7070
class HookSpec:
7171
@hookspec
7272
def fun(self, hooks, nesting: int):
73-
yield
73+
pass
7474

7575
class Plugin:
7676
def __init__(self, num: int) -> None:
@@ -91,9 +91,9 @@ def __init__(self, num: int) -> None:
9191
def __repr__(self) -> str:
9292
return f"<PluginWrap {self.num}>"
9393

94-
@hookimpl(hookwrapper=True)
94+
@hookimpl
9595
def fun(self):
96-
yield
96+
return (yield)
9797

9898
pm.add_hookspecs(HookSpec)
9999

0 commit comments

Comments
 (0)