Skip to content

Commit e241aed

Browse files
committed
Make new-style wrappers use explicit wrapper=True
Previously new-style wrappers were implied by the function being a generator, however that broke a use case of a non-wrapper impl being a generator as a way to return an Iterator result. This is legitimate, and used at least by conda (see #403). Therefore, change to require an explicit `wrapper=True`. Also adds a test for the iterator-returning case. Fix #405.
1 parent 8c9866d commit e241aed

13 files changed

+155
-66
lines changed

.coveragerc

+2
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,7 @@ exclude_lines =
2323

2424
if __name__ == .__main__.:
2525

26+
raise NotImplementedError
27+
2628
# Ignore coverage on lines solely with `...`
2729
^\s*\.\.\.\s*$

CHANGELOG.rst

+3-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ pluggy 1.1.0 (YANKED)
2626

2727
.. note::
2828

29-
This release was yanked because unfortunately the new-style hook wrappers broke some downstream projects. See `#403 <https://github.com/pytest-dev/pluggy/issues/403>`__ for more information.
29+
This release was yanked because unfortunately the implicit new-style hook wrappers broke some downstream projects.
30+
See `#403 <https://github.com/pytest-dev/pluggy/issues/403>`__ for more information.
31+
This was rectified in the 1.2.0 release.
3032

3133
Deprecations and Removals
3234
-------------------------

changelog/405.feature.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The new-style hook wrappers, added in the yanked 1.1.0 release, now require an explicit ``wrapper=True`` designation in the ``@hookimpl()`` decorator.

docs/index.rst

+13-7
Original file line numberDiff line numberDiff line change
@@ -366,19 +366,25 @@ Wrappers
366366
.. note::
367367
This section describes "new-style hook wrappers", which were added in Pluggy
368368
1.1. For earlier versions, see the "old-style hook wrappers" section below.
369-
The two styles are fully interoperable.
370369

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*.
370+
New-style hooks wrappers are declared with ``wrapper=True``, while
371+
old-style hook wrappers are declared with ``hookwrapper=True``.
372+
373+
The two styles are fully interoperable between plugins using different
374+
styles. However within the same plugin we recommend using only one style,
375+
for consistency.
376+
377+
A *hookimpl* can be marked with the ``"wrapper"`` option, which indicates
378+
that the function will be called to *wrap* (or surround) all other normal
379+
*hookimpl* calls. A *hook wrapper* can thus execute some code ahead and after the
380+
execution of all corresponding non-wrappper *hookimpls*.
375381

376382
Much in the same way as a :py:func:`@contextlib.contextmanager <python:contextlib.contextmanager>`,
377383
*hook wrappers* must be implemented as generator function with a single ``yield`` in its body:
378384

379385
.. code-block:: python
380386
381-
@hookimpl
387+
@hookimpl(wrapper=True)
382388
def setup_project(config, args):
383389
"""Wrap calls to ``setup_project()`` implementations which
384390
should return json encoded config options.
@@ -837,7 +843,7 @@ re-raised at the hook invocation point:
837843
return 3
838844
839845
840-
@hookimpl
846+
@hookimpl(wrapper=True)
841847
def myhook(self, args):
842848
try:
843849
return (yield)

src/pluggy/_callers.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def _multicall(
6666
teardowns.append((wrapper_gen,))
6767
except StopIteration:
6868
_raise_wrapfail(wrapper_gen, "did not yield")
69-
elif hook_impl.isgeneratorfunction:
69+
elif hook_impl.wrapper:
7070
try:
7171
# If this cast is not valid, a type error is raised below,
7272
# which is the desired response.

src/pluggy/_hooks.py

+16-10
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class _HookSpecOpts(TypedDict):
4545
warn_on_impl: Warning | None
4646

4747
class _HookImplOpts(TypedDict):
48+
wrapper: bool
4849
hookwrapper: bool
4950
optionalhook: bool
5051
tryfirst: bool
@@ -145,6 +146,7 @@ def __call__(
145146
tryfirst: bool = ...,
146147
trylast: bool = ...,
147148
specname: str | None = ...,
149+
wrapper: bool = ...,
148150
) -> _F:
149151
...
150152

@@ -157,6 +159,7 @@ def __call__( # noqa: F811
157159
tryfirst: bool = ...,
158160
trylast: bool = ...,
159161
specname: str | None = ...,
162+
wrapper: bool = ...,
160163
) -> Callable[[_F], _F]:
161164
...
162165

@@ -168,6 +171,7 @@ def __call__( # noqa: F811
168171
tryfirst: bool = False,
169172
trylast: bool = False,
170173
specname: str | None = None,
174+
wrapper: bool = False,
171175
) -> _F | Callable[[_F], _F]:
172176
"""If passed a function, directly sets attributes on the function
173177
which will make it discoverable to :meth:`PluginManager.register`.
@@ -185,8 +189,7 @@ def __call__( # noqa: F811
185189
If ``trylast`` is ``True``, this hook implementation will run as late as
186190
possible in the chain of N hook implementations.
187191
188-
If the hook implementation is a generator function, and ``hookwrapper``
189-
is ``False`` or not set ("new-style hook wrapper"), the hook
192+
If ``wrapper`` is ``True``("new-style hook wrapper"), the hook
190193
implementation needs to execute exactly one ``yield``. The code before
191194
the ``yield`` is run early before any non-hook-wrapper function is run.
192195
The code after the ``yield`` is run after all non-hook-wrapper functions
@@ -201,18 +204,19 @@ def __call__( # noqa: F811
201204
The code after the ``yield`` is run after all non-hook-wrapper function
202205
have run The ``yield`` receives a :class:`_Result` object representing
203206
the exception or result outcome of the inner calls (including earlier
204-
hook wrapper calls).
207+
hook wrapper calls). This option is mutually exclusive with ``wrapper``.
205208
206209
If ``specname`` is provided, it will be used instead of the function
207210
name when matching this hook implementation to a hook specification
208211
during registration.
209212
210-
.. versionadded:: 1.1
211-
New-style hook wrappers.
213+
.. versionadded:: 1.2.0
214+
The ``wrapper`` parameter.
212215
"""
213216

214217
def setattr_hookimpl_opts(func: _F) -> _F:
215218
opts: _HookImplOpts = {
219+
"wrapper": wrapper,
216220
"hookwrapper": hookwrapper,
217221
"optionalhook": optionalhook,
218222
"tryfirst": tryfirst,
@@ -231,6 +235,7 @@ def setattr_hookimpl_opts(func: _F) -> _F:
231235
def normalize_hookimpl_opts(opts: _HookImplOpts) -> None:
232236
opts.setdefault("tryfirst", False)
233237
opts.setdefault("trylast", False)
238+
opts.setdefault("wrapper", False)
234239
opts.setdefault("hookwrapper", False)
235240
opts.setdefault("optionalhook", False)
236241
opts.setdefault("specname", None)
@@ -373,12 +378,12 @@ def get_hookimpls(self) -> list[HookImpl]:
373378
def _add_hookimpl(self, hookimpl: HookImpl) -> None:
374379
"""Add an implementation to the callback chain."""
375380
for i, method in enumerate(self._hookimpls):
376-
if method.hookwrapper or method.isgeneratorfunction:
381+
if method.hookwrapper or method.wrapper:
377382
splitpoint = i
378383
break
379384
else:
380385
splitpoint = len(self._hookimpls)
381-
if hookimpl.hookwrapper or hookimpl.isgeneratorfunction:
386+
if hookimpl.hookwrapper or hookimpl.wrapper:
382387
start, end = splitpoint, len(self._hookimpls)
383388
else:
384389
start, end = 0, splitpoint
@@ -457,6 +462,7 @@ def call_extra(
457462
), "Cannot directly call a historic hook - use call_historic instead."
458463
self._verify_all_args_are_provided(kwargs)
459464
opts: _HookImplOpts = {
465+
"wrapper": False,
460466
"hookwrapper": False,
461467
"optionalhook": False,
462468
"trylast": False,
@@ -471,7 +477,7 @@ def call_extra(
471477
while (
472478
i >= 0
473479
and hookimpls[i].tryfirst
474-
and not (hookimpls[i].hookwrapper or hookimpls[i].isgeneratorfunction)
480+
and not (hookimpls[i].hookwrapper or hookimpls[i].wrapper)
475481
):
476482
i -= 1
477483
hookimpls.insert(i + 1, hookimpl)
@@ -545,7 +551,7 @@ class HookImpl:
545551
"plugin",
546552
"opts",
547553
"plugin_name",
548-
"isgeneratorfunction",
554+
"wrapper",
549555
"hookwrapper",
550556
"optionalhook",
551557
"tryfirst",
@@ -564,7 +570,7 @@ def __init__(
564570
self.plugin = plugin
565571
self.opts = hook_impl_opts
566572
self.plugin_name = plugin_name
567-
self.isgeneratorfunction = inspect.isgeneratorfunction(self.function)
573+
self.wrapper = hook_impl_opts["wrapper"]
568574
self.hookwrapper = hook_impl_opts["hookwrapper"]
569575
self.optionalhook = hook_impl_opts["optionalhook"]
570576
self.tryfirst = hook_impl_opts["tryfirst"]

src/pluggy/_manager.py

+15-6
Original file line numberDiff line numberDiff line change
@@ -290,12 +290,10 @@ 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 (
294-
hookimpl.hookwrapper or hookimpl.isgeneratorfunction
295-
):
293+
if hook.is_historic() and (hookimpl.hookwrapper or hookimpl.wrapper):
296294
raise PluginValidationError(
297295
hookimpl.plugin,
298-
"Plugin %r\nhook %r\nhistoric incompatible with yield/hookwrapper"
296+
"Plugin %r\nhook %r\nhistoric incompatible with yield/wrapper/hookwrapper"
299297
% (hookimpl.plugin_name, hook.name),
300298
)
301299

@@ -319,11 +317,22 @@ def _verify_hook(self, hook: _HookCaller, hookimpl: HookImpl) -> None:
319317
),
320318
)
321319

322-
if hookimpl.hookwrapper and not inspect.isgeneratorfunction(hookimpl.function):
320+
if (
321+
hookimpl.wrapper or hookimpl.hookwrapper
322+
) and not inspect.isgeneratorfunction(hookimpl.function):
323323
raise PluginValidationError(
324324
hookimpl.plugin,
325325
"Plugin %r for hook %r\nhookimpl definition: %s\n"
326-
"Declared as hookwrapper=True but function is not a generator function"
326+
"Declared as wrapper=True or hookwrapper=True "
327+
"but function is not a generator function"
328+
% (hookimpl.plugin_name, hook.name, _formatdef(hookimpl.function)),
329+
)
330+
331+
if hookimpl.wrapper and hookimpl.hookwrapper:
332+
raise PluginValidationError(
333+
hookimpl.plugin,
334+
"Plugin %r for hook %r\nhookimpl definition: %s\n"
335+
"The wrapper=True and hookwrapper=True options are mutually exclusive"
327336
% (hookimpl.plugin_name, hook.name, _formatdef(hookimpl.function)),
328337
)
329338

testing/benchmark.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def hook(arg1, arg2, arg3):
1919
return arg1, arg2, arg3
2020

2121

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

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

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

testing/test_details.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def x1meth(self):
2525
def x1meth2(self):
2626
yield # pragma: no cover
2727

28-
@hookimpl(trylast=True)
28+
@hookimpl(wrapper=True, trylast=True)
2929
def x1meth3(self):
3030
return (yield) # pragma: no cover
3131

@@ -49,21 +49,21 @@ def x1meth3(self):
4949
hookimpls = pm.hook.x1meth.get_hookimpls()
5050
assert len(hookimpls) == 1
5151
assert not hookimpls[0].hookwrapper
52-
assert not hookimpls[0].isgeneratorfunction
52+
assert not hookimpls[0].wrapper
5353
assert not hookimpls[0].tryfirst
5454
assert not hookimpls[0].trylast
5555
assert not hookimpls[0].optionalhook
5656

5757
hookimpls = pm.hook.x1meth2.get_hookimpls()
5858
assert len(hookimpls) == 1
5959
assert hookimpls[0].hookwrapper
60-
assert hookimpls[0].isgeneratorfunction
60+
assert not hookimpls[0].wrapper
6161
assert hookimpls[0].tryfirst
6262

6363
hookimpls = pm.hook.x1meth3.get_hookimpls()
6464
assert len(hookimpls) == 1
6565
assert not hookimpls[0].hookwrapper
66-
assert hookimpls[0].isgeneratorfunction
66+
assert hookimpls[0].wrapper
6767
assert not hookimpls[0].tryfirst
6868
assert hookimpls[0].trylast
6969

testing/test_hookcaller.py

+16-7
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,19 @@ def __init__(self, hc: _HookCaller) -> None:
3535
self.hc = hc
3636

3737
def __call__(
38-
self, tryfirst: bool = False, trylast: bool = False, hookwrapper: bool = False
38+
self,
39+
tryfirst: bool = False,
40+
trylast: bool = False,
41+
hookwrapper: bool = False,
42+
wrapper: bool = False,
3943
) -> Callable[[FuncT], FuncT]:
4044
def wrap(func: FuncT) -> FuncT:
41-
hookimpl(tryfirst=tryfirst, trylast=trylast, hookwrapper=hookwrapper)(func)
45+
hookimpl(
46+
tryfirst=tryfirst,
47+
trylast=trylast,
48+
hookwrapper=hookwrapper,
49+
wrapper=wrapper,
50+
)(func)
4251
self.hc._add_hookimpl(
4352
HookImpl(None, "<temp>", func, func.example_impl), # type: ignore[attr-defined]
4453
)
@@ -150,7 +159,7 @@ def test_adding_wrappers_ordering(hc: _HookCaller, addmeth: AddMeth) -> None:
150159
def he_method1():
151160
yield
152161

153-
@addmeth()
162+
@addmeth(wrapper=True)
154163
def he_method1_fun():
155164
yield
156165

@@ -184,7 +193,7 @@ def he_method1():
184193
def he_method2():
185194
yield
186195

187-
@addmeth(tryfirst=True)
196+
@addmeth(wrapper=True, tryfirst=True)
188197
def he_method3():
189198
yield
190199

@@ -218,7 +227,7 @@ def m4() -> None:
218227

219228
assert funcs(hc.get_hookimpls()) == [m3, m2, m1, m4]
220229

221-
@addmeth(tryfirst=True)
230+
@addmeth(wrapper=True, tryfirst=True)
222231
def m5():
223232
yield
224233

@@ -236,7 +245,7 @@ def m7() -> None:
236245

237246
assert funcs(hc.get_hookimpls()) == [m3, m2, m7, m6, m1, m4, m5]
238247

239-
@addmeth()
248+
@addmeth(wrapper=True)
240249
def m8():
241250
yield
242251

@@ -260,7 +269,7 @@ def m11() -> None:
260269

261270
assert funcs(hc.get_hookimpls()) == [m9, m3, m2, m7, m6, m10, m11, m1, m4, m8, m5]
262271

263-
@addmeth()
272+
@addmeth(wrapper=True)
264273
def m12():
265274
yield
266275

0 commit comments

Comments
 (0)