diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f75ac6ba..078703be 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/PyCQA/autoflake
- rev: v2.2.0
+ rev: v2.2.1
hooks:
- id: autoflake
name: autoflake
@@ -8,7 +8,7 @@ repos:
language: python
files: \.py$
- repo: https://github.com/asottile/reorder-python-imports
- rev: v3.10.0
+ rev: v3.12.0
hooks:
- id: reorder-python-imports
args: ['--application-directories=.:src', --py38-plus]
@@ -24,12 +24,12 @@ repos:
hooks:
- id: rst-backticks
- repo: https://github.com/asottile/pyupgrade
- rev: v3.10.1
+ rev: v3.15.0
hooks:
- id: pyupgrade
args: [--py38-plus]
- repo: https://github.com/psf/black
- rev: 23.7.0
+ rev: 23.12.1
hooks:
- id: black
args: [--safe, --quiet]
@@ -47,7 +47,7 @@ repos:
language: python
additional_dependencies: [pygments, restructuredtext_lint]
- repo: https://github.com/pre-commit/mirrors-mypy
- rev: v1.5.1
+ rev: v1.8.0
hooks:
- id: mypy
files: ^(src/|testing/)
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 2ce57326..d7af1746 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -21,6 +21,25 @@ Versions follow `Semantic Versioning `_ (``..
.. towncrier release notes start
+pluggy 1.4.0 (2024-01-24)
+=========================
+
+Features
+--------
+
+- `#463 `_: 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.
+
+- `#471 `_: Add :func:`PluginManager.unblock ` method to unblock a plugin by plugin name.
+
+Bug Fixes
+---------
+
+- `#441 `_: Fix :func:`~pluggy.HookCaller.call_extra()` extra methods getting ordered before everything else in some circumstances. Regressed in pluggy 1.1.0.
+
+- `#438 `_: Fix plugins registering other plugins in a hook when the other plugins implement the same hook itself. Regressed in pluggy 1.1.0.
+
+
pluggy 1.3.0 (2023-08-26)
=========================
@@ -148,7 +167,7 @@ Features
--------
- `#282 `_: When registering a hookimpl which is declared as ``hookwrapper=True`` but whose
- function is not a generator function, a ``PluggyValidationError`` exception is
+ function is not a generator function, a :class:`~pluggy.PluginValidationError` exception is
now raised.
Previously this problem would cause an error only later, when calling the hook.
@@ -158,7 +177,7 @@ Features
.. code-block:: python
- def my_hook_real_implementation(arg):
+ def my_hook_implementation(arg):
print("before")
yield
print("after")
diff --git a/docs/api_reference.rst b/docs/api_reference.rst
index 40b27bfe..b14d725d 100644
--- a/docs/api_reference.rst
+++ b/docs/api_reference.rst
@@ -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:
diff --git a/docs/conf.py b/docs/conf.py
index ece874a2..aaa6c966 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -45,7 +45,7 @@
"github_button": "true",
"github_banner": "true",
"github_type": "star",
- "badge_branch": "master",
+ "badge_branch": "main",
"page_width": "1080px",
"sidebar_width": "300px",
"fixed_sidebar": "false",
diff --git a/docs/index.rst b/docs/index.rst
index d527a507..42fb8f49 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -416,9 +416,11 @@ of the hook thus far, or, if the previous calls raised an exception, it is
:py:meth:`thrown ` 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.
+
+- 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.
@@ -1028,11 +1030,11 @@ Table of contents
.. _callbacks:
https://en.wikipedia.org/wiki/Callback_(computer_programming)
.. _tox test suite:
- https://github.com/pytest-dev/pluggy/blob/master/tox.ini
+ https://github.com/pytest-dev/pluggy/blob/main/tox.ini
.. _Semantic Versioning:
https://semver.org/
.. _Python interpreters:
- https://github.com/pytest-dev/pluggy/blob/master/tox.ini#L2
+ https://github.com/pytest-dev/pluggy/blob/main/tox.ini#L2
.. _500+ plugins:
https://docs.pytest.org/en/latest/reference/plugin_list.html
.. _pre-commit:
diff --git a/src/pluggy/__init__.py b/src/pluggy/__init__.py
index 9d9e873b..2adf9454 100644
--- a/src/pluggy/__init__.py
+++ b/src/pluggy/__init__.py
@@ -18,6 +18,8 @@
"HookspecMarker",
"HookimplMarker",
"Result",
+ "PluggyWarning",
+ "PluggyTeardownRaisedWarning",
]
from ._manager import PluginManager, PluginValidationError
@@ -31,3 +33,7 @@
HookimplOpts,
HookImpl,
)
+from ._warnings import (
+ PluggyWarning,
+ PluggyTeardownRaisedWarning,
+)
diff --git a/src/pluggy/_callers.py b/src/pluggy/_callers.py
index 6498eaed..787f56ba 100644
--- a/src/pluggy/_callers.py
+++ b/src/pluggy/_callers.py
@@ -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)
+ )
+
+
+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],
@@ -60,7 +85,7 @@ def _multicall(
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:
@@ -128,9 +153,13 @@ def _multicall(
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:
diff --git a/src/pluggy/_hooks.py b/src/pluggy/_hooks.py
index 916ca704..7c8420f4 100644
--- a/src/pluggy/_hooks.py
+++ b/src/pluggy/_hooks.py
@@ -389,6 +389,13 @@ def __init__(
#: Name of the hook getting called.
self.name: Final = name
self._hookexec: Final = hook_execute
+ # The hookimpls list. The caller iterates it *in reverse*. Format:
+ # 1. trylast nonwrappers
+ # 2. nonwrappers
+ # 3. tryfirst nonwrappers
+ # 4. trylast wrappers
+ # 5. wrappers
+ # 6. tryfirst wrappers
self._hookimpls: Final[list[HookImpl]] = []
self._call_history: _CallHistory | None = None
# TODO: Document, or make private.
@@ -490,7 +497,8 @@ def __call__(self, **kwargs: object) -> Any:
), "Cannot directly call a historic hook - use call_historic instead."
self._verify_all_args_are_provided(kwargs)
firstresult = self.spec.opts.get("firstresult", False) if self.spec else False
- return self._hookexec(self.name, self._hookimpls, kwargs, firstresult)
+ # Copy because plugins may register other plugins during iteration (#438).
+ return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult)
def call_historic(
self,
@@ -511,7 +519,8 @@ def call_historic(
self._call_history.append((kwargs, result_callback))
# Historizing hooks don't return results.
# Remember firstresult isn't compatible with historic.
- res = self._hookexec(self.name, self._hookimpls, kwargs, False)
+ # Copy because plugins may register other plugins during iteration (#438).
+ res = self._hookexec(self.name, self._hookimpls.copy(), kwargs, False)
if result_callback is None:
return
if isinstance(res, list):
@@ -541,10 +550,11 @@ def call_extra(
hookimpl = HookImpl(None, "", method, opts)
# Find last non-tryfirst nonwrapper method.
i = len(hookimpls) - 1
- while (
- i >= 0
- and hookimpls[i].tryfirst
- and not (hookimpls[i].hookwrapper or hookimpls[i].wrapper)
+ while i >= 0 and (
+ # Skip wrappers.
+ (hookimpls[i].hookwrapper or hookimpls[i].wrapper)
+ # Skip tryfirst nonwrappers.
+ or hookimpls[i].tryfirst
):
i -= 1
hookimpls.insert(i + 1, hookimpl)
diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py
index 84717e6e..ce1e107a 100644
--- a/src/pluggy/_manager.py
+++ b/src/pluggy/_manager.py
@@ -1,6 +1,5 @@
from __future__ import annotations
-import importlib.metadata
import inspect
import types
import warnings
@@ -11,6 +10,7 @@
from typing import Iterable
from typing import Mapping
from typing import Sequence
+from typing import TYPE_CHECKING
from . import _tracing
from ._callers import _multicall
@@ -26,6 +26,10 @@
from ._hooks import normalize_hookimpl_opts
from ._result import Result
+if TYPE_CHECKING:
+ # importtlib.metadata import is slow, defer it.
+ import importlib.metadata
+
_BeforeTrace = Callable[[str, Sequence[HookImpl], Mapping[str, Any]], None]
_AfterTrace = Callable[[Result[Any], str, Sequence[HookImpl], Mapping[str, Any]], None]
@@ -231,6 +235,16 @@ def is_blocked(self, name: str) -> bool:
"""Return whether the given plugin name is blocked."""
return name in self._name2plugin and self._name2plugin[name] is None
+ def unblock(self, name: str) -> bool:
+ """Unblocks a name.
+
+ Returns whether the name was actually blocked.
+ """
+ if self._name2plugin.get(name, -1) is None:
+ del self._name2plugin[name]
+ return True
+ return False
+
def add_hookspecs(self, module_or_class: _Namespace) -> None:
"""Add new hook specifications defined in the given ``module_or_class``.
@@ -384,6 +398,8 @@ def load_setuptools_entrypoints(self, group: str, name: str | None = None) -> in
:return:
The number of plugins loaded by this call.
"""
+ import importlib.metadata
+
count = 0
for dist in list(importlib.metadata.distributions()):
for ep in dist.entry_points:
diff --git a/src/pluggy/_result.py b/src/pluggy/_result.py
index 29859eb9..aa21fa13 100644
--- a/src/pluggy/_result.py
+++ b/src/pluggy/_result.py
@@ -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
@@ -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."""
diff --git a/src/pluggy/_warnings.py b/src/pluggy/_warnings.py
new file mode 100644
index 00000000..6356c770
--- /dev/null
+++ b/src/pluggy/_warnings.py
@@ -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
+ ` 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
+ `, or use :func:`result.force_exception()
+ ` to set the exception instead of raising.
+ """
+
+ __module__ = "pluggy"
diff --git a/testing/test_hookcaller.py b/testing/test_hookcaller.py
index 88ed1316..9eb6f979 100644
--- a/testing/test_hookcaller.py
+++ b/testing/test_hookcaller.py
@@ -1,4 +1,5 @@
from typing import Callable
+from typing import Generator
from typing import List
from typing import Sequence
from typing import TypeVar
@@ -448,3 +449,74 @@ def conflict(self) -> None:
"Hook 'conflict' is already registered within namespace "
".Api1'>"
)
+
+
+def test_call_extra_hook_order(hc: HookCaller, addmeth: AddMeth) -> None:
+ """Ensure that call_extra is calling hooks in the right order."""
+ order = []
+
+ @addmeth(tryfirst=True)
+ def method1() -> str:
+ order.append("1")
+ return "1"
+
+ @addmeth()
+ def method2() -> str:
+ order.append("2")
+ return "2"
+
+ @addmeth(trylast=True)
+ def method3() -> str:
+ order.append("3")
+ return "3"
+
+ @addmeth(wrapper=True, tryfirst=True)
+ def method4() -> Generator[None, str, str]:
+ order.append("4pre")
+ result = yield
+ order.append("4post")
+ return result
+
+ @addmeth(wrapper=True)
+ def method5() -> Generator[None, str, str]:
+ order.append("5pre")
+ result = yield
+ order.append("5post")
+ return result
+
+ @addmeth(wrapper=True, trylast=True)
+ def method6() -> Generator[None, str, str]:
+ order.append("6pre")
+ result = yield
+ order.append("6post")
+ return result
+
+ def extra1() -> str:
+ order.append("extra1")
+ return "extra1"
+
+ def extra2() -> str:
+ order.append("extra2")
+ return "extra2"
+
+ result = hc.call_extra([extra1, extra2], {"arg": "test"})
+ assert order == [
+ "4pre",
+ "5pre",
+ "6pre",
+ "1",
+ "extra2",
+ "extra1",
+ "2",
+ "3",
+ "6post",
+ "5post",
+ "4post",
+ ]
+ assert result == [
+ "1",
+ "extra2",
+ "extra1",
+ "2",
+ "3",
+ ]
diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py
index 81b86b65..d3bcac4e 100644
--- a/testing/test_pluginmanager.py
+++ b/testing/test_pluginmanager.py
@@ -110,6 +110,13 @@ class A:
pm.unregister(name="somename")
assert pm.is_blocked("somename")
+ # Unblock.
+ assert not pm.unblock("someothername")
+ assert pm.unblock("somename")
+ assert not pm.is_blocked("somename")
+ assert not pm.unblock("somename")
+ assert pm.register(A(), "somename")
+
def test_register_mismatch_method(he_pm: PluginManager) -> None:
class hello:
@@ -675,3 +682,79 @@ def he_method1(self):
assert saveindent[0] > indent
finally:
undo()
+
+
+@pytest.mark.parametrize("historic", [False, True])
+def test_register_while_calling(
+ pm: PluginManager,
+ historic: bool,
+) -> None:
+ """Test that registering an impl of a hook while it is being called does
+ not affect the call itself, only later calls.
+
+ For historic hooks however, the hook is called immediately on registration.
+
+ Regression test for #438.
+ """
+ hookspec = HookspecMarker("example")
+
+ class Hooks:
+ @hookspec(historic=historic)
+ def configure(self) -> int:
+ raise NotImplementedError()
+
+ class Plugin1:
+ @hookimpl
+ def configure(self) -> int:
+ return 1
+
+ class Plugin2:
+ def __init__(self) -> None:
+ self.already_registered = False
+
+ @hookimpl
+ def configure(self) -> int:
+ if not self.already_registered:
+ pm.register(Plugin4())
+ pm.register(Plugin5())
+ pm.register(Plugin6())
+ self.already_registered = True
+ return 2
+
+ class Plugin3:
+ @hookimpl
+ def configure(self) -> int:
+ return 3
+
+ class Plugin4:
+ @hookimpl(tryfirst=True)
+ def configure(self) -> int:
+ return 4
+
+ class Plugin5:
+ @hookimpl()
+ def configure(self) -> int:
+ return 5
+
+ class Plugin6:
+ @hookimpl(trylast=True)
+ def configure(self) -> int:
+ return 6
+
+ pm.add_hookspecs(Hooks)
+ pm.register(Plugin1())
+ pm.register(Plugin2())
+ pm.register(Plugin3())
+
+ if not historic:
+ result = pm.hook.configure()
+ assert result == [3, 2, 1]
+ result = pm.hook.configure()
+ assert result == [4, 5, 3, 2, 1, 6]
+ else:
+ result = []
+ pm.hook.configure.call_historic(result.append)
+ assert result == [4, 5, 6, 3, 2, 1]
+ result = []
+ pm.hook.configure.call_historic(result.append)
+ assert result == [4, 5, 3, 2, 1, 6]
diff --git a/testing/test_warnings.py b/testing/test_warnings.py
new file mode 100644
index 00000000..4f5454be
--- /dev/null
+++ b/testing/test_warnings.py
@@ -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"