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

Warn when old-style hookwrapper raises during teardown #464

Merged
merged 2 commits into from
Jan 21, 2024
Merged
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
2 changes: 2 additions & 0 deletions changelog/463.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
A warning :class:`~pluggy.PluggyTeardownRaisedWarning` is now issued when an old-style hookwrapper raises an exception during teardown.
See the warning documentation for more details.
12 changes: 12 additions & 0 deletions docs/api_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,15 @@ API Reference
.. autoclass:: pluggy.HookimplOpts()
:show-inheritance:
:members:


Warnings
--------

Custom warnings generated in some situations such as improper usage or deprecated features.

.. autoclass:: pluggy.PluggyWarning()
:show-inheritance:

.. autoclass:: pluggy.PluggyTeardownRaisedWarning()
:show-inheritance:
6 changes: 6 additions & 0 deletions src/pluggy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"HookspecMarker",
"HookimplMarker",
"Result",
"PluggyWarning",
"PluggyTeardownRaisedWarning",
]

from ._manager import PluginManager, PluginValidationError
Expand All @@ -31,3 +33,7 @@
HookimplOpts,
HookImpl,
)
from ._warnings import (
PluggyWarning,
PluggyTeardownRaisedWarning,
)
39 changes: 34 additions & 5 deletions src/pluggy/_callers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,52 @@
"""
from __future__ import annotations

import warnings
from typing import cast
from typing import Generator
from typing import Mapping
from typing import NoReturn
from typing import Sequence
from typing import Tuple
from typing import Union

from ._hooks import HookImpl
from ._result import _raise_wrapfail
from ._result import HookCallError
from ._result import Result
from ._warnings import PluggyTeardownRaisedWarning


# 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.
# Wrapping with a tuple is the fastest type-safe way I found to do it.
Teardown = Union[
Tuple[Generator[None, Result[object], None]],
Tuple[Generator[None, Result[object], None], HookImpl],
Generator[None, object, object],
]


def _raise_wrapfail(
wrap_controller: (
Generator[None, Result[object], None] | Generator[None, object, object]
),
msg: str,
) -> NoReturn:
co = wrap_controller.gi_code
raise RuntimeError(
"wrap_controller at %r %s:%d %s"
% (co.co_name, co.co_filename, co.co_firstlineno, msg)
)


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

View check run for this annotation

Codecov / codecov/patch

src/pluggy/_callers.py#L39-L41

Added lines #L39 - L41 were not covered by tests
def _warn_teardown_exception(
hook_name: str, hook_impl: HookImpl, e: BaseException
) -> None:
msg = "A plugin raised an exception during an old-style hookwrapper teardown.\n"
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)


def _multicall(
hook_name: str,
hook_impls: Sequence[HookImpl],
Expand Down Expand Up @@ -60,7 +85,7 @@
res = hook_impl.function(*args)
wrapper_gen = cast(Generator[None, Result[object], None], res)
next(wrapper_gen) # first yield
teardowns.append((wrapper_gen,))
teardowns.append((wrapper_gen, hook_impl))
except StopIteration:
_raise_wrapfail(wrapper_gen, "did not yield")
elif hook_impl.wrapper:
Expand Down Expand Up @@ -128,9 +153,13 @@
if isinstance(teardown, tuple):
try:
teardown[0].send(outcome)
_raise_wrapfail(teardown[0], "has second yield")
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:
try:
if outcome._exception is not None:
Expand Down
15 changes: 0 additions & 15 deletions src/pluggy/_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@
from typing import Callable
from typing import cast
from typing import final
from typing import Generator
from typing import Generic
from typing import NoReturn
from typing import Optional
from typing import Tuple
from typing import Type
Expand All @@ -20,19 +18,6 @@
ResultType = TypeVar("ResultType")


def _raise_wrapfail(
wrap_controller: (
Generator[None, Result[ResultType], None] | Generator[None, object, object]
),
msg: str,
) -> NoReturn:
co = wrap_controller.gi_code
raise RuntimeError(
"wrap_controller at %r %s:%d %s"
% (co.co_name, co.co_filename, co.co_firstlineno, msg)
)


class HookCallError(Exception):
"""Hook was called incorrectly."""

Expand Down
27 changes: 27 additions & 0 deletions src/pluggy/_warnings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from typing import final


class PluggyWarning(UserWarning):
"""Base class for all warnings emitted by pluggy."""

__module__ = "pluggy"


@final
class PluggyTeardownRaisedWarning(PluggyWarning):
"""A plugin raised an exception during an :ref:`old-style hookwrapper
<old_style_hookwrappers>` teardown.

Such exceptions are not handled by pluggy, and may cause subsequent
teardowns to be executed at unexpected times, or be skipped entirely.

This is an issue in the plugin implementation.

If the exception is unintended, fix the underlying cause.

If the exception is intended, switch to :ref:`new-style hook wrappers
<hookwrappers>`, or use :func:`result.force_exception()
<pluggy.Result.force_exception>` to set the exception instead of raising.
"""

__module__ = "pluggy"
49 changes: 49 additions & 0 deletions testing/test_warnings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from pathlib import Path

import pytest

from pluggy import HookimplMarker
from pluggy import HookspecMarker
from pluggy import PluggyTeardownRaisedWarning
from pluggy import PluginManager


hookspec = HookspecMarker("example")
hookimpl = HookimplMarker("example")


def test_teardown_raised_warning(pm: PluginManager) -> None:
class Api:
@hookspec
def my_hook(self):
raise NotImplementedError()

pm.add_hookspecs(Api)

class Plugin1:
@hookimpl
def my_hook(self):
pass

class Plugin2:
@hookimpl(hookwrapper=True)
def my_hook(self):
yield
1 / 0

class Plugin3:
@hookimpl(hookwrapper=True)
def my_hook(self):
yield

pm.register(Plugin1(), "plugin1")
pm.register(Plugin2(), "plugin2")
pm.register(Plugin3(), "plugin3")
with pytest.warns(
PluggyTeardownRaisedWarning,
match=r"\bplugin2\b.*\bmy_hook\b.*\n.*ZeroDivisionError",
) as wc:
with pytest.raises(ZeroDivisionError):
pm.hook.my_hook()
assert len(wc.list) == 1
assert Path(wc.list[0].filename).name == "test_warnings.py"