Skip to content
Open
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
9 changes: 9 additions & 0 deletions changelog/707.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Hook implementations are now represented by dedicated types:
:class:`pluggy.NormalImpl` for normal implementations and
:class:`pluggy.WrapperImpl` for (old- and new-style) wrappers, both
subclasses of :class:`pluggy.HookImpl`.
``HookimplConfiguration.create_hookimpl()`` selects the appropriate
subclass, and ``WrapperImpl.setup_and_get_completion_hook()`` exposes
wrapper setup/teardown as a ``CompletionHook`` callback.
``HookImpl`` now stores its configuration as ``hookimpl_config``; the old
``opts`` attribute remains as a deprecated alias property.
8 changes: 8 additions & 0 deletions docs/api_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ API Reference
.. autoclass:: pluggy.HookImpl()
:members:

.. autoclass:: pluggy.NormalImpl()
:show-inheritance:
:members:

.. autoclass:: pluggy.WrapperImpl()
:show-inheritance:
:members:

.. autoclass:: pluggy.HookspecConfiguration()
:members:

Expand Down
4 changes: 4 additions & 0 deletions src/pluggy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
"HookspecConfiguration",
"HookspecMarker",
"HookspecOpts",
"NormalImpl",
"PluggyTeardownRaisedWarning",
"PluggyWarning",
"PluginManager",
"PluginValidationError",
"Result",
"WrapperImpl",
"__version__",
]
from ._config import HookimplConfiguration
Expand All @@ -23,6 +25,8 @@
from ._hooks import HookimplMarker
from ._hooks import HookRelay
from ._hooks import HookspecMarker
from ._hooks import NormalImpl
from ._hooks import WrapperImpl
from ._manager import PluginManager
from ._manager import PluginValidationError
from ._pytest_compat import HookimplOpts
Expand Down
4 changes: 2 additions & 2 deletions src/pluggy/_caller.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,10 +224,10 @@ def call_extra(
"Cannot directly call a historic hook - use call_historic instead."
)
self._verify_all_args_are_provided(kwargs)
opts = HookimplConfiguration()
config = HookimplConfiguration()
hookimpls = self._hookimpls.copy()
for method in methods:
hookimpl = HookImpl(None, "<temp>", method, opts)
hookimpl = config.create_hookimpl(None, "<temp>", method)
# Find last non-tryfirst nonwrapper method.
i = len(hookimpls) - 1
while i >= 0 and (
Expand Down
29 changes: 29 additions & 0 deletions src/pluggy/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@
from typing import Any
from typing import Final
from typing import final
from typing import TYPE_CHECKING


if TYPE_CHECKING:
from ._implementation import _HookImplFunction
from ._implementation import _Plugin
from ._implementation import NormalImpl
from ._implementation import WrapperImpl


@final
Expand Down Expand Up @@ -98,6 +106,27 @@ def __init__(
#: The name of the hook specification to match, see :ref:`specname`.
self.specname = specname

def create_hookimpl(
self,
plugin: _Plugin,
plugin_name: str,
function: _HookImplFunction[object],
) -> NormalImpl | WrapperImpl:
"""Create the appropriate :class:`HookImpl` subclass for this
configuration.

Wrapper configurations produce a :class:`WrapperImpl`; all others
produce a :class:`NormalImpl`.
"""
# Local import to avoid a circular import with the implementation
# module.
from ._implementation import NormalImpl
from ._implementation import WrapperImpl

if self.wrapper or self.hookwrapper:
return WrapperImpl(plugin, plugin_name, function, self)
return NormalImpl(plugin, plugin_name, function, self)

def __repr__(self) -> str:
attrs = [
f"{slot}={getattr(self, slot)!r}"
Expand Down
8 changes: 1 addition & 7 deletions src/pluggy/_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
import warnings

from ._implementation import HookImpl
from ._result import HookCallError
from ._result import Result
from ._warnings import PluggyTeardownRaisedWarning

Expand Down Expand Up @@ -96,12 +95,7 @@ def _multicall(
teardowns: list[Teardown] = []
try: # run impl and wrapper setup functions in a loop
for hook_impl in reversed(hook_impls):
try:
args = [caller_kwargs[argname] for argname in hook_impl.argnames]
except KeyError as e:
raise HookCallError(
f"hook call must provide argument {e.args[0]!r}"
) from e
args = hook_impl._get_call_args(caller_kwargs)

if hook_impl.hookwrapper:
function_gen = run_old_style_hookwrapper(hook_impl, hook_name, args)
Expand Down
6 changes: 6 additions & 0 deletions src/pluggy/_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,14 @@
from ._decorators import varnames
from ._implementation import _HookImplFunction
from ._implementation import _Plugin
from ._implementation import CompletionHook
from ._implementation import HookImpl
from ._implementation import NormalImpl
from ._implementation import WrapperImpl


__all__ = [
"CompletionHook",
"HookCaller",
"HookImpl",
"HookRelay",
Expand All @@ -34,6 +38,8 @@
"HookimplMarker",
"HookspecConfiguration",
"HookspecMarker",
"NormalImpl",
"WrapperImpl",
"_HookCaller",
"_HookExec",
"_HookImplFunction",
Expand Down
165 changes: 154 additions & 11 deletions src/pluggy/_implementation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,18 @@

from collections.abc import Callable
from collections.abc import Generator
from collections.abc import Mapping
from typing import cast
from typing import Final
from typing import final
from typing import Protocol
from typing import runtime_checkable
from typing import TypeAlias
from typing import TypeVar

from ._config import HookimplConfiguration
from ._decorators import varnames
from ._result import HookCallError
from ._result import Result


Expand All @@ -22,17 +27,31 @@
_HookImplFunction: TypeAlias = Callable[..., _T | Generator[None, Result[_T], None]]


@final
@runtime_checkable

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider reducing the exposed type-level surface by hiding the NormalImpl/WrapperImpl choice behind a factory, using a callable alias for CompletionHook, and extracting wrapper teardown into a separate helper.

The split into NormalImpl / WrapperImpl and the CompletionHook protocol adds quite a bit of surface area for the amount of new behaviour. You can keep all functionality while reducing the amount of “type-level” complexity by:


1. Hide the subclass distinction behind a factory

Right now callers must know which subclass to instantiate and are punished with ValueError if the config doesn’t match. Instead, centralise that logic and only expose a single creation entry point. This keeps the subclasses (and the wrapper‑specific methods) but removes the duplication and mental overhead at call sites.

# helper near the class definitions
def create_hook_impl(
    plugin: _Plugin,
    plugin_name: str,
    function: _HookImplFunction[object],
    hook_impl_config: HookimplConfiguration,
) -> HookImpl:
    if hook_impl_config.wrapper or hook_impl_config.hookwrapper:
        return WrapperImpl(plugin, plugin_name, function, hook_impl_config)
    return NormalImpl(plugin, plugin_name, function, hook_impl_config)

Call sites would then use create_hook_impl(...) and never directly pick NormalImpl vs WrapperImpl. You can also drop the ValueError checks in the subclasses because the factory is the single gatekeeper.


2. Simplify CompletionHook to a callable alias if you don’t need runtime typing

If you don’t rely on isinstance(x, CompletionHook) / issubclass checks, a protocol is heavier than necessary. A type alias keeps the signature clear without introducing an extra concept:

CompletionHook: TypeAlias = Callable[
    [object | list[object] | None, BaseException | None],
    tuple[object | list[object] | None, BaseException | None],
]

The return type of WrapperImpl.setup_and_get_completion_hook doesn’t need to change beyond using this alias, and all current usage will keep working.


3. Extract the teardown orchestration from WrapperImpl.setup_and_get_completion_hook

The nested completion_hook function mixes argument extraction, wrapper generator preparation, and teardown orchestration. You can move the teardown logic into _execution so that WrapperImpl only sets up the generator and delegates:

# in ._execution (or similar)
def run_wrapper_teardown(
    wrapper_gen: Generator[None, object, object],
    result: object | list[object] | None,
    exception: BaseException | None,
) -> tuple[object | list[object] | None, BaseException | None]:
    try:
        if exception is not None:
            try:
                wrapper_gen.throw(exception)
            except RuntimeError as re:
                if isinstance(exception, StopIteration) and re.__cause__ is exception:
                    wrapper_gen.close()
                    return result, exception
                raise
        else:
            wrapper_gen.send(result)
        wrapper_gen.close()
        _raise_wrapfail(wrapper_gen, "has second yield")
    except StopIteration as si:
        return si.value, None
    except BaseException as e:
        return result, e
# in WrapperImpl
from ._execution import run_old_style_hookwrapper, run_wrapper_teardown

def setup_and_get_completion_hook(
    self, hook_name: str, caller_kwargs: Mapping[str, object]
) -> CompletionHook:
    args = self._get_call_args(caller_kwargs)

    if self.hookwrapper:
        wrapper_gen = run_old_style_hookwrapper(self, hook_name, args)
    else:
        wrapper_gen = cast(Generator[None, object, object], self.function(*args))

    try:
        next(wrapper_gen)
    except StopIteration:
        _raise_wrapfail(wrapper_gen, "did not yield")

    def completion_hook(
        result: object | list[object] | None,
        exception: BaseException | None,
    ) -> tuple[object | list[object] | None, BaseException | None]:
        return run_wrapper_teardown(wrapper_gen, result, exception)

    return completion_hook

This keeps the completion‑hook behaviour exactly as it is, but makes the teardown flow reusable, testable in isolation, and easier to read.

class CompletionHook(Protocol):
"""Teardown callback returned by :meth:`WrapperImpl.setup_and_get_completion_hook`.

Receives the current ``(result, exception)`` outcome of the hook call and
returns the possibly replaced ``(result, exception)`` pair.
"""

def __call__(
self,
result: object | list[object] | None,
exception: BaseException | None,
) -> tuple[object | list[object] | None, BaseException | None]: ...


class HookImpl:
"""A hook implementation in a :class:`HookCaller`."""
"""Base class for hook implementations in a :class:`HookCaller`."""

__slots__ = (
"argnames",
"function",
"hookimpl_config",
"hookwrapper",
"kwargnames",
"optionalhook",
"opts",
"plugin",
"plugin_name",
"tryfirst",
Expand All @@ -45,7 +64,7 @@ def __init__(
plugin: _Plugin,
plugin_name: str,
function: _HookImplFunction[object],
hook_impl_opts: HookimplConfiguration,
hook_impl_config: HookimplConfiguration,
) -> None:
""":meta private:"""
#: The hook implementation function.
Expand All @@ -59,23 +78,147 @@ def __init__(
self.plugin: Final = plugin
#: The :class:`HookimplConfiguration` used to configure this hook
#: implementation.
self.opts: Final = hook_impl_opts
self.hookimpl_config: Final = hook_impl_config
#: The name of the plugin which defined this hook implementation.
self.plugin_name: Final = plugin_name
#: Whether the hook implementation is a :ref:`wrapper <hookwrapper>`.
self.wrapper: Final = hook_impl_opts.wrapper
self.wrapper: Final = hook_impl_config.wrapper
#: Whether the hook implementation is an :ref:`old-style wrapper
#: <old_style_hookwrappers>`.
self.hookwrapper: Final = hook_impl_opts.hookwrapper
self.hookwrapper: Final = hook_impl_config.hookwrapper
#: Whether validation against a hook specification is :ref:`optional
#: <optionalhook>`.
self.optionalhook: Final = hook_impl_opts.optionalhook
self.optionalhook: Final = hook_impl_config.optionalhook
#: Whether to try to order this hook implementation :ref:`first
#: <callorder>`.
self.tryfirst: Final = hook_impl_opts.tryfirst
self.tryfirst: Final = hook_impl_config.tryfirst
#: Whether to try to order this hook implementation :ref:`last
#: <callorder>`.
self.trylast: Final = hook_impl_opts.trylast
self.trylast: Final = hook_impl_config.trylast

@property
def opts(self) -> HookimplConfiguration:
"""Alias for :attr:`hookimpl_config`.

.. deprecated::
Use :attr:`hookimpl_config` instead.
"""
return self.hookimpl_config

def _get_call_args(self, caller_kwargs: Mapping[str, object]) -> list[object]:
"""Extract the positional arguments for calling this hook implementation.

:raises HookCallError: If a required argument is missing.
"""
try:
return [caller_kwargs[argname] for argname in self.argnames]
except KeyError as e:
raise HookCallError(f"hook call must provide argument {e.args[0]!r}") from e

def __repr__(self) -> str:
return f"<HookImpl plugin_name={self.plugin_name!r}, plugin={self.plugin!r}>"
return (
f"<{type(self).__name__} "
f"plugin_name={self.plugin_name!r}, plugin={self.plugin!r}>"
)


@final
class NormalImpl(HookImpl):
"""A normal (non-wrapper) hook implementation in a :class:`HookCaller`."""

def __init__(
self,
plugin: _Plugin,
plugin_name: str,
function: _HookImplFunction[object],
hook_impl_config: HookimplConfiguration,
) -> None:
""":meta private:"""
if hook_impl_config.wrapper or hook_impl_config.hookwrapper:
raise ValueError(
"NormalImpl cannot be used for wrapper implementations. "
"Use WrapperImpl instead."
)
super().__init__(plugin, plugin_name, function, hook_impl_config)


@final
class WrapperImpl(HookImpl):
"""A wrapper hook implementation in a :class:`HookCaller`."""

def __init__(
self,
plugin: _Plugin,
plugin_name: str,
function: _HookImplFunction[object],
hook_impl_config: HookimplConfiguration,
) -> None:
""":meta private:"""
if not (hook_impl_config.wrapper or hook_impl_config.hookwrapper):
raise ValueError(
"WrapperImpl can only be used for wrapper implementations. "
"Use NormalImpl for normal implementations."
)
super().__init__(plugin, plugin_name, function, hook_impl_config)

def setup_and_get_completion_hook(
self, hook_name: str, caller_kwargs: Mapping[str, object]
) -> CompletionHook:
"""Run the wrapper setup phase and return its :class:`CompletionHook`.

Old-style hookwrappers and new-style wrappers are handled uniformly by
adapting old-style wrappers via ``run_old_style_hookwrapper``.

The returned completion hook performs the teardown: it sends the
current outcome into the wrapper generator (or throws the current
exception) and returns the possibly replaced ``(result, exception)``
pair.
"""
# Local import to avoid a circular import with the execution module.
from ._execution import _raise_wrapfail
from ._execution import run_old_style_hookwrapper

args = self._get_call_args(caller_kwargs)

wrapper_gen: Generator[None, object, object]
if self.hookwrapper:
wrapper_gen = run_old_style_hookwrapper(self, hook_name, args)
else:
wrapper_gen = cast(Generator[None, object, object], self.function(*args))

try:
next(wrapper_gen) # first yield / setup phase
except StopIteration:
_raise_wrapfail(wrapper_gen, "did not yield")

def completion_hook(
result: object | list[object] | None, exception: BaseException | None
) -> tuple[object | list[object] | None, BaseException | None]:
try:
if exception is not None:
try:
wrapper_gen.throw(exception)
except RuntimeError as re:
# StopIteration from generator causes RuntimeError
# even for coroutine usage - see #544
if (
isinstance(exception, StopIteration)
and re.__cause__ is exception
):
wrapper_gen.close()
return result, exception
else:
raise
else:
wrapper_gen.send(result)
# Following is unreachable for a well behaved hook wrapper.
# Try to force finalizers otherwise postponed till GC action.
# Note: close() may raise if generator handles GeneratorExit.
wrapper_gen.close()
_raise_wrapfail(wrapper_gen, "has second yield")
except StopIteration as si:
return si.value, None
except BaseException as e:
return result, e

return completion_hook
2 changes: 1 addition & 1 deletion src/pluggy/_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ def register(self, plugin: _Plugin, name: str | None = None) -> str | None:
hookimpl_config = self._discover_hookimpl_configuration(plugin, attr_name)
if hookimpl_config is not None:
method: _HookImplFunction[object] = getattr(plugin, attr_name)
hookimpl = HookImpl(plugin, plugin_name, method, hookimpl_config)
hookimpl = hookimpl_config.create_hookimpl(plugin, plugin_name, method)
hook_name = hookimpl_config.specname or attr_name
hook: HookCaller | None = getattr(self.hook, hook_name, None)
if hook is None:
Expand Down
2 changes: 1 addition & 1 deletion testing/test_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ def myhook(self):
plugin = Plugin()
pname = pm.register(plugin)
assert repr(pm.hook.myhook.get_hookimpls()[0]) == (
f"<HookImpl plugin_name={pname!r}, plugin={plugin!r}>"
f"<NormalImpl plugin_name={pname!r}, plugin={plugin!r}>"
Comment on lines 195 to +196

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a complementary repr test for WrapperImpl to cover the new subclass-specific repr

NormalImpl’s repr is now asserted to include the concrete subclass name, and WrapperImpl shares the same repr logic but lacks a direct test. Please add a test that registers a wrapper implementation (e.g., via a small plugin using @hookimpl(wrapper=True)) and asserts its repr begins with <WrapperImpl ...> and includes the correct plugin_name and plugin values, so the new behavior is fully covered.

Suggested implementation:

    plugin = Plugin()
    pname = pm.register(plugin)
    assert repr(pm.hook.myhook.get_hookimpls()[0]) == (
        f"<NormalImpl plugin_name={pname!r}, plugin={plugin!r}>"
    )

    class WrapperPlugin:
        @hookimpl(wrapper=True)
        def myhook(self, result):
            return result

    wrapper_plugin = WrapperPlugin()
    wrapper_pname = pm.register(wrapper_plugin)
    # WrapperImpl should be the second hook implementation for myhook
    assert repr(pm.hook.myhook.get_hookimpls()[1]) == (
        f"<WrapperImpl plugin_name={wrapper_pname!r}, plugin={wrapper_plugin!r}>"
    )
  1. Ensure hookimpl is already imported in testing/test_details.py (typically from pluggy import HookimplMarker or similar, aliased to hookimpl). If it is not, add the appropriate import using the existing conventions in the file.
  2. Confirm that pm in this test has a myhook spec that accepts a result argument for wrapper implementations; if not, adjust the WrapperPlugin.myhook signature to match the defined hook spec.
  3. If the pm.register(plugin) call changes the ordering of hook implementations (e.g., via tryfirst/trylast or other options in surrounding code), you may need to assert against the correct index for the WrapperImpl in pm.hook.myhook.get_hookimpls().

)


Expand Down
Loading