Skip to content

Commit 3a28b4d

Browse files
authored
Merge pull request #464 from bluetech/hookwrapper-teardown-warning
Warn when old-style hookwrapper raises during teardown
2 parents 4331b7a + 3bc3aab commit 3a28b4d

File tree

7 files changed

+130
-20
lines changed

7 files changed

+130
-20
lines changed

changelog/463.feature.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
A warning :class:`~pluggy.PluggyTeardownRaisedWarning` is now issued when an old-style hookwrapper raises an exception during teardown.
2+
See the warning documentation for more details.

docs/api_reference.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,15 @@ API Reference
4747
.. autoclass:: pluggy.HookimplOpts()
4848
:show-inheritance:
4949
:members:
50+
51+
52+
Warnings
53+
--------
54+
55+
Custom warnings generated in some situations such as improper usage or deprecated features.
56+
57+
.. autoclass:: pluggy.PluggyWarning()
58+
:show-inheritance:
59+
60+
.. autoclass:: pluggy.PluggyTeardownRaisedWarning()
61+
:show-inheritance:

src/pluggy/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
"HookspecMarker",
1919
"HookimplMarker",
2020
"Result",
21+
"PluggyWarning",
22+
"PluggyTeardownRaisedWarning",
2123
]
2224

2325
from ._manager import PluginManager, PluginValidationError
@@ -31,3 +33,7 @@
3133
HookimplOpts,
3234
HookImpl,
3335
)
36+
from ._warnings import (
37+
PluggyWarning,
38+
PluggyTeardownRaisedWarning,
39+
)

src/pluggy/_callers.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,52 @@
33
"""
44
from __future__ import annotations
55

6+
import warnings
67
from typing import cast
78
from typing import Generator
89
from typing import Mapping
10+
from typing import NoReturn
911
from typing import Sequence
1012
from typing import Tuple
1113
from typing import Union
1214

1315
from ._hooks import HookImpl
14-
from ._result import _raise_wrapfail
1516
from ._result import HookCallError
1617
from ._result import Result
18+
from ._warnings import PluggyTeardownRaisedWarning
1719

1820

1921
# Need to distinguish between old- and new-style hook wrappers.
20-
# Wrapping one a singleton tuple is the fastest type-safe way I found to do it.
22+
# Wrapping with a tuple is the fastest type-safe way I found to do it.
2123
Teardown = Union[
22-
Tuple[Generator[None, Result[object], None]],
24+
Tuple[Generator[None, Result[object], None], HookImpl],
2325
Generator[None, object, object],
2426
]
2527

2628

29+
def _raise_wrapfail(
30+
wrap_controller: (
31+
Generator[None, Result[object], None] | Generator[None, object, object]
32+
),
33+
msg: str,
34+
) -> NoReturn:
35+
co = wrap_controller.gi_code
36+
raise RuntimeError(
37+
"wrap_controller at %r %s:%d %s"
38+
% (co.co_name, co.co_filename, co.co_firstlineno, msg)
39+
)
40+
41+
42+
def _warn_teardown_exception(
43+
hook_name: str, hook_impl: HookImpl, e: BaseException
44+
) -> None:
45+
msg = "A plugin raised an exception during an old-style hookwrapper teardown.\n"
46+
msg += f"Plugin: {hook_impl.plugin_name}, Hook: {hook_name}\n"
47+
msg += f"{type(e).__name__}: {e}\n"
48+
msg += "For more information see https://pluggy.readthedocs.io/en/stable/api_reference.html#pluggy.PluggyTeardownRaisedWarning" # noqa: E501
49+
warnings.warn(PluggyTeardownRaisedWarning(msg), stacklevel=5)
50+
51+
2752
def _multicall(
2853
hook_name: str,
2954
hook_impls: Sequence[HookImpl],
@@ -60,7 +85,7 @@ def _multicall(
6085
res = hook_impl.function(*args)
6186
wrapper_gen = cast(Generator[None, Result[object], None], res)
6287
next(wrapper_gen) # first yield
63-
teardowns.append((wrapper_gen,))
88+
teardowns.append((wrapper_gen, hook_impl))
6489
except StopIteration:
6590
_raise_wrapfail(wrapper_gen, "did not yield")
6691
elif hook_impl.wrapper:
@@ -128,9 +153,13 @@ def _multicall(
128153
if isinstance(teardown, tuple):
129154
try:
130155
teardown[0].send(outcome)
131-
_raise_wrapfail(teardown[0], "has second yield")
132156
except StopIteration:
133157
pass
158+
except BaseException as e:
159+
_warn_teardown_exception(hook_name, teardown[1], e)
160+
raise
161+
else:
162+
_raise_wrapfail(teardown[0], "has second yield")
134163
else:
135164
try:
136165
if outcome._exception is not None:

src/pluggy/_result.py

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,7 @@
77
from typing import Callable
88
from typing import cast
99
from typing import final
10-
from typing import Generator
1110
from typing import Generic
12-
from typing import NoReturn
1311
from typing import Optional
1412
from typing import Tuple
1513
from typing import Type
@@ -20,19 +18,6 @@
2018
ResultType = TypeVar("ResultType")
2119

2220

23-
def _raise_wrapfail(
24-
wrap_controller: (
25-
Generator[None, Result[ResultType], None] | Generator[None, object, object]
26-
),
27-
msg: str,
28-
) -> NoReturn:
29-
co = wrap_controller.gi_code
30-
raise RuntimeError(
31-
"wrap_controller at %r %s:%d %s"
32-
% (co.co_name, co.co_filename, co.co_firstlineno, msg)
33-
)
34-
35-
3621
class HookCallError(Exception):
3722
"""Hook was called incorrectly."""
3823

src/pluggy/_warnings.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from typing import final
2+
3+
4+
class PluggyWarning(UserWarning):
5+
"""Base class for all warnings emitted by pluggy."""
6+
7+
__module__ = "pluggy"
8+
9+
10+
@final
11+
class PluggyTeardownRaisedWarning(PluggyWarning):
12+
"""A plugin raised an exception during an :ref:`old-style hookwrapper
13+
<old_style_hookwrappers>` teardown.
14+
15+
Such exceptions are not handled by pluggy, and may cause subsequent
16+
teardowns to be executed at unexpected times, or be skipped entirely.
17+
18+
This is an issue in the plugin implementation.
19+
20+
If the exception is unintended, fix the underlying cause.
21+
22+
If the exception is intended, switch to :ref:`new-style hook wrappers
23+
<hookwrappers>`, or use :func:`result.force_exception()
24+
<pluggy.Result.force_exception>` to set the exception instead of raising.
25+
"""
26+
27+
__module__ = "pluggy"

testing/test_warnings.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
5+
from pluggy import HookimplMarker
6+
from pluggy import HookspecMarker
7+
from pluggy import PluggyTeardownRaisedWarning
8+
from pluggy import PluginManager
9+
10+
11+
hookspec = HookspecMarker("example")
12+
hookimpl = HookimplMarker("example")
13+
14+
15+
def test_teardown_raised_warning(pm: PluginManager) -> None:
16+
class Api:
17+
@hookspec
18+
def my_hook(self):
19+
raise NotImplementedError()
20+
21+
pm.add_hookspecs(Api)
22+
23+
class Plugin1:
24+
@hookimpl
25+
def my_hook(self):
26+
pass
27+
28+
class Plugin2:
29+
@hookimpl(hookwrapper=True)
30+
def my_hook(self):
31+
yield
32+
1 / 0
33+
34+
class Plugin3:
35+
@hookimpl(hookwrapper=True)
36+
def my_hook(self):
37+
yield
38+
39+
pm.register(Plugin1(), "plugin1")
40+
pm.register(Plugin2(), "plugin2")
41+
pm.register(Plugin3(), "plugin3")
42+
with pytest.warns(
43+
PluggyTeardownRaisedWarning,
44+
match=r"\bplugin2\b.*\bmy_hook\b.*\n.*ZeroDivisionError",
45+
) as wc:
46+
with pytest.raises(ZeroDivisionError):
47+
pm.hook.my_hook()
48+
assert len(wc.list) == 1
49+
assert Path(wc.list[0].filename).name == "test_warnings.py"

0 commit comments

Comments
 (0)