From fac1af08eff6fa5e519f45f989850b3edd826b49 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 22 Jul 2026 14:40:06 +0200 Subject: [PATCH 01/10] feat(config): replace TypedDict options with Hook*Configuration Markers attach HookspecConfiguration/HookimplConfiguration objects. Registration discovers those privately; parse_hookimpl_opts and parse_hookspec_opts remain a deprecated pytest concession that returns legacy dicts and is only called when a subclass overrides them and no modern configuration attribute was found. Co-authored-by: Cursor AI Co-authored-by: Cursor Grok 4.5 --- changelog/704.removal.rst | 8 + docs/api_reference.rst | 6 +- docs/index.rst | 9 +- src/pluggy/__init__.py | 8 +- src/pluggy/_caller.py | 23 +-- src/pluggy/_config.py | 207 +++++++++++++++++++------ src/pluggy/_decorators.py | 46 +++--- src/pluggy/_hooks.py | 10 +- src/pluggy/_impl.py | 17 ++- src/pluggy/_manager.py | 168 +++++++++++++------- src/pluggy/_pytest_compat.py | 55 +++++++ testing/test_configuration.py | 278 ++++++++++++++++++++++++++++++++++ testing/test_details.py | 8 +- testing/test_hookcaller.py | 8 +- 14 files changed, 685 insertions(+), 166 deletions(-) create mode 100644 changelog/704.removal.rst create mode 100644 src/pluggy/_pytest_compat.py create mode 100644 testing/test_configuration.py diff --git a/changelog/704.removal.rst b/changelog/704.removal.rst new file mode 100644 index 00000000..4d5eef43 --- /dev/null +++ b/changelog/704.removal.rst @@ -0,0 +1,8 @@ +Hook options are now :class:`pluggy.HookspecConfiguration` / +:class:`pluggy.HookimplConfiguration` objects (markers attach these instead of +dicts). ``PluginManager.parse_hookimpl_opts`` / +``parse_hookspec_opts`` remain as a deprecated pytest/support concession that +returns legacy dicts and are only invoked during registration when a subclass +overrides them and no modern configuration attribute was found. +``HookspecOpts`` / ``HookimplOpts`` TypedDicts remain importable for +pytest/typing compatibility. diff --git a/docs/api_reference.rst b/docs/api_reference.rst index b14d725d..7d19a4a6 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -40,12 +40,10 @@ API Reference .. autoclass:: pluggy.HookImpl() :members: -.. autoclass:: pluggy.HookspecOpts() - :show-inheritance: +.. autoclass:: pluggy.HookspecConfiguration() :members: -.. autoclass:: pluggy.HookimplOpts() - :show-inheritance: +.. autoclass:: pluggy.HookimplConfiguration() :members: diff --git a/docs/index.rst b/docs/index.rst index f2aaf957..68900ffc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -819,10 +819,13 @@ and particular plugins in it: Parsing mark options ^^^^^^^^^^^^^^^^^^^^ -You can retrieve the *options* applied to a particular -*hookspec* or *hookimpl* as per :ref:`marking_hooks` using the +Markers attach :class:`~pluggy.HookspecConfiguration` / +:class:`~pluggy.HookimplConfiguration` objects to functions. The :py:meth:`~pluggy.PluginManager.parse_hookspec_opts()` and -:py:meth:`~pluggy.PluginManager.parse_hookimpl_opts()` respectively. +:py:meth:`~pluggy.PluginManager.parse_hookimpl_opts()` methods remain as a +**deprecated** pytest/support concession that returns legacy dict-shaped +options; registration only calls them when a subclass overrides them and no +modern configuration attribute was found. .. _calling: diff --git a/src/pluggy/__init__.py b/src/pluggy/__init__.py index 32c7eae5..83064d90 100644 --- a/src/pluggy/__init__.py +++ b/src/pluggy/__init__.py @@ -3,8 +3,10 @@ "HookCaller", "HookImpl", "HookRelay", + "HookimplConfiguration", "HookimplMarker", "HookimplOpts", + "HookspecConfiguration", "HookspecMarker", "HookspecOpts", "PluggyTeardownRaisedWarning", @@ -14,15 +16,17 @@ "Result", "__version__", ] +from ._config import HookimplConfiguration +from ._config import HookspecConfiguration from ._hooks import HookCaller from ._hooks import HookImpl from ._hooks import HookimplMarker -from ._hooks import HookimplOpts from ._hooks import HookRelay from ._hooks import HookspecMarker -from ._hooks import HookspecOpts from ._manager import PluginManager from ._manager import PluginValidationError +from ._pytest_compat import HookimplOpts +from ._pytest_compat import HookspecOpts from ._result import HookCallError from ._result import Result from ._warnings import PluggyTeardownRaisedWarning diff --git a/src/pluggy/_caller.py b/src/pluggy/_caller.py index 52b02316..8295982c 100644 --- a/src/pluggy/_caller.py +++ b/src/pluggy/_caller.py @@ -15,8 +15,8 @@ from typing import TypeAlias import warnings -from ._config import HookimplOpts -from ._config import HookspecOpts +from ._config import HookimplConfiguration +from ._config import HookspecConfiguration from ._decorators import _Namespace from ._decorators import HookSpec from ._impl import _Plugin @@ -69,7 +69,7 @@ def __init__( name: str, hook_execute: _HookExec, specmodule_or_class: _Namespace | None = None, - spec_opts: HookspecOpts | None = None, + spec_opts: HookspecConfiguration | None = None, ) -> None: """:meta private:""" #: Name of the hook getting called. @@ -98,7 +98,7 @@ def has_spec(self) -> bool: def set_specification( self, specmodule_or_class: _Namespace, - spec_opts: HookspecOpts, + spec_opts: HookspecConfiguration, ) -> None: if self.spec is not None: raise ValueError( @@ -106,7 +106,7 @@ def set_specification( f"within namespace {self.spec.namespace}" ) self.spec = HookSpec(specmodule_or_class, self.name, spec_opts) - if spec_opts.get("historic"): + if spec_opts.historic: self._call_history = [] def is_historic(self) -> bool: @@ -192,7 +192,7 @@ def __call__(self, **kwargs: object) -> Any: ) call_kwargs = self._apply_defaults(kwargs) self._verify_all_args_are_provided(call_kwargs) - firstresult = self.spec.opts.get("firstresult", False) if self.spec else False + firstresult = self.spec.opts.firstresult if self.spec else False # Copy because plugins may register other plugins during iteration (#438). return self._hookexec( self.name, self._hookimpls.copy(), call_kwargs, firstresult @@ -237,14 +237,7 @@ def call_extra( ) kwargs = self._apply_defaults(kwargs) self._verify_all_args_are_provided(kwargs) - opts: HookimplOpts = { - "wrapper": False, - "hookwrapper": False, - "optionalhook": False, - "trylast": False, - "tryfirst": False, - "specname": None, - } + opts = HookimplConfiguration() hookimpls = self._hookimpls.copy() for method in methods: hookimpl = HookImpl(None, "", method, opts) @@ -258,7 +251,7 @@ def call_extra( ): i -= 1 hookimpls.insert(i + 1, hookimpl) - firstresult = self.spec.opts.get("firstresult", False) if self.spec else False + firstresult = self.spec.opts.firstresult if self.spec else False return self._hookexec(self.name, hookimpls, kwargs, firstresult) def _maybe_apply_history(self, method: HookImpl) -> None: diff --git a/src/pluggy/_config.py b/src/pluggy/_config.py index 576a7794..48ead414 100644 --- a/src/pluggy/_config.py +++ b/src/pluggy/_config.py @@ -5,50 +5,163 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TypedDict - - -class HookspecOpts(TypedDict): - """Options for a hook specification.""" - - #: Whether the hook is :ref:`first result only `. - firstresult: bool - #: Whether the hook is :ref:`historic `. - historic: bool - #: Whether the hook :ref:`warns when implemented `. - warn_on_impl: Warning | None - #: Whether the hook warns when :ref:`certain arguments are requested - #: `. - #: - #: .. versionadded:: 1.5 - warn_on_impl_args: Mapping[str, Warning] | None - - -class HookimplOpts(TypedDict): - """Options for a hook implementation.""" - - #: Whether the hook implementation is a :ref:`wrapper `. - wrapper: bool - #: Whether the hook implementation is an :ref:`old-style wrapper - #: `. - hookwrapper: bool - #: Whether validation against a hook specification is :ref:`optional - #: `. - optionalhook: bool - #: Whether to try to order this hook implementation :ref:`first - #: `. - tryfirst: bool - #: Whether to try to order this hook implementation :ref:`last - #: `. - trylast: bool - #: The name of the hook specification to match, see :ref:`specname`. - specname: str | None - - -def normalize_hookimpl_opts(opts: HookimplOpts) -> None: - opts.setdefault("tryfirst", False) - opts.setdefault("trylast", False) - opts.setdefault("wrapper", False) - opts.setdefault("hookwrapper", False) - opts.setdefault("optionalhook", False) - opts.setdefault("specname", None) +from typing import Any +from typing import Final +from typing import final + + +@final +class HookspecConfiguration: + """Configuration for a hook specification.""" + + __slots__ = ( + "firstresult", + "historic", + "warn_on_impl", + "warn_on_impl_args", + ) + firstresult: Final[bool] + historic: Final[bool] + warn_on_impl: Final[Warning | None] + warn_on_impl_args: Final[Mapping[str, Warning] | None] + + def __init__( + self, + firstresult: bool = False, + historic: bool = False, + warn_on_impl: Warning | None = None, + warn_on_impl_args: Mapping[str, Warning] | None = None, + ) -> None: + if historic and firstresult: + raise ValueError("cannot have a historic firstresult hook") + #: Whether the hook is :ref:`first result only `. + self.firstresult = firstresult + #: Whether the hook is :ref:`historic `. + self.historic = historic + #: Whether the hook :ref:`warns when implemented `. + self.warn_on_impl = warn_on_impl + #: Whether the hook warns when :ref:`certain arguments are requested + #: `. + self.warn_on_impl_args = warn_on_impl_args + + def __repr__(self) -> str: + attrs = [ + f"{slot}={getattr(self, slot)!r}" + for slot in self.__slots__ + if getattr(self, slot) + ] + return f"HookspecConfiguration({', '.join(attrs)})" + + +@final +class HookimplConfiguration: + """Configuration for a hook implementation.""" + + __slots__ = ( + "hookwrapper", + "optionalhook", + "specname", + "tryfirst", + "trylast", + "wrapper", + ) + wrapper: Final[bool] + hookwrapper: Final[bool] + optionalhook: Final[bool] + tryfirst: Final[bool] + trylast: Final[bool] + specname: Final[str | None] + + def __init__( + self, + wrapper: bool = False, + hookwrapper: bool = False, + optionalhook: bool = False, + tryfirst: bool = False, + trylast: bool = False, + specname: str | None = None, + ) -> None: + #: Whether the hook implementation is a :ref:`wrapper `. + self.wrapper = wrapper + #: Whether the hook implementation is an :ref:`old-style wrapper + #: `. + self.hookwrapper = hookwrapper + #: Whether validation against a hook specification is :ref:`optional + #: `. + self.optionalhook = optionalhook + #: Whether to try to order this hook implementation :ref:`first + #: `. + self.tryfirst = tryfirst + #: Whether to try to order this hook implementation :ref:`last + #: `. + self.trylast = trylast + #: The name of the hook specification to match, see :ref:`specname`. + self.specname = specname + + def __repr__(self) -> str: + attrs = [ + f"{slot}={getattr(self, slot)!r}" + for slot in self.__slots__ + if getattr(self, slot) + ] + return f"HookimplConfiguration({', '.join(attrs)})" + + +def hookspec_config_from_mapping( + opts: Mapping[str, Any], +) -> HookspecConfiguration: + """Build a :class:`HookspecConfiguration` from a mapping. + + Intended for pytest/support migration only — not the public options API. + Prefer constructing :class:`HookspecConfiguration` directly. + """ + return HookspecConfiguration( + firstresult=bool(opts.get("firstresult", False)), + historic=bool(opts.get("historic", False)), + warn_on_impl=opts.get("warn_on_impl"), + warn_on_impl_args=opts.get("warn_on_impl_args"), + ) + + +def hookimpl_config_from_mapping( + opts: Mapping[str, Any], +) -> HookimplConfiguration: + """Build a :class:`HookimplConfiguration` from a mapping. + + Intended for pytest/support migration only — not the public options API. + Prefer constructing :class:`HookimplConfiguration` directly. + """ + return HookimplConfiguration( + wrapper=bool(opts.get("wrapper", False)), + hookwrapper=bool(opts.get("hookwrapper", False)), + optionalhook=bool(opts.get("optionalhook", False)), + tryfirst=bool(opts.get("tryfirst", False)), + trylast=bool(opts.get("trylast", False)), + specname=opts.get("specname"), + ) + + +def hookspec_config_to_mapping( + config: HookspecConfiguration, +) -> dict[str, Any]: + """Serialize configuration to a legacy mapping (pytest/support only).""" + return { + "firstresult": config.firstresult, + "historic": config.historic, + "warn_on_impl": config.warn_on_impl, + "warn_on_impl_args": config.warn_on_impl_args, + } + + +def hookimpl_config_to_mapping( + config: HookimplConfiguration, +) -> dict[str, Any]: + """Serialize configuration to a legacy mapping (pytest/support only).""" + return { + "wrapper": config.wrapper, + "hookwrapper": config.hookwrapper, + "optionalhook": config.optionalhook, + "tryfirst": config.tryfirst, + "trylast": config.trylast, + "specname": config.specname, + } diff --git a/src/pluggy/_decorators.py b/src/pluggy/_decorators.py index c5574a67..08342702 100644 --- a/src/pluggy/_decorators.py +++ b/src/pluggy/_decorators.py @@ -17,8 +17,8 @@ from typing import TypeVar import warnings -from ._config import HookimplOpts -from ._config import HookspecOpts +from ._config import HookimplConfiguration +from ._config import HookspecConfiguration _F = TypeVar("_F", bound=Callable[..., object]) @@ -96,15 +96,13 @@ def __call__( """ def setattr_hookspec_opts(func: _F) -> _F: - if historic and firstresult: - raise ValueError("cannot have a historic firstresult hook") - opts: HookspecOpts = { - "firstresult": firstresult, - "historic": historic, - "warn_on_impl": warn_on_impl, - "warn_on_impl_args": warn_on_impl_args, - } - setattr(func, self.project_name + "_spec", opts) + config = HookspecConfiguration( + firstresult=firstresult, + historic=historic, + warn_on_impl=warn_on_impl, + warn_on_impl_args=warn_on_impl_args, + ) + setattr(func, self.project_name + "_spec", config) return func if function is not None: @@ -213,15 +211,15 @@ def __call__( """ def setattr_hookimpl_opts(func: _F) -> _F: - opts: HookimplOpts = { - "wrapper": wrapper, - "hookwrapper": hookwrapper, - "optionalhook": optionalhook, - "tryfirst": tryfirst, - "trylast": trylast, - "specname": specname, - } - setattr(func, self.project_name + "_impl", opts) + config = HookimplConfiguration( + wrapper=wrapper, + hookwrapper=hookwrapper, + optionalhook=optionalhook, + tryfirst=tryfirst, + trylast=trylast, + specname=specname, + ) + setattr(func, self.project_name + "_impl", config) return func if function is None: @@ -339,7 +337,9 @@ class HookSpec: "warn_on_impl_args", ) - def __init__(self, namespace: _Namespace, name: str, opts: HookspecOpts) -> None: + def __init__( + self, namespace: _Namespace, name: str, opts: HookspecConfiguration + ) -> None: self.namespace = namespace self.name = name self.function: Callable[..., object] = getattr(namespace, name) @@ -352,5 +352,5 @@ def __init__(self, namespace: _Namespace, name: str, opts: HookspecOpts) -> None defaults = inspect.unwrap(self.function).__defaults__ self.kwargdefaults = dict(zip(self.kwargnames, defaults or ())) self.opts = opts - self.warn_on_impl = opts.get("warn_on_impl") - self.warn_on_impl_args = opts.get("warn_on_impl_args") + self.warn_on_impl = opts.warn_on_impl + self.warn_on_impl_args = opts.warn_on_impl_args diff --git a/src/pluggy/_hooks.py b/src/pluggy/_hooks.py index b57aae66..55197772 100644 --- a/src/pluggy/_hooks.py +++ b/src/pluggy/_hooks.py @@ -13,9 +13,8 @@ from ._caller import _SubsetHookCaller from ._caller import HookCaller from ._caller import HookRelay -from ._config import HookimplOpts -from ._config import HookspecOpts -from ._config import normalize_hookimpl_opts +from ._config import HookimplConfiguration +from ._config import HookspecConfiguration from ._decorators import _Namespace from ._decorators import HookimplMarker from ._decorators import HookSpec @@ -31,10 +30,10 @@ "HookImpl", "HookRelay", "HookSpec", + "HookimplConfiguration", "HookimplMarker", - "HookimplOpts", + "HookspecConfiguration", "HookspecMarker", - "HookspecOpts", "_HookCaller", "_HookExec", "_HookImplFunction", @@ -42,6 +41,5 @@ "_Namespace", "_Plugin", "_SubsetHookCaller", - "normalize_hookimpl_opts", "varnames", ] diff --git a/src/pluggy/_impl.py b/src/pluggy/_impl.py index 1b7182f1..9ff7d625 100644 --- a/src/pluggy/_impl.py +++ b/src/pluggy/_impl.py @@ -11,7 +11,7 @@ from typing import TypeAlias from typing import TypeVar -from ._config import HookimplOpts +from ._config import HookimplConfiguration from ._decorators import varnames from ._result import Result @@ -45,7 +45,7 @@ def __init__( plugin: _Plugin, plugin_name: str, function: _HookImplFunction[object], - hook_impl_opts: HookimplOpts, + hook_impl_opts: HookimplConfiguration, ) -> None: """:meta private:""" #: The hook implementation function. @@ -58,24 +58,25 @@ def __init__( self.kwargnames: Final = kwargnames #: The plugin which defined this hook implementation. self.plugin: Final = plugin - #: The :class:`HookimplOpts` used to configure this hook implementation. + #: The :class:`HookimplConfiguration` used to configure this hook + #: implementation. self.opts: Final = hook_impl_opts #: The name of the plugin which defined this hook implementation. self.plugin_name: Final = plugin_name #: Whether the hook implementation is a :ref:`wrapper `. - self.wrapper: Final = hook_impl_opts["wrapper"] + self.wrapper: Final = hook_impl_opts.wrapper #: Whether the hook implementation is an :ref:`old-style wrapper #: `. - self.hookwrapper: Final = hook_impl_opts["hookwrapper"] + self.hookwrapper: Final = hook_impl_opts.hookwrapper #: Whether validation against a hook specification is :ref:`optional #: `. - self.optionalhook: Final = hook_impl_opts["optionalhook"] + self.optionalhook: Final = hook_impl_opts.optionalhook #: Whether to try to order this hook implementation :ref:`first #: `. - self.tryfirst: Final = hook_impl_opts["tryfirst"] + self.tryfirst: Final = hook_impl_opts.tryfirst #: Whether to try to order this hook implementation :ref:`last #: `. - self.trylast: Final = hook_impl_opts["trylast"] + self.trylast: Final = hook_impl_opts.trylast def __repr__(self) -> str: return f"" diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index aa54088c..21d740d1 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -16,16 +16,21 @@ from . import _tracing from ._callers import _multicall from ._compat import DistFacade +from ._config import hookimpl_config_from_mapping +from ._config import hookimpl_config_to_mapping +from ._config import HookimplConfiguration +from ._config import hookspec_config_from_mapping +from ._config import hookspec_config_to_mapping +from ._config import HookspecConfiguration from ._hooks import _HookImplFunction from ._hooks import _Namespace from ._hooks import _Plugin from ._hooks import _SubsetHookCaller from ._hooks import HookCaller from ._hooks import HookImpl -from ._hooks import HookimplOpts from ._hooks import HookRelay -from ._hooks import HookspecOpts -from ._hooks import normalize_hookimpl_opts +from ._pytest_compat import HookimplOpts +from ._pytest_compat import HookspecOpts from ._result import Result @@ -112,16 +117,19 @@ def _static_hook_attr( return None -def _get_marker_opts(holder: object, attrname: str) -> dict[str, Any] | None: - """Read marker options from ``holder``, falling back to ``__func__``.""" - opts = getattr(holder, attrname, None) - if opts is not None: - return opts if isinstance(opts, dict) else None - func = getattr(holder, "__func__", None) +def _get_marker_attr(holder: object, attrname: str) -> object | None: + """Read a marker attribute from ``holder``, falling back to ``__func__``. + + The marker sits on the ``classmethod``/``staticmethod`` wrapper when it was + applied above it, and on the wrapped function when applied below. + """ + marker: object = getattr(holder, attrname, None) + if marker is not None: + return marker + func: object = getattr(holder, "__func__", None) if func is not None: - opts = getattr(func, attrname, None) - if opts is not None: - return opts if isinstance(opts, dict) else None + wrapped: object = getattr(func, attrname, None) + return wrapped return None @@ -221,9 +229,8 @@ def register(self, plugin: _Plugin, name: str | None = None) -> str | None: # fails validation leaves the manager untouched. hookimpls: list[tuple[str, HookImpl, bool]] = [] for attr_name in dir(plugin): - hookimpl_opts = self.parse_hookimpl_opts(plugin, attr_name) - if hookimpl_opts is not None: - normalize_hookimpl_opts(hookimpl_opts) + hookimpl_config = self._discover_hookimpl_configuration(plugin, attr_name) + if hookimpl_config is not None: found = _static_hook_attr(plugin, attr_name) # Only reachable when a subclass overrode parse_hookimpl_opts # to claim an attribute pluggy cannot bind. @@ -231,8 +238,8 @@ def register(self, plugin: _Plugin, name: str | None = None) -> str | None: f"{plugin!r}.{attr_name} is not a hookable attribute" ) method: _HookImplFunction[object] = found[1] - hookimpl = HookImpl(plugin, plugin_name, method, hookimpl_opts) - hook_name = hookimpl_opts.get("specname") or attr_name + hookimpl = HookImpl(plugin, plugin_name, method, hookimpl_config) + hook_name = hookimpl_config.specname or attr_name hook: HookCaller | None = getattr(self.hook, hook_name, None) verified = False if hook is not None and hook.has_spec(): @@ -255,16 +262,10 @@ def register(self, plugin: _Plugin, name: str | None = None) -> str | None: hook._add_hookimpl(hookimpl) return plugin_name - def parse_hookimpl_opts(self, plugin: _Plugin, name: str) -> HookimplOpts | None: - """Try to obtain a hook implementation from an item with the given name - in the given plugin which is being searched for hook impls. - - :returns: - The parsed hookimpl options, or None to skip the given item. - - This method can be overridden by ``PluginManager`` subclasses to - customize how hook implementation are picked up. By default, returns the - options for items decorated with :class:`HookimplMarker`. + def _read_hookimpl_configuration( + self, plugin: _Plugin, name: str + ) -> HookimplConfiguration | None: + """Read a modern :class:`HookimplConfiguration` from a plugin attribute. Discovery uses :func:`inspect.getattr_static` so properties and other descriptors are not executed. Only functions, classmethods, @@ -273,10 +274,46 @@ def parse_hookimpl_opts(self, plugin: _Plugin, name: str) -> HookimplOpts | None found = _static_hook_attr(plugin, name) if found is None: return None - return cast( - HookimplOpts | None, - _get_marker_opts(found[0], self.project_name + "_impl"), - ) + res = _get_marker_attr(found[0], self.project_name + "_impl") + if isinstance(res, HookimplConfiguration): + return res + if isinstance(res, Mapping): + return hookimpl_config_from_mapping(res) + return None + + def _discover_hookimpl_configuration( + self, plugin: _Plugin, name: str + ) -> HookimplConfiguration | None: + """Discover hookimpl configuration for registration. + + Prefer the modern marker attribute. Only call the deprecated + :meth:`parse_hookimpl_opts` when a subclass actually overrides it and + no modern configuration was found (pytest unmarked-hook concession). + """ + config = self._read_hookimpl_configuration(plugin, name) + if config is not None: + return config + parse_hookimpl_opts = type(self).parse_hookimpl_opts + if parse_hookimpl_opts is PluginManager.parse_hookimpl_opts: + return None + legacy = parse_hookimpl_opts(self, plugin, name) + if legacy is None: + return None + return hookimpl_config_from_mapping(legacy) + + def parse_hookimpl_opts(self, plugin: _Plugin, name: str) -> HookimplOpts | None: + """Return legacy dict-shaped hookimpl options, if any. + + .. deprecated:: + Thin pytest/support concession. Registration uses private discovery + of :class:`HookimplConfiguration` and only invokes this method when + a subclass overrides it and no modern configuration attribute was + found. Prefer marker-attached configuration objects. + """ + config = self._read_hookimpl_configuration(plugin, name) + if config is None: + return None + return cast(HookimplOpts, hookimpl_config_to_mapping(config)) def unregister( self, plugin: _Plugin | None = None, name: str | None = None @@ -349,15 +386,15 @@ def add_hookspecs(self, module_or_class: _Namespace) -> None: """ names = [] for name in dir(module_or_class): - spec_opts = self.parse_hookspec_opts(module_or_class, name) - if spec_opts is not None: + spec_config = self._discover_hookspec_configuration(module_or_class, name) + if spec_config is not None: hc: HookCaller | None = getattr(self.hook, name, None) if hc is None: - hc = HookCaller(name, self._hookexec, module_or_class, spec_opts) + hc = HookCaller(name, self._hookexec, module_or_class, spec_config) setattr(self.hook, name, hc) else: # Plugins registered this hook without knowing the spec. - hc.set_specification(module_or_class, spec_opts) + hc.set_specification(module_or_class, spec_config) for hookfunction in hc.get_hookimpls(): self._verify_hook(hc, hookfunction) names.append(name) @@ -367,19 +404,10 @@ def add_hookspecs(self, module_or_class: _Namespace) -> None: f"did not find any {self.project_name!r} hooks in {module_or_class!r}" ) - def parse_hookspec_opts( + def _read_hookspec_configuration( self, module_or_class: _Namespace, name: str - ) -> HookspecOpts | None: - """Try to obtain a hook specification from an item with the given name - in the given module or class which is being searched for hook specs. - - :returns: - The parsed hookspec options for defining a hook, or None to skip the - given item. - - This method can be overridden by ``PluginManager`` subclasses to - customize how hook specifications are picked up. By default, returns the - options for items decorated with :class:`HookspecMarker`. + ) -> HookspecConfiguration | None: + """Read a modern :class:`HookspecConfiguration` from a marked function. Discovery uses :func:`inspect.getattr_static` so properties and other descriptors are not executed. Only functions, classmethods, @@ -388,10 +416,48 @@ def parse_hookspec_opts( found = _static_hook_attr(module_or_class, name) if found is None: return None - return cast( - HookspecOpts | None, - _get_marker_opts(found[0], self.project_name + "_spec"), - ) + opts = _get_marker_attr(found[0], self.project_name + "_spec") + if isinstance(opts, HookspecConfiguration): + return opts + if isinstance(opts, Mapping): + return hookspec_config_from_mapping(opts) + return None + + def _discover_hookspec_configuration( + self, module_or_class: _Namespace, name: str + ) -> HookspecConfiguration | None: + """Discover hookspec configuration for ``add_hookspecs``. + + Prefer the modern marker attribute. Only call the deprecated + :meth:`parse_hookspec_opts` when a subclass actually overrides it and + no modern configuration was found. + """ + config = self._read_hookspec_configuration(module_or_class, name) + if config is not None: + return config + parse_hookspec_opts = type(self).parse_hookspec_opts + if parse_hookspec_opts is PluginManager.parse_hookspec_opts: + return None + legacy = parse_hookspec_opts(self, module_or_class, name) + if legacy is None: + return None + return hookspec_config_from_mapping(legacy) + + def parse_hookspec_opts( + self, module_or_class: _Namespace, name: str + ) -> HookspecOpts | None: + """Return legacy dict-shaped hookspec options, if any. + + .. deprecated:: + Thin pytest/support concession. ``add_hookspecs`` uses private + discovery of :class:`HookspecConfiguration` and only invokes this + method when a subclass overrides it and no modern configuration + attribute was found. Prefer marker-attached configuration objects. + """ + config = self._read_hookspec_configuration(module_or_class, name) + if config is None: + return None + return cast(HookspecOpts, hookspec_config_to_mapping(config)) def get_plugins(self) -> set[Any]: """Return a set of all registered plugin objects.""" diff --git a/src/pluggy/_pytest_compat.py b/src/pluggy/_pytest_compat.py new file mode 100644 index 00000000..e9d3b693 --- /dev/null +++ b/src/pluggy/_pytest_compat.py @@ -0,0 +1,55 @@ +"""Pytest/support compatibility helpers for legacy option encodings. + +The live pluggy API uses :class:`~pluggy.HookspecConfiguration` and +:class:`~pluggy.HookimplConfiguration`. This module keeps TypedDict shapes and +mapping conversion for pytest and other callers that still type or attach +dict-shaped options during migration. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TypedDict + +from ._config import hookimpl_config_from_mapping +from ._config import HookimplConfiguration +from ._config import hookspec_config_from_mapping +from ._config import HookspecConfiguration + + +class HookspecOpts(TypedDict): + """Legacy TypedDict for hook specification options. + + Prefer :class:`~pluggy.HookspecConfiguration`. Kept for pytest/typing + compatibility during migration. + """ + + firstresult: bool + historic: bool + warn_on_impl: Warning | None + warn_on_impl_args: Mapping[str, Warning] | None + + +class HookimplOpts(TypedDict): + """Legacy TypedDict for hook implementation options. + + Prefer :class:`~pluggy.HookimplConfiguration`. Kept for pytest/typing + compatibility during migration. + """ + + wrapper: bool + hookwrapper: bool + optionalhook: bool + tryfirst: bool + trylast: bool + specname: str | None + + +__all__ = [ + "HookimplConfiguration", + "HookimplOpts", + "HookspecConfiguration", + "HookspecOpts", + "hookimpl_config_from_mapping", + "hookspec_config_from_mapping", +] diff --git a/testing/test_configuration.py b/testing/test_configuration.py new file mode 100644 index 00000000..c7dbef0f --- /dev/null +++ b/testing/test_configuration.py @@ -0,0 +1,278 @@ +""" +Tests for configuration classes. +""" + +from __future__ import annotations + +import pytest + +from pluggy import HookimplConfiguration +from pluggy import HookimplMarker +from pluggy import HookspecConfiguration +from pluggy import HookspecMarker +from pluggy import PluginManager +from pluggy._config import hookimpl_config_from_mapping +from pluggy._config import hookspec_config_from_mapping + + +class TestHookspecConfiguration: + def test_basic_creation(self) -> None: + config = HookspecConfiguration() + assert config.firstresult is False + assert config.historic is False + assert config.warn_on_impl is None + assert config.warn_on_impl_args is None + + def test_firstresult(self) -> None: + config = HookspecConfiguration(firstresult=True) + assert config.firstresult is True + assert config.historic is False + + def test_historic(self) -> None: + config = HookspecConfiguration(historic=True) + assert config.firstresult is False + assert config.historic is True + + def test_historic_firstresult_validation(self) -> None: + with pytest.raises(ValueError, match="cannot have a historic firstresult"): + HookspecConfiguration(historic=True, firstresult=True) + + def test_warn_on_impl(self) -> None: + warning = UserWarning("test warning") + config = HookspecConfiguration(warn_on_impl=warning) + assert config.warn_on_impl is warning + + def test_warn_on_impl_args(self) -> None: + warnings_dict = {"arg1": UserWarning("arg1 warning")} + config = HookspecConfiguration(warn_on_impl_args=warnings_dict) + assert config.warn_on_impl_args is warnings_dict + + +class TestHookimplConfiguration: + def test_basic_creation(self) -> None: + config = HookimplConfiguration() + assert config.wrapper is False + assert config.hookwrapper is False + assert config.optionalhook is False + assert config.tryfirst is False + assert config.trylast is False + assert config.specname is None + + def test_wrapper(self) -> None: + config = HookimplConfiguration(wrapper=True) + assert config.wrapper is True + assert config.hookwrapper is False + + def test_hookwrapper(self) -> None: + config = HookimplConfiguration(hookwrapper=True) + assert config.wrapper is False + assert config.hookwrapper is True + + def test_both_wrappers_allowed(self) -> None: + """Both wrapper types are allowed at config level; validation is later.""" + config = HookimplConfiguration(wrapper=True, hookwrapper=True) + assert config.wrapper is True + assert config.hookwrapper is True + + def test_tryfirst(self) -> None: + config = HookimplConfiguration(tryfirst=True) + assert config.tryfirst is True + assert config.trylast is False + + def test_trylast(self) -> None: + config = HookimplConfiguration(trylast=True) + assert config.tryfirst is False + assert config.trylast is True + + def test_optionalhook(self) -> None: + config = HookimplConfiguration(optionalhook=True) + assert config.optionalhook is True + + def test_specname(self) -> None: + config = HookimplConfiguration(specname="custom_name") + assert config.specname == "custom_name" + + +class TestMappingShim: + def test_hookspec_config_from_mapping(self) -> None: + warning = UserWarning("w") + config = hookspec_config_from_mapping( + { + "firstresult": True, + "warn_on_impl": warning, + } + ) + assert config.firstresult is True + assert config.historic is False + assert config.warn_on_impl is warning + + def test_hookimpl_config_from_mapping(self) -> None: + config = hookimpl_config_from_mapping( + { + "tryfirst": True, + "specname": "other", + } + ) + assert config.tryfirst is True + assert config.specname == "other" + assert config.wrapper is False + + def test_read_hookimpl_accepts_legacy_dict_attribute(self) -> None: + pm = PluginManager("test") + + def method() -> str: + return "ok" + + # setattr, not attribute access: the marker is dynamic and must not + # be type-checked against the function object. + setattr(method, "test_impl", {"tryfirst": True}) # noqa: B010 + + class Plugin: + pass + + plugin = Plugin() + plugin.method = method # type: ignore[attr-defined] + config = pm._read_hookimpl_configuration(plugin, "method") + assert isinstance(config, HookimplConfiguration) + assert config.tryfirst is True + + def test_parse_hookimpl_opts_returns_legacy_dict(self) -> None: + hookimpl = HookimplMarker("test") + + @hookimpl(tryfirst=True) + def method() -> str: + return "ok" + + class Plugin: + pass + + plugin = Plugin() + plugin.method = method # type: ignore[attr-defined] + opts = PluginManager("test").parse_hookimpl_opts(plugin, "method") + assert opts == { + "wrapper": False, + "hookwrapper": False, + "optionalhook": False, + "tryfirst": True, + "trylast": False, + "specname": None, + } + + def test_read_hookspec_accepts_legacy_dict_attribute(self) -> None: + pm = PluginManager("test") + + class Spec: + def myhook(self) -> None: + pass + + setattr(Spec.myhook, "test_spec", {"firstresult": True}) # noqa: B010 + config = pm._read_hookspec_configuration(Spec, "myhook") + assert isinstance(config, HookspecConfiguration) + assert config.firstresult is True + + def test_discover_skips_parse_hookimpl_opts_unless_overridden(self) -> None: + calls: list[str] = [] + + class TrackingPluginManager(PluginManager): + def parse_hookimpl_opts(self, plugin: object, name: str): + calls.append(name) + return super().parse_hookimpl_opts(plugin, name) + + class Spec: + @HookspecMarker("test") + def marked(self) -> None: + pass + + def unmarked(self) -> None: + pass + + class Plugin: + @HookimplMarker("test") + def marked(self) -> str: + return "marked" + + def unmarked(self) -> str: + return "unmarked" + + pm = TrackingPluginManager("test") + pm.add_hookspecs(Spec) + pm.register(Plugin()) + # Marked impl is discovered privately; unmarked has no config and the + # override returns None, so parse_hookimpl_opts is only tried for names + # without a modern configuration attribute. + assert "marked" not in calls + assert "unmarked" in calls + + +def test_markers_attach_configuration_objects() -> None: + hookspec = HookspecMarker("test") + hookimpl = HookimplMarker("test") + + @hookspec(firstresult=True) + def myspec(arg: object) -> None: + pass + + @hookimpl(tryfirst=True) + def myimpl(arg: object) -> str: + return "x" + + spec_config = getattr(myspec, "test_spec") # noqa: B009 + impl_config = getattr(myimpl, "test_impl") # noqa: B009 + assert isinstance(spec_config, HookspecConfiguration) + assert spec_config.firstresult is True + assert isinstance(impl_config, HookimplConfiguration) + assert impl_config.tryfirst is True + + +def test_config_integration_with_hooks() -> None: + pm = PluginManager("test") + hookspec = HookspecMarker("test") + hookimpl = HookimplMarker("test") + + class MySpec: + @hookspec(firstresult=True) + def myhook(self, arg: object) -> None: + pass + + class Plugin1: + @hookimpl(trylast=True) + def myhook(self, arg: object) -> str: + return f"plugin1: {arg}" + + class Plugin2: + @hookimpl(tryfirst=True) + def myhook(self, arg: object) -> str: + return f"plugin2: {arg}" + + pm.add_hookspecs(MySpec) + pm.register(Plugin1()) + pm.register(Plugin2()) + + result = pm.hook.myhook(arg="test") + assert result == "plugin2: test" + + +def test_historic_hook_configuration() -> None: + pm = PluginManager("test") + hookspec = HookspecMarker("test") + hookimpl = HookimplMarker("test") + + results: list[str] = [] + + class MySpec: + @hookspec(historic=True) + def myhook(self, arg: object) -> None: + pass + + pm.add_hookspecs(MySpec) + pm.hook.myhook.call_historic( + kwargs={"arg": "call1"}, result_callback=results.append + ) + + class Plugin1: + @hookimpl + def myhook(self, arg: object) -> str: + return f"plugin1: {arg}" + + pm.register(Plugin1()) + assert "plugin1: call1" in results diff --git a/testing/test_details.py b/testing/test_details.py index 8e4c35a9..f255877e 100644 --- a/testing/test_details.py +++ b/testing/test_details.py @@ -16,9 +16,11 @@ def test_parse_hookimpl_override() -> None: class MyPluginManager(PluginManager): def parse_hookimpl_opts(self, module_or_class, name): opts = PluginManager.parse_hookimpl_opts(self, module_or_class, name) - if opts is None and name.startswith("x1"): - opts = {} # type: ignore[assignment] - return opts + if opts is not None: + return opts + if name.startswith("x1"): + return {} + return None class Plugin: def x1meth(self): diff --git a/testing/test_hookcaller.py b/testing/test_hookcaller.py index cdf79ca0..4a365c45 100644 --- a/testing/test_hookcaller.py +++ b/testing/test_hookcaller.py @@ -317,11 +317,11 @@ def he_myhook3(self, arg1) -> None: pm.add_hookspecs(HookSpec) assert pm.hook.he_myhook1.spec is not None - assert not pm.hook.he_myhook1.spec.opts["firstresult"] + assert not pm.hook.he_myhook1.spec.opts.firstresult assert pm.hook.he_myhook2.spec is not None - assert pm.hook.he_myhook2.spec.opts["firstresult"] + assert pm.hook.he_myhook2.spec.opts.firstresult assert pm.hook.he_myhook3.spec is not None - assert not pm.hook.he_myhook3.spec.opts["firstresult"] + assert not pm.hook.he_myhook3.spec.opts.firstresult @pytest.mark.parametrize("name", ["hookwrapper", "optionalhook", "tryfirst", "trylast"]) @@ -332,7 +332,7 @@ def he_myhook1(arg1) -> None: pass if val: - assert he_myhook1.example_impl.get(name) + assert getattr(he_myhook1.example_impl, name) else: assert not hasattr(he_myhook1, name) From 599b7d3b0a3f6b55184c1bbb8e932ee6af2b2f84 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sat, 17 Oct 2026 07:40:06 +0200 Subject: [PATCH 02/10] test(config): cover configuration reprs and legacy hookspec mapping codecov failed the patch on #704: both Configuration __repr__ methods and parse_hookspec_opts' mapping path had no test, and several helper bodies in the new tests never ran. The override in test_parse_hookimpl_override no longer needs a branch for a non-None result: parse_hookimpl_opts is only consulted when no modern configuration was found, so that branch was unreachable. Co-Authored-By: Claude Opus 5.5 via Claude Code --- testing/test_configuration.py | 56 +++++++++++++++++++++++++++-------- testing/test_details.py | 6 ++-- 2 files changed, 45 insertions(+), 17 deletions(-) diff --git a/testing/test_configuration.py b/testing/test_configuration.py index c7dbef0f..03b57f34 100644 --- a/testing/test_configuration.py +++ b/testing/test_configuration.py @@ -47,6 +47,13 @@ def test_warn_on_impl_args(self) -> None: config = HookspecConfiguration(warn_on_impl_args=warnings_dict) assert config.warn_on_impl_args is warnings_dict + def test_repr_lists_only_set_options(self) -> None: + assert repr(HookspecConfiguration()) == "HookspecConfiguration()" + assert ( + repr(HookspecConfiguration(firstresult=True)) + == "HookspecConfiguration(firstresult=True)" + ) + class TestHookimplConfiguration: def test_basic_creation(self) -> None: @@ -92,6 +99,13 @@ def test_specname(self) -> None: config = HookimplConfiguration(specname="custom_name") assert config.specname == "custom_name" + def test_repr_lists_only_set_options(self) -> None: + assert repr(HookimplConfiguration()) == "HookimplConfiguration()" + assert ( + repr(HookimplConfiguration(tryfirst=True, specname="other")) + == "HookimplConfiguration(specname='other', tryfirst=True)" + ) + class TestMappingShim: def test_hookspec_config_from_mapping(self) -> None: @@ -120,8 +134,8 @@ def test_hookimpl_config_from_mapping(self) -> None: def test_read_hookimpl_accepts_legacy_dict_attribute(self) -> None: pm = PluginManager("test") - def method() -> str: - return "ok" + def method() -> None: + pass # setattr, not attribute access: the marker is dynamic and must not # be type-checked against the function object. @@ -140,8 +154,8 @@ def test_parse_hookimpl_opts_returns_legacy_dict(self) -> None: hookimpl = HookimplMarker("test") @hookimpl(tryfirst=True) - def method() -> str: - return "ok" + def method() -> None: + pass class Plugin: pass @@ -170,6 +184,20 @@ def myhook(self) -> None: assert isinstance(config, HookspecConfiguration) assert config.firstresult is True + def test_parse_hookspec_opts_returns_legacy_dict(self) -> None: + class Spec: + @HookspecMarker("test") + def myhook(self) -> None: + pass + + opts = PluginManager("test").parse_hookspec_opts(Spec, "myhook") + assert opts == { + "firstresult": False, + "historic": False, + "warn_on_impl": None, + "warn_on_impl_args": None, + } + def test_discover_skips_parse_hookimpl_opts_unless_overridden(self) -> None: calls: list[str] = [] @@ -188,11 +216,11 @@ def unmarked(self) -> None: class Plugin: @HookimplMarker("test") - def marked(self) -> str: - return "marked" + def marked(self) -> None: + pass - def unmarked(self) -> str: - return "unmarked" + def unmarked(self) -> None: + pass pm = TrackingPluginManager("test") pm.add_hookspecs(Spec) @@ -213,8 +241,8 @@ def myspec(arg: object) -> None: pass @hookimpl(tryfirst=True) - def myimpl(arg: object) -> str: - return "x" + def myimpl(arg: object) -> None: + pass spec_config = getattr(myspec, "test_spec") # noqa: B009 impl_config = getattr(myimpl, "test_impl") # noqa: B009 @@ -246,10 +274,12 @@ def myhook(self, arg: object) -> str: pm.add_hookspecs(MySpec) pm.register(Plugin1()) - pm.register(Plugin2()) + plugin2 = Plugin2() + pm.register(plugin2) - result = pm.hook.myhook(arg="test") - assert result == "plugin2: test" + assert pm.hook.myhook(arg="test") == "plugin2: test" + pm.unregister(plugin2) + assert pm.hook.myhook(arg="test") == "plugin1: test" def test_historic_hook_configuration() -> None: diff --git a/testing/test_details.py b/testing/test_details.py index f255877e..9f5ea9d4 100644 --- a/testing/test_details.py +++ b/testing/test_details.py @@ -16,11 +16,9 @@ def test_parse_hookimpl_override() -> None: class MyPluginManager(PluginManager): def parse_hookimpl_opts(self, module_or_class, name): opts = PluginManager.parse_hookimpl_opts(self, module_or_class, name) - if opts is not None: - return opts - if name.startswith("x1"): + if opts is None and name.startswith("x1"): return {} - return None + return opts class Plugin: def x1meth(self): From e946be09355e16a6d5bf011efd994a1130ad2ae1 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 24 Jul 2026 11:20:10 +0200 Subject: [PATCH 03/10] feat(decorators): attach Hook*Configuration objects on marked functions Complete design step 03: markers already attach configuration objects since step 02; this finishes the step by storing the spec configuration as HookSpec.config (try-claude naming) with a deprecated .opts alias, reading .config in HookCaller firstresult resolution, and covering decoration-time historic+firstresult validation and configuration attachment with tests. Co-Authored-By: Claude Fable 5 --- changelog/706.trivial.rst | 3 +++ src/pluggy/_caller.py | 4 ++-- src/pluggy/_decorators.py | 19 ++++++++++++++----- testing/test_configuration.py | 28 ++++++++++++++++++++++++++++ testing/test_hookcaller.py | 6 +++--- 5 files changed, 50 insertions(+), 10 deletions(-) create mode 100644 changelog/706.trivial.rst diff --git a/changelog/706.trivial.rst b/changelog/706.trivial.rst new file mode 100644 index 00000000..88d43f7e --- /dev/null +++ b/changelog/706.trivial.rst @@ -0,0 +1,3 @@ +``HookSpec`` now stores its :class:`pluggy.HookspecConfiguration` as +``config``; the old ``opts`` attribute remains as a deprecated alias +property. diff --git a/src/pluggy/_caller.py b/src/pluggy/_caller.py index 8295982c..b149cf31 100644 --- a/src/pluggy/_caller.py +++ b/src/pluggy/_caller.py @@ -192,7 +192,7 @@ def __call__(self, **kwargs: object) -> Any: ) call_kwargs = self._apply_defaults(kwargs) self._verify_all_args_are_provided(call_kwargs) - firstresult = self.spec.opts.firstresult if self.spec else False + firstresult = self.spec.config.firstresult if self.spec else False # Copy because plugins may register other plugins during iteration (#438). return self._hookexec( self.name, self._hookimpls.copy(), call_kwargs, firstresult @@ -251,7 +251,7 @@ def call_extra( ): i -= 1 hookimpls.insert(i + 1, hookimpl) - firstresult = self.spec.opts.firstresult if self.spec else False + firstresult = self.spec.config.firstresult if self.spec else False return self._hookexec(self.name, hookimpls, kwargs, firstresult) def _maybe_apply_history(self, method: HookImpl) -> None: diff --git a/src/pluggy/_decorators.py b/src/pluggy/_decorators.py index 08342702..3e81ef16 100644 --- a/src/pluggy/_decorators.py +++ b/src/pluggy/_decorators.py @@ -327,18 +327,18 @@ def varnames( class HookSpec: __slots__ = ( "argnames", + "config", "function", "kwargdefaults", "kwargnames", "name", "namespace", - "opts", "warn_on_impl", "warn_on_impl_args", ) def __init__( - self, namespace: _Namespace, name: str, opts: HookspecConfiguration + self, namespace: _Namespace, name: str, config: HookspecConfiguration ) -> None: self.namespace = namespace self.name = name @@ -351,6 +351,15 @@ def __init__( ) defaults = inspect.unwrap(self.function).__defaults__ self.kwargdefaults = dict(zip(self.kwargnames, defaults or ())) - self.opts = opts - self.warn_on_impl = opts.warn_on_impl - self.warn_on_impl_args = opts.warn_on_impl_args + self.config = config + self.warn_on_impl = config.warn_on_impl + self.warn_on_impl_args = config.warn_on_impl_args + + @property + def opts(self) -> HookspecConfiguration: + """Alias for :attr:`config`. + + .. deprecated:: + Use :attr:`config` instead. + """ + return self.config diff --git a/testing/test_configuration.py b/testing/test_configuration.py index 03b57f34..170070e4 100644 --- a/testing/test_configuration.py +++ b/testing/test_configuration.py @@ -252,6 +252,34 @@ def myimpl(arg: object) -> None: assert impl_config.tryfirst is True +def test_historic_firstresult_raises_at_decoration_time() -> None: + hookspec = HookspecMarker("test") + + with pytest.raises(ValueError, match="cannot have a historic firstresult"): + + @hookspec(historic=True, firstresult=True) + def myspec() -> None: + pass + + +def test_hookspec_stores_configuration() -> None: + pm = PluginManager("test") + hookspec = HookspecMarker("test") + + class Spec: + @hookspec(firstresult=True) + def myhook(self, arg: object) -> None: + pass + + pm.add_hookspecs(Spec) + spec = pm.hook.myhook.spec + assert spec is not None + assert isinstance(spec.config, HookspecConfiguration) + assert spec.config.firstresult is True + # Deprecated alias. + assert spec.opts is spec.config + + def test_config_integration_with_hooks() -> None: pm = PluginManager("test") hookspec = HookspecMarker("test") diff --git a/testing/test_hookcaller.py b/testing/test_hookcaller.py index 4a365c45..49b01e5b 100644 --- a/testing/test_hookcaller.py +++ b/testing/test_hookcaller.py @@ -317,11 +317,11 @@ def he_myhook3(self, arg1) -> None: pm.add_hookspecs(HookSpec) assert pm.hook.he_myhook1.spec is not None - assert not pm.hook.he_myhook1.spec.opts.firstresult + assert not pm.hook.he_myhook1.spec.config.firstresult assert pm.hook.he_myhook2.spec is not None - assert pm.hook.he_myhook2.spec.opts.firstresult + assert pm.hook.he_myhook2.spec.config.firstresult assert pm.hook.he_myhook3.spec is not None - assert not pm.hook.he_myhook3.spec.opts.firstresult + assert not pm.hook.he_myhook3.spec.config.firstresult @pytest.mark.parametrize("name", ["hookwrapper", "optionalhook", "tryfirst", "trylast"]) From 5c006afb7ea14183b42245182cc44f6c10c88cf6 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 24 Jul 2026 12:30:04 +0200 Subject: [PATCH 04/10] feat(implementation): add NormalImpl, WrapperImpl, and CompletionHook setup API Complete design step 04: - HookImpl becomes a base class storing hookimpl_config (deprecated .opts alias kept) with arg binding moved to _get_call_args. - NormalImpl / WrapperImpl subclasses validate their configuration; HookimplConfiguration.create_hookimpl() returns the right subclass (fixing the try-claude footgun of bare HookImpl for normals). - WrapperImpl.setup_and_get_completion_hook() runs wrapper setup and returns a CompletionHook (runtime-checkable Protocol) that owns teardown, adapting old-style hookwrappers uniformly. - Registration and call_extra construct impls via create_hookimpl; multicall binds args via _get_call_args. Full dual-sequence multicall rewiring lands with design step 05. Co-Authored-By: Claude Fable 5 --- changelog/707.feature.rst | 9 ++ docs/api_reference.rst | 8 ++ src/pluggy/__init__.py | 4 + src/pluggy/_caller.py | 4 +- src/pluggy/_config.py | 29 +++++ src/pluggy/_execution.py | 16 +-- src/pluggy/_hooks.py | 6 ++ src/pluggy/_impl.py | 183 ++++++++++++++++++++++++++++++-- src/pluggy/_manager.py | 4 +- testing/test_details.py | 2 +- testing/test_impl.py | 218 ++++++++++++++++++++++++++++++++++++++ 11 files changed, 453 insertions(+), 30 deletions(-) create mode 100644 changelog/707.feature.rst create mode 100644 testing/test_impl.py diff --git a/changelog/707.feature.rst b/changelog/707.feature.rst new file mode 100644 index 00000000..cbdca55e --- /dev/null +++ b/changelog/707.feature.rst @@ -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. diff --git a/docs/api_reference.rst b/docs/api_reference.rst index 7d19a4a6..a62272f7 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -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: diff --git a/src/pluggy/__init__.py b/src/pluggy/__init__.py index 83064d90..d562a1b9 100644 --- a/src/pluggy/__init__.py +++ b/src/pluggy/__init__.py @@ -9,11 +9,13 @@ "HookspecConfiguration", "HookspecMarker", "HookspecOpts", + "NormalImpl", "PluggyTeardownRaisedWarning", "PluggyWarning", "PluginManager", "PluginValidationError", "Result", + "WrapperImpl", "__version__", ] from ._config import HookimplConfiguration @@ -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 diff --git a/src/pluggy/_caller.py b/src/pluggy/_caller.py index b149cf31..53d3f903 100644 --- a/src/pluggy/_caller.py +++ b/src/pluggy/_caller.py @@ -237,10 +237,10 @@ def call_extra( ) kwargs = self._apply_defaults(kwargs) self._verify_all_args_are_provided(kwargs) - opts = HookimplConfiguration() + config = HookimplConfiguration() hookimpls = self._hookimpls.copy() for method in methods: - hookimpl = HookImpl(None, "", method, opts) + hookimpl = config.create_hookimpl(None, "", method) # Find last non-tryfirst nonwrapper method. i = len(hookimpls) - 1 while i >= 0 and ( diff --git a/src/pluggy/_config.py b/src/pluggy/_config.py index 48ead414..64e374ea 100644 --- a/src/pluggy/_config.py +++ b/src/pluggy/_config.py @@ -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 ._impl import _HookImplFunction + from ._impl import _Plugin + from ._impl import NormalImpl + from ._impl import WrapperImpl @final @@ -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 ._impl import NormalImpl + from ._impl 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}" diff --git a/src/pluggy/_execution.py b/src/pluggy/_execution.py index 0e62b4d3..8f301879 100644 --- a/src/pluggy/_execution.py +++ b/src/pluggy/_execution.py @@ -14,7 +14,6 @@ import warnings from ._impl import HookImpl -from ._result import HookCallError from ._result import Result from ._warnings import PluggyTeardownRaisedWarning @@ -103,20 +102,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] - if hook_impl.kwargnames: - kwargs = { - argname: caller_kwargs[argname] - for argname in hook_impl.kwargnames - if argname in caller_kwargs - } or None - else: - kwargs = None - except KeyError as e: - raise HookCallError( - f"hook call must provide argument {e.args[0]!r}" - ) from e + args, kwargs = hook_impl._get_call_args(caller_kwargs) if hook_impl.hookwrapper: function_gen = run_old_style_hookwrapper( diff --git a/src/pluggy/_hooks.py b/src/pluggy/_hooks.py index 55197772..ef3a76f9 100644 --- a/src/pluggy/_hooks.py +++ b/src/pluggy/_hooks.py @@ -22,10 +22,14 @@ from ._decorators import varnames from ._impl import _HookImplFunction from ._impl import _Plugin +from ._impl import CompletionHook from ._impl import HookImpl +from ._impl import NormalImpl +from ._impl import WrapperImpl __all__ = [ + "CompletionHook", "HookCaller", "HookImpl", "HookRelay", @@ -34,6 +38,8 @@ "HookimplMarker", "HookspecConfiguration", "HookspecMarker", + "NormalImpl", + "WrapperImpl", "_HookCaller", "_HookExec", "_HookImplFunction", diff --git a/src/pluggy/_impl.py b/src/pluggy/_impl.py index 9ff7d625..5112226e 100644 --- a/src/pluggy/_impl.py +++ b/src/pluggy/_impl.py @@ -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 @@ -22,17 +27,31 @@ _HookImplFunction: TypeAlias = Callable[..., _T | Generator[None, Result[_T], None]] -@final +@runtime_checkable +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", @@ -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. @@ -60,23 +79,165 @@ 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 `. - self.wrapper: Final = hook_impl_opts.wrapper + self.wrapper: Final = hook_impl_config.wrapper #: Whether the hook implementation is an :ref:`old-style wrapper #: `. - self.hookwrapper: Final = hook_impl_opts.hookwrapper + self.hookwrapper: Final = hook_impl_config.hookwrapper #: Whether validation against a hook specification is :ref:`optional #: `. - self.optionalhook: Final = hook_impl_opts.optionalhook + self.optionalhook: Final = hook_impl_config.optionalhook #: Whether to try to order this hook implementation :ref:`first #: `. - self.tryfirst: Final = hook_impl_opts.tryfirst + self.tryfirst: Final = hook_impl_config.tryfirst #: Whether to try to order this hook implementation :ref:`last #: `. - 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] + ) -> tuple[list[object], dict[str, object] | None]: + """Extract the arguments for calling this hook implementation. + + Returns the positional arguments, and the keyword arguments for + parameters with defaults that the caller provided, or ``None`` when + there are none so the call can skip ``**`` unpacking. + + :raises HookCallError: If a required argument is missing. + """ + try: + args = [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 + if not self.kwargnames: + return args, None + kwargs = { + argname: caller_kwargs[argname] + for argname in self.kwargnames + if argname in caller_kwargs + } + return args, kwargs or None def __repr__(self) -> str: - return f"" + 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, kwargs = 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, kwargs) + elif kwargs is None: + wrapper_gen = cast(Generator[None, object, object], self.function(*args)) + else: + wrapper_gen = cast( + Generator[None, object, object], self.function(*args, **kwargs) + ) + + 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 diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index 21d740d1..b830ec0c 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -238,7 +238,9 @@ def register(self, plugin: _Plugin, name: str | None = None) -> str | None: f"{plugin!r}.{attr_name} is not a hookable attribute" ) method: _HookImplFunction[object] = found[1] - hookimpl = HookImpl(plugin, plugin_name, method, hookimpl_config) + hookimpl: 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) verified = False diff --git a/testing/test_details.py b/testing/test_details.py index 9f5ea9d4..9101fe59 100644 --- a/testing/test_details.py +++ b/testing/test_details.py @@ -246,7 +246,7 @@ def myhook(self): plugin = Plugin() pname = pm.register(plugin) assert repr(pm.hook.myhook.get_hookimpls()[0]) == ( - f"" + f"" ) diff --git a/testing/test_impl.py b/testing/test_impl.py new file mode 100644 index 00000000..a1f8523f --- /dev/null +++ b/testing/test_impl.py @@ -0,0 +1,218 @@ +""" +Tests for the HookImpl hierarchy and the CompletionHook setup API. +""" + +from __future__ import annotations + +from collections.abc import Callable +from collections.abc import Generator +from typing import Any + +import pytest + +from pluggy import HookCallError +from pluggy import HookImpl +from pluggy import HookimplConfiguration +from pluggy import HookimplMarker +from pluggy import HookspecMarker +from pluggy import NormalImpl +from pluggy import PluginManager +from pluggy import WrapperImpl +from pluggy._impl import CompletionHook + + +hookspec = HookspecMarker("example") +hookimpl = HookimplMarker("example") + + +def func(arg: object) -> object: + return arg + + +def wrapper_func(arg: object) -> Generator[None, object, object]: + return (yield) + + +class TestCreateHookimpl: + def test_normal_config_returns_normal_impl(self) -> None: + config = HookimplConfiguration() + impl = config.create_hookimpl(None, "test", func) + assert type(impl) is NormalImpl + assert isinstance(impl, HookImpl) + assert impl.hookimpl_config is config + + def test_wrapper_config_returns_wrapper_impl(self) -> None: + config = HookimplConfiguration(wrapper=True) + impl = config.create_hookimpl(None, "test", wrapper_func) + assert type(impl) is WrapperImpl + + def test_hookwrapper_config_returns_wrapper_impl(self) -> None: + config = HookimplConfiguration(hookwrapper=True) + impl = config.create_hookimpl(None, "test", wrapper_func) + assert type(impl) is WrapperImpl + + def test_normal_impl_rejects_wrapper_config(self) -> None: + config = HookimplConfiguration(wrapper=True) + with pytest.raises(ValueError, match="Use WrapperImpl"): + NormalImpl(None, "test", wrapper_func, config) + + def test_wrapper_impl_rejects_normal_config(self) -> None: + config = HookimplConfiguration() + with pytest.raises(ValueError, match="Use NormalImpl"): + WrapperImpl(None, "test", func, config) + + def test_opts_alias(self) -> None: + config = HookimplConfiguration(tryfirst=True) + impl = config.create_hookimpl(None, "test", func) + assert impl.opts is config + + +class TestGetCallArgs: + def test_binds_in_argname_order(self) -> None: + def f(b: object, a: object) -> None: + pass + + impl = HookimplConfiguration().create_hookimpl(None, "test", f) + assert impl._get_call_args({"a": 1, "b": 2, "extra": 3}) == ([2, 1], None) + + def test_missing_argument_raises_hook_call_error(self) -> None: + impl = HookimplConfiguration().create_hookimpl(None, "test", func) + with pytest.raises(HookCallError, match="must provide argument 'arg'"): + impl._get_call_args({}) + + +def make_wrapper_impl( + function: Callable[..., Any], *, hookwrapper: bool = False +) -> WrapperImpl: + config = HookimplConfiguration(wrapper=not hookwrapper, hookwrapper=hookwrapper) + impl = config.create_hookimpl(None, "test", function) + assert isinstance(impl, WrapperImpl) + return impl + + +class TestSetupAndGetCompletionHook: + def test_returns_completion_hook_protocol_instance(self) -> None: + impl = make_wrapper_impl(wrapper_func) + completion = impl.setup_and_get_completion_hook("myhook", {"arg": 1}) + assert isinstance(completion, CompletionHook) + assert completion(21, None) == (21, None) + + def test_setup_runs_code_before_yield(self) -> None: + events: list[str] = [] + + def wrapper() -> Generator[None, object, object]: + events.append("setup") + res = yield + events.append("teardown") + return res + + impl = make_wrapper_impl(wrapper) + completion = impl.setup_and_get_completion_hook("myhook", {}) + assert events == ["setup"] + assert completion("x", None) == ("x", None) + assert events == ["setup", "teardown"] + + def test_completion_replaces_result(self) -> None: + def wrapper() -> Generator[None, object, object]: + res = yield + assert isinstance(res, int) + return res + 1 + + impl = make_wrapper_impl(wrapper) + completion = impl.setup_and_get_completion_hook("myhook", {}) + assert completion(41, None) == (42, None) + + def test_completion_can_swallow_exception(self) -> None: + def wrapper() -> Generator[None, object, object]: + try: + yield + except ValueError: + return "fallback" + raise AssertionError("unreachable") + + impl = make_wrapper_impl(wrapper) + completion = impl.setup_and_get_completion_hook("myhook", {}) + result, exception = completion(None, ValueError("boom")) + assert result == "fallback" + assert exception is None + + def test_completion_passes_through_unhandled_exception(self) -> None: + impl = make_wrapper_impl(wrapper_func) + completion = impl.setup_and_get_completion_hook("myhook", {"arg": 1}) + exc = ValueError("boom") + result, exception = completion("kept", exc) + assert result == "kept" + assert exception is exc + + def test_completion_can_raise_new_exception(self) -> None: + def wrapper() -> Generator[None, object, object]: + yield + raise RuntimeError("replaced") + + impl = make_wrapper_impl(wrapper) + completion = impl.setup_and_get_completion_hook("myhook", {}) + result, exception = completion("x", None) + assert result == "x" + assert isinstance(exception, RuntimeError) + assert str(exception) == "replaced" + + def test_did_not_yield_raises(self) -> None: + def no_yield() -> Generator[None, object, object]: + return "nope" + yield # type: ignore[unreachable] # pragma: no cover + + impl = make_wrapper_impl(no_yield) + with pytest.raises(RuntimeError, match="did not yield"): + impl.setup_and_get_completion_hook("myhook", {}) + + def test_second_yield_raises(self) -> None: + def two_yields() -> Generator[None, object, None]: + yield + yield + + impl = make_wrapper_impl(two_yields) + completion = impl.setup_and_get_completion_hook("myhook", {}) + result, exception = completion("x", None) + assert result == "x" + assert isinstance(exception, RuntimeError) + assert "has second yield" in str(exception) + + def test_old_style_hookwrapper_receives_result_object(self) -> None: + seen: list[object] = [] + + def old_style(arg: object) -> Generator[None, object, None]: + outcome = yield + seen.append(outcome) + outcome.force_result(f"forced: {arg}") # type: ignore[attr-defined] + + impl = make_wrapper_impl(old_style, hookwrapper=True) + completion = impl.setup_and_get_completion_hook("myhook", {"arg": "a"}) + result, exception = completion("orig", None) + assert result == "forced: a" + assert exception is None + assert seen and seen[0].__class__.__name__ == "Result" + + +class TestRegistrationCreatesSubclasses: + def test_registered_impl_types(self) -> None: + class Spec: + @hookspec + def myhook(self, arg: object) -> None: + pass + + class Plugin: + @hookimpl + def myhook(self, arg: object) -> object: + return arg + + @hookimpl(wrapper=True) + def myhook_wrapper(self, arg: object) -> Generator[None, object, object]: + return (yield) + + pm = PluginManager("example") + pm.add_hookspecs(Spec) + pm.register(Plugin()) + impls = {type(impl).__name__ for impl in pm.hook.myhook.get_hookimpls()} + assert impls == {"NormalImpl"} + wrapper_impls = pm.hook.myhook_wrapper.get_hookimpls() + assert {type(impl).__name__ for impl in wrapper_impls} == {"WrapperImpl"} From 696c2810a03f7af71d70144987c46a079ff46c1b Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 24 Jul 2026 15:11:47 +0200 Subject: [PATCH 05/10] refactor(caller): Protocol HookCaller, split callers, CompletionHook multicall Complete design step 05: - HookCaller is now a @runtime_checkable Protocol; concrete callers are NormalHookCaller (split list[NormalImpl] / list[WrapperImpl] storage), HistoricHookCaller (memorize/replay, rejects wrappers) and SubsetHookCaller (read-only filtered proxy). _HookCaller and _SubsetHookCaller remain as compat aliases. - _multicall takes dual sequences and orchestrates phases only: wrapper setup collects CompletionHooks, normals run, completion hooks run LIFO and may replace (result, exception) - no wrapper flag branching. - add_hookspecs hands a NormalHookCaller over to a HistoricHookCaller when a historic spec arrives after impl registration. - PluginManager._hookexec and tracing use the dual-sequence signature; monitoring callbacks keep receiving one combined impl list. - HookSpec.verify_all_args_are_provided replaces the caller-side helper; set_specification accepts a config object or legacy mapping (shim). - New tests: protocol isinstance for all concretes, historic handover, historic direct-call/call_extra rejection. Co-Authored-By: Claude Fable 5 --- changelog/708.feature.rst | 12 + docs/api_reference.rst | 11 + src/pluggy/__init__.py | 6 + src/pluggy/_caller.py | 568 +++++++++++++++++++++++++--------- src/pluggy/_decorators.py | 29 ++ src/pluggy/_execution.py | 127 +++----- src/pluggy/_hooks.py | 6 + src/pluggy/_manager.py | 70 ++++- testing/benchmark.py | 26 +- testing/test_hookcaller.py | 124 ++++++-- testing/test_multicall.py | 17 +- testing/test_pluginmanager.py | 2 +- 12 files changed, 718 insertions(+), 280 deletions(-) create mode 100644 changelog/708.feature.rst diff --git a/changelog/708.feature.rst b/changelog/708.feature.rst new file mode 100644 index 00000000..7e0e6608 --- /dev/null +++ b/changelog/708.feature.rst @@ -0,0 +1,12 @@ +:class:`pluggy.HookCaller` is now a runtime-checkable +:class:`~typing.Protocol` implemented by the new concrete callers +:class:`pluggy.NormalHookCaller` (split normal/wrapper implementation +lists), :class:`pluggy.HistoricHookCaller` (call memorization and replay, +no wrappers) and :class:`pluggy.SubsetHookCaller`. +``isinstance(caller, HookCaller)`` keeps working for all of them. + +The hook execution engine now runs a dual-sequence multicall: wrappers own +their setup/teardown through ``CompletionHook`` callbacks which run LIFO +after the normal implementations, removing all wrapper flag branching from +the hot loop. Hook call monitoring callbacks still receive a single +combined implementation list. diff --git a/docs/api_reference.rst b/docs/api_reference.rst index a62272f7..c4ce49ae 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -29,6 +29,17 @@ API Reference :members: :special-members: __call__ +.. autoclass:: pluggy.NormalHookCaller() + :members: + :special-members: __call__ + +.. autoclass:: pluggy.HistoricHookCaller() + :members: + :special-members: __call__ + +.. autoclass:: pluggy.SubsetHookCaller() + :members: + .. autoclass:: pluggy.HookCallError() :show-inheritance: :members: diff --git a/src/pluggy/__init__.py b/src/pluggy/__init__.py index d562a1b9..d3858dfa 100644 --- a/src/pluggy/__init__.py +++ b/src/pluggy/__init__.py @@ -1,4 +1,5 @@ __all__ = [ + "HistoricHookCaller", "HookCallError", "HookCaller", "HookImpl", @@ -9,23 +10,28 @@ "HookspecConfiguration", "HookspecMarker", "HookspecOpts", + "NormalHookCaller", "NormalImpl", "PluggyTeardownRaisedWarning", "PluggyWarning", "PluginManager", "PluginValidationError", "Result", + "SubsetHookCaller", "WrapperImpl", "__version__", ] from ._config import HookimplConfiguration from ._config import HookspecConfiguration +from ._hooks import HistoricHookCaller from ._hooks import HookCaller from ._hooks import HookImpl from ._hooks import HookimplMarker from ._hooks import HookRelay from ._hooks import HookspecMarker +from ._hooks import NormalHookCaller from ._hooks import NormalImpl +from ._hooks import SubsetHookCaller from ._hooks import WrapperImpl from ._manager import PluginManager from ._manager import PluginValidationError diff --git a/src/pluggy/_caller.py b/src/pluggy/_caller.py index 53d3f903..edb203e1 100644 --- a/src/pluggy/_caller.py +++ b/src/pluggy/_caller.py @@ -6,28 +6,124 @@ from collections.abc import Callable from collections.abc import Mapping +from collections.abc import MutableSequence from collections.abc import Sequence from collections.abc import Set as AbstractSet from typing import Any +from typing import cast from typing import Final from typing import final +from typing import Protocol +from typing import runtime_checkable from typing import TYPE_CHECKING from typing import TypeAlias -import warnings +from typing import TypeVar from ._config import HookimplConfiguration +from ._config import hookspec_config_from_mapping from ._config import HookspecConfiguration from ._decorators import _Namespace from ._decorators import HookSpec from ._impl import _Plugin from ._impl import HookImpl +from ._impl import NormalImpl +from ._impl import WrapperImpl _HookExec: TypeAlias = Callable[ - [str, Sequence[HookImpl], Mapping[str, object], bool], - object | list[object], + [str, Sequence[NormalImpl], Sequence[WrapperImpl], Mapping[str, object], bool], + "object | list[object]", ] +_T_HookImpl = TypeVar("_T_HookImpl", bound=HookImpl) + + +def _insert_hookimpl_into_list( + hookimpl: _T_HookImpl, target_list: MutableSequence[_T_HookImpl] +) -> None: + """Insert a hookimpl into the target list maintaining proper ordering. + + The ordering is: [trylast, normal, tryfirst]. + """ + if hookimpl.trylast: + target_list.insert(0, hookimpl) + elif hookimpl.tryfirst: + target_list.append(hookimpl) + else: + # find last non-tryfirst method + i = len(target_list) - 1 + while i >= 0 and target_list[i].tryfirst: + i -= 1 + target_list.insert(i + 1, hookimpl) + + +def _coerce_spec_config( + spec_config: HookspecConfiguration | Mapping[str, Any], +) -> HookspecConfiguration: + """Accept a configuration object or a legacy mapping (pytest shim).""" + if isinstance(spec_config, HookspecConfiguration): + return spec_config + return hookspec_config_from_mapping(spec_config) + + +@runtime_checkable +class HookCaller(Protocol): + """Protocol defining the interface for hook callers. + + .. versionchanged:: 1.7 + ``HookCaller`` is now a :class:`~typing.Protocol` (runtime checkable). + The concrete implementations are :class:`NormalHookCaller`, + :class:`HistoricHookCaller` and ``SubsetHookCaller``. + """ + + @property + def name(self) -> str: + """Name of the hook getting called.""" + ... + + @property + def spec(self) -> HookSpec | None: + """The hook specification, if any.""" + ... + + def has_spec(self) -> bool: + """Whether this caller has a hook specification.""" + ... + + def is_historic(self) -> bool: + """Whether this caller is :ref:`historic `.""" + ... + + def get_hookimpls(self) -> list[HookImpl]: + """Get all registered hook implementations for this hook.""" + ... + + def set_specification( + self, + specmodule_or_class: _Namespace, + spec_config: HookspecConfiguration | Mapping[str, Any], + ) -> None: + """Set the hook specification.""" + ... + + def __call__(self, **kwargs: object) -> Any: + """Call the hook with given kwargs.""" + ... + + def call_historic( + self, + result_callback: Callable[[Any], None] | None = None, + kwargs: Mapping[str, object] | None = None, + ) -> None: + """Call the hook historically.""" + ... + + def call_extra( + self, methods: Sequence[Callable[..., object]], kwargs: Mapping[str, object] + ) -> Any: + """Call the hook with additional methods.""" + ... + @final class HookRelay: @@ -41,7 +137,7 @@ def __init__(self) -> None: if TYPE_CHECKING: - def __getattr__(self, name: str) -> HookCaller: ... + def __getattr__(self, name: str) -> NormalHookCaller | HistoricHookCaller: ... # Historical name (pluggy<=1.2), kept for backward compatibility. @@ -53,13 +149,13 @@ def __getattr__(self, name: str) -> HookCaller: ... ] -class HookCaller: +class NormalHookCaller: """A caller of all registered implementations of a hook specification.""" __slots__ = ( - "_call_history", "_hookexec", - "_hookimpls", + "_normal_hookimpls", + "_wrapper_hookimpls", "name", "spec", ) @@ -69,26 +165,22 @@ def __init__( name: str, hook_execute: _HookExec, specmodule_or_class: _Namespace | None = None, - spec_opts: HookspecConfiguration | None = None, + spec_config: HookspecConfiguration | None = None, ) -> None: """:meta private:""" #: 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 + # Split hook implementations into two lists for simpler management: + # Normal hooks: [trylast, normal, tryfirst] + # Wrapper hooks: [trylast, normal, tryfirst] + self._normal_hookimpls: Final[list[NormalImpl]] = [] + self._wrapper_hookimpls: Final[list[WrapperImpl]] = [] # TODO: Document, or make private. self.spec: HookSpec | None = None if specmodule_or_class is not None: - assert spec_opts is not None - self.set_specification(specmodule_or_class, spec_opts) + assert spec_config is not None + self.set_specification(specmodule_or_class, spec_config) # TODO: Document, or make private. def has_spec(self) -> bool: @@ -98,85 +190,56 @@ def has_spec(self) -> bool: def set_specification( self, specmodule_or_class: _Namespace, - spec_opts: HookspecConfiguration, + spec_config: HookspecConfiguration | Mapping[str, Any], ) -> None: if self.spec is not None: raise ValueError( f"Hook {self.spec.name!r} is already registered " f"within namespace {self.spec.namespace}" ) - self.spec = HookSpec(specmodule_or_class, self.name, spec_opts) - if spec_opts.historic: - self._call_history = [] + config = _coerce_spec_config(spec_config) + if config.historic: + raise ValueError( + f"NormalHookCaller cannot handle historic hooks. " + f"Use HistoricHookCaller for {self.name!r}" + ) + self.spec = HookSpec(specmodule_or_class, self.name, config) def is_historic(self) -> bool: """Whether this caller is :ref:`historic `.""" - return self._call_history is not None + return False def _remove_plugin(self, plugin: _Plugin) -> None: """Remove all hook implementations registered by the given plugin.""" - remaining = [impl for impl in self._hookimpls if impl.plugin != plugin] - if len(remaining) == len(self._hookimpls): + remaining_normal = [i for i in self._normal_hookimpls if i.plugin != plugin] + remaining_wrapper = [i for i in self._wrapper_hookimpls if i.plugin != plugin] + if len(remaining_normal) == len(self._normal_hookimpls) and len( + remaining_wrapper + ) == len(self._wrapper_hookimpls): raise ValueError(f"plugin {plugin!r} not found") - self._hookimpls[:] = remaining + self._normal_hookimpls[:] = remaining_normal + self._wrapper_hookimpls[:] = remaining_wrapper def get_hookimpls(self) -> list[HookImpl]: - """Get all registered hook implementations for this hook.""" - return self._hookimpls.copy() + """Get all registered hook implementations for this hook. + + Normal implementations come first, then wrappers (matching the + historical combined-list ordering). + """ + return [*self._normal_hookimpls, *self._wrapper_hookimpls] def _add_hookimpl(self, hookimpl: HookImpl) -> None: """Add an implementation to the callback chain.""" - for i, method in enumerate(self._hookimpls): - if method.hookwrapper or method.wrapper: - splitpoint = i - break - else: - splitpoint = len(self._hookimpls) - if hookimpl.hookwrapper or hookimpl.wrapper: - start, end = splitpoint, len(self._hookimpls) - else: - start, end = 0, splitpoint - - if hookimpl.trylast: - self._hookimpls.insert(start, hookimpl) - elif hookimpl.tryfirst: - self._hookimpls.insert(end, hookimpl) + if isinstance(hookimpl, WrapperImpl): + _insert_hookimpl_into_list(hookimpl, self._wrapper_hookimpls) else: - # find last non-tryfirst method - i = end - 1 - while i >= start and self._hookimpls[i].tryfirst: - i -= 1 - self._hookimpls.insert(i + 1, hookimpl) + assert isinstance(hookimpl, NormalImpl), ( + "normal hook implementations must be NormalImpl instances" + ) + _insert_hookimpl_into_list(hookimpl, self._normal_hookimpls) def __repr__(self) -> str: - return f"" - - def _apply_defaults(self, kwargs: Mapping[str, object]) -> Mapping[str, object]: - if self.spec is None or not self.spec.kwargdefaults: - return kwargs - return {**self.spec.kwargdefaults, **kwargs} - - def _verify_all_args_are_provided(self, kwargs: Mapping[str, object]) -> None: - # This is written to avoid expensive operations when not needed. - if self.spec: - for argname in self.spec.argnames: - if argname not in kwargs: - notincall = ", ".join( - repr(argname) - for argname in self.spec.argnames - # Avoid self.spec.argnames - kwargs.keys() - # it doesn't preserve order. - if argname not in kwargs - ) - warnings.warn( - f"Argument(s) {notincall} which are declared in the hookspec " - "cannot be found in this hook call", - # 3, not 2: the warning is raised in this helper, which - # is called by __call__/call_historic/call_extra, which - # are called by the code making the hook call. - stacklevel=3, - ) - break + return f"" def __call__(self, **kwargs: object) -> Any: """Call the hook. @@ -187,15 +250,144 @@ def __call__(self, **kwargs: object) -> Any: Returns the result(s) of calling all registered plugins, see :ref:`calling`. """ - assert not self.is_historic(), ( - "Cannot directly call a historic hook - use call_historic instead." - ) - call_kwargs = self._apply_defaults(kwargs) - self._verify_all_args_are_provided(call_kwargs) + call_kwargs: Mapping[str, object] = kwargs + if self.spec: + call_kwargs = self.spec.apply_defaults(kwargs) + self.spec.verify_all_args_are_provided(call_kwargs) firstresult = self.spec.config.firstresult if self.spec else False # Copy because plugins may register other plugins during iteration (#438). return self._hookexec( - self.name, self._hookimpls.copy(), call_kwargs, firstresult + self.name, + self._normal_hookimpls.copy(), + self._wrapper_hookimpls.copy(), + call_kwargs, + firstresult, + ) + + def call_historic( + self, + result_callback: Callable[[Any], None] | None = None, + kwargs: Mapping[str, object] | None = None, + ) -> None: + """Historic calls are only supported by historic hooks.""" + raise AssertionError( + f"Hook {self.name!r} is not historic - cannot call call_historic" + ) + + def call_extra( + self, methods: Sequence[Callable[..., object]], kwargs: Mapping[str, object] + ) -> Any: + """Call the hook with some additional temporarily participating + methods using the specified ``kwargs`` as call parameters, see + :ref:`call_extra`.""" + if self.spec: + kwargs = self.spec.apply_defaults(kwargs) + self.spec.verify_all_args_are_provided(kwargs) + config = HookimplConfiguration() + normal_hookimpls = self._normal_hookimpls.copy() + for method in methods: + hookimpl = config.create_hookimpl(None, "", method) + # call_extra only supports normal implementations. + assert isinstance(hookimpl, NormalImpl) + _insert_hookimpl_into_list(hookimpl, normal_hookimpls) + firstresult = self.spec.config.firstresult if self.spec else False + return self._hookexec( + self.name, + normal_hookimpls, + self._wrapper_hookimpls.copy(), + kwargs, + firstresult, + ) + + def _maybe_apply_history(self, method: HookImpl) -> None: + """Nothing to do - normal hooks have no call history.""" + + +# Historical name (pluggy<=1.2), kept for backward compatibility. +_HookCaller = NormalHookCaller + + +class HistoricHookCaller: + """A caller for historic hook specifications that memorizes and replays + calls. + + Historic hooks memorize every call and replay them on plugins registered + after the call was made. Historic hooks do not support wrappers. + """ + + __slots__ = ( + "_call_history", + "_hookexec", + "_hookimpls", + "name", + "spec", + ) + + spec: HookSpec + + def __init__( + self, + name: str, + hook_execute: _HookExec, + specmodule_or_class: _Namespace, + spec_config: HookspecConfiguration, + ) -> None: + """:meta private:""" + assert spec_config.historic, "HistoricHookCaller requires historic=True" + #: Name of the hook getting called. + self.name: Final = name + self._hookexec: Final = hook_execute + # The hookimpls list for historic hooks (no wrappers supported). + self._hookimpls: Final[list[NormalImpl]] = [] + self._call_history: Final[_CallHistory] = [] + # TODO: Document, or make private. + self.spec = HookSpec(specmodule_or_class, name, spec_config) + + def has_spec(self) -> bool: + return True + + def set_specification( + self, + specmodule_or_class: _Namespace, + spec_config: HookspecConfiguration | Mapping[str, Any], + ) -> None: + """Historic hooks cannot have their specification changed.""" + raise ValueError( + f"Hook {self.spec.name!r} is already registered " + f"within namespace {self.spec.namespace}" + ) + + def is_historic(self) -> bool: + """Whether this caller is :ref:`historic `.""" + return True + + def _remove_plugin(self, plugin: _Plugin) -> None: + remaining = [impl for impl in self._hookimpls if impl.plugin != plugin] + if len(remaining) == len(self._hookimpls): + raise ValueError(f"plugin {plugin!r} not found") + self._hookimpls[:] = remaining + + def get_hookimpls(self) -> list[HookImpl]: + """Get all registered hook implementations for this hook.""" + return list(self._hookimpls) + + def _add_hookimpl(self, hookimpl: HookImpl) -> None: + """Add an implementation to the callback chain.""" + assert isinstance(hookimpl, NormalImpl), ( + "historic hooks do not support wrappers" + ) + _insert_hookimpl_into_list(hookimpl, self._hookimpls) + + def __repr__(self) -> str: + return f"" + + def __call__(self, **kwargs: object) -> Any: + """Historic hooks cannot be called directly. + + Use :meth:`call_historic` instead. + """ + raise AssertionError( + "Cannot directly call a historic hook - use call_historic instead." ) def call_historic( @@ -211,15 +403,14 @@ def call_historic( If provided, will be called for each non-``None`` result obtained from a hook implementation. """ - assert self._call_history is not None kwargs = kwargs or {} - kwargs = self._apply_defaults(kwargs) - self._verify_all_args_are_provided(kwargs) + kwargs = self.spec.apply_defaults(kwargs) + self.spec.verify_all_args_are_provided(kwargs) self._call_history.append((kwargs, result_callback)) # Historizing hooks don't return results. # Remember firstresult isn't compatible with historic. # Copy because plugins may register other plugins during iteration (#438). - res = self._hookexec(self.name, self._hookimpls.copy(), kwargs, False) + res = self._hookexec(self.name, self._hookimpls.copy(), [], kwargs, False) if result_callback is None: return if isinstance(res, list): @@ -229,87 +420,168 @@ def call_historic( def call_extra( self, methods: Sequence[Callable[..., object]], kwargs: Mapping[str, object] ) -> Any: - """Call the hook with some additional temporarily participating - methods using the specified ``kwargs`` as call parameters, see - :ref:`call_extra`.""" - assert not self.is_historic(), ( + """Historic hooks do not support call_extra.""" + raise AssertionError( "Cannot directly call a historic hook - use call_historic instead." ) - kwargs = self._apply_defaults(kwargs) - self._verify_all_args_are_provided(kwargs) - config = HookimplConfiguration() - hookimpls = self._hookimpls.copy() - for method in methods: - hookimpl = config.create_hookimpl(None, "", method) - # Find last non-tryfirst nonwrapper method. - i = len(hookimpls) - 1 - 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) - firstresult = self.spec.config.firstresult if self.spec else False - return self._hookexec(self.name, hookimpls, kwargs, firstresult) def _maybe_apply_history(self, method: HookImpl) -> None: - """Apply call history to a new hookimpl if it is marked as historic.""" - if self.is_historic(): - assert self._call_history is not None - for kwargs, result_callback in self._call_history: - res = self._hookexec(self.name, [method], kwargs, False) - if res and result_callback is not None: - # XXX: remember firstresult isn't compat with historic - assert isinstance(res, list) - result_callback(res[0]) - - -# Historical name (pluggy<=1.2), kept for backward compatibility. -_HookCaller = HookCaller - - -class _SubsetHookCaller(HookCaller): - """A proxy to another HookCaller which manages calls to all registered + """Apply call history to a new hookimpl.""" + assert isinstance(method, NormalImpl) + for kwargs, result_callback in self._call_history: + res = self._hookexec(self.name, [method], [], kwargs, False) + if res and result_callback is not None: + # XXX: remember firstresult isn't compat with historic + assert isinstance(res, list) + result_callback(res[0]) + + +class SubsetHookCaller: + """A proxy to another hook caller which manages calls to all registered plugins except the ones from remove_plugins.""" - # This class is unusual: in inhertits from `HookCaller` so all of - # the *code* runs in the class, but it delegates all underlying *data* - # to the original HookCaller. # `subset_hook_caller` used to be implemented by creating a full-fledged # HookCaller, copying all hookimpls from the original. This had problems # with memory leaks (#346) and historic calls (#347), which make a proxy # approach better. - # An alternative implementation is to use a `_getattr__`/`__getattribute__` - # proxy, however that adds more overhead and is more tricky to implement. __slots__ = ( "_orig", "_remove_plugins", ) - def __init__(self, orig: HookCaller, remove_plugins: AbstractSet[_Plugin]) -> None: - self._orig = orig - self._remove_plugins = remove_plugins - self.name = orig.name # type: ignore[misc] - self._hookexec = orig._hookexec # type: ignore[misc] + def __init__( + self, + orig: NormalHookCaller | HistoricHookCaller, + remove_plugins: AbstractSet[_Plugin], + ) -> None: + """:meta private:""" + self._orig: Final = orig + self._remove_plugins: Final = remove_plugins - @property # type: ignore[misc] - def _hookimpls(self) -> list[HookImpl]: - return [ - impl - for impl in self._orig._hookimpls - if impl.plugin not in self._remove_plugins - ] + @property + def name(self) -> str: + return self._orig.name @property - def spec(self) -> HookSpec | None: # type: ignore[override] + def spec(self) -> HookSpec | None: return self._orig.spec - @property - def _call_history(self) -> _CallHistory | None: # type: ignore[override] - return self._orig._call_history + def has_spec(self) -> bool: + return self._orig.has_spec() + + def is_historic(self) -> bool: + return self._orig.is_historic() + + def _get_filtered(self, hookimpls: Sequence[_T_HookImpl]) -> list[_T_HookImpl]: + """Filter out hook implementations from removed plugins.""" + return [impl for impl in hookimpls if impl.plugin not in self._remove_plugins] + + def get_hookimpls(self) -> list[HookImpl]: + """Get filtered hook implementations for this hook.""" + return self._get_filtered(self._orig.get_hookimpls()) + + def set_specification( + self, + specmodule_or_class: _Namespace, + spec_config: HookspecConfiguration | Mapping[str, Any], + ) -> None: + """SubsetHookCaller is a read-only proxy - specs cannot be set.""" + raise RuntimeError( + f"Cannot set specification on SubsetHookCaller {self.name!r} - " + "it is a read-only proxy to another hook caller" + ) + + def __call__(self, **kwargs: object) -> Any: + """Call the hook with filtered implementations.""" + if self.is_historic(): + raise AssertionError( + "Cannot directly call a historic hook - use call_historic instead." + ) + orig = self._orig + assert isinstance(orig, NormalHookCaller) + call_kwargs: Mapping[str, object] = kwargs + if orig.spec: + call_kwargs = orig.spec.apply_defaults(kwargs) + orig.spec.verify_all_args_are_provided(call_kwargs) + firstresult = orig.spec.config.firstresult if orig.spec else False + return orig._hookexec( + self.name, + self._get_filtered(orig._normal_hookimpls), + self._get_filtered(orig._wrapper_hookimpls), + call_kwargs, + firstresult, + ) + + def call_historic( + self, + result_callback: Callable[[Any], None] | None = None, + kwargs: Mapping[str, object] | None = None, + ) -> None: + """Call the hook with given ``kwargs`` for all registered plugins and + for all plugins which will be registered afterwards, see + :ref:`historic`. + """ + orig = self._orig + assert isinstance(orig, HistoricHookCaller), ( + f"Hook {self.name!r} is not historic - cannot call call_historic" + ) + kwargs = kwargs or {} + kwargs = orig.spec.apply_defaults(kwargs) + orig.spec.verify_all_args_are_provided(kwargs) + # History is shared with the original caller. + orig._call_history.append((kwargs, result_callback)) + res = orig._hookexec( + self.name, self._get_filtered(orig._hookimpls), [], kwargs, False + ) + if result_callback is None: + return + if isinstance(res, list): + for x in res: + result_callback(x) + + def call_extra( + self, methods: Sequence[Callable[..., object]], kwargs: Mapping[str, object] + ) -> Any: + """Call the hook with some additional temporarily participating + methods using the specified ``kwargs`` as call parameters, see + :ref:`call_extra`.""" + orig = self._orig + if self.is_historic(): + raise AssertionError( + "Cannot directly call a historic hook - use call_historic instead." + ) + assert isinstance(orig, NormalHookCaller) + if orig.spec: + kwargs = orig.spec.apply_defaults(kwargs) + orig.spec.verify_all_args_are_provided(kwargs) + config = HookimplConfiguration() + normal_impls = self._get_filtered(orig._normal_hookimpls) + for method in methods: + hookimpl = config.create_hookimpl(None, "", method) + assert isinstance(hookimpl, NormalImpl) + _insert_hookimpl_into_list(hookimpl, normal_impls) + firstresult = orig.spec.config.firstresult if orig.spec else False + return orig._hookexec( + self.name, + normal_impls, + self._get_filtered(orig._wrapper_hookimpls), + kwargs, + firstresult, + ) def __repr__(self) -> str: - return f"<_SubsetHookCaller {self.name!r}>" + return f"" + + +# Historical name, kept for backward compatibility. +_SubsetHookCaller = SubsetHookCaller + + +if TYPE_CHECKING: + # Verify the concrete callers satisfy the HookCaller protocol. + _: list[HookCaller] = [ + cast(NormalHookCaller, None), + cast(HistoricHookCaller, None), + cast(SubsetHookCaller, None), + ] diff --git a/src/pluggy/_decorators.py b/src/pluggy/_decorators.py index 3e81ef16..1f16bed3 100644 --- a/src/pluggy/_decorators.py +++ b/src/pluggy/_decorators.py @@ -363,3 +363,32 @@ def opts(self) -> HookspecConfiguration: Use :attr:`config` instead. """ return self.config + + def apply_defaults(self, kwargs: Mapping[str, object]) -> Mapping[str, object]: + """Fill in hookspec argument defaults the call did not provide.""" + if not self.kwargdefaults: + return kwargs + return {**self.kwargdefaults, **kwargs} + + def verify_all_args_are_provided(self, kwargs: Mapping[str, object]) -> None: + """Warn if a hook call does not provide all declared arguments.""" + # This is written to avoid expensive operations when not needed. + for argname in self.argnames: + if argname not in kwargs: + notincall = ", ".join( + repr(argname) + for argname in self.argnames + # Avoid self.argnames - kwargs.keys() + # it doesn't preserve order. + if argname not in kwargs + ) + warnings.warn( + f"Argument(s) {notincall} which are declared in the hookspec " + "cannot be found in this hook call", + # 3, not 2: the warning is raised here, in the spec, which + # every caller invokes directly from __call__/ + # call_historic/call_extra, which the calling code invokes. + # Adding a hop between those two breaks this. + stacklevel=3, + ) + break diff --git a/src/pluggy/_execution.py b/src/pluggy/_execution.py index 8f301879..ef7fd3a4 100644 --- a/src/pluggy/_execution.py +++ b/src/pluggy/_execution.py @@ -13,7 +13,9 @@ from typing import TypeAlias import warnings -from ._impl import HookImpl +from ._impl import CompletionHook +from ._impl import NormalImpl +from ._impl import WrapperImpl from ._result import Result from ._warnings import PluggyTeardownRaisedWarning @@ -24,7 +26,7 @@ def run_old_style_hookwrapper( - hook_impl: HookImpl, + hook_impl: WrapperImpl, hook_name: str, args: Sequence[object], kwargs: Mapping[str, object] | None, @@ -74,7 +76,7 @@ def _raise_wrapfail( def _warn_teardown_exception( - hook_name: str, hook_impl: HookImpl, e: BaseException + hook_name: str, hook_impl: WrapperImpl, e: BaseException ) -> None: msg = ( f"A plugin raised an exception during an old-style hookwrapper teardown.\n" @@ -82,12 +84,13 @@ def _warn_teardown_exception( f"{type(e).__name__}: {e}\n" f"For more information see https://pluggy.readthedocs.io/en/stable/api_reference.html#pluggy.PluggyTeardownRaisedWarning" ) - warnings.warn(PluggyTeardownRaisedWarning(msg), stacklevel=6) + warnings.warn(PluggyTeardownRaisedWarning(msg), stacklevel=7) def _multicall( hook_name: str, - hook_impls: Sequence[HookImpl], + normal_impls: Sequence[NormalImpl], + wrapper_impls: Sequence[WrapperImpl], caller_kwargs: Mapping[str, object], firstresult: bool, ) -> object | list[object]: @@ -95,87 +98,51 @@ def _multicall( result(s). ``caller_kwargs`` comes from HookCaller.__call__(). + + Wrappers own their setup/teardown via + :meth:`~pluggy._impl.WrapperImpl.setup_and_get_completion_hook`; + this function only orchestrates the phases: + + 1. Set up wrappers, collecting their completion hooks. + 2. Run normal implementations. + 3. Run completion hooks LIFO, each may replace ``(result, exception)``. + 4. Raise or return. """ __tracebackhide__ = True results: list[object] = [] - exception = None - teardowns: list[Teardown] = [] - try: # run impl and wrapper setup functions in a loop - for hook_impl in reversed(hook_impls): - args, kwargs = hook_impl._get_call_args(caller_kwargs) - - if hook_impl.hookwrapper: - function_gen = run_old_style_hookwrapper( - hook_impl, hook_name, args, kwargs - ) - - next(function_gen) # first yield - teardowns.append(function_gen) - - elif hook_impl.wrapper: - if kwargs is None: - res = hook_impl.function(*args) - else: - res = hook_impl.function(*args, **kwargs) - # If this cast is not valid, a type error is raised below, - # which is the desired response. - if TYPE_CHECKING: - function_gen = cast(Generator[None, object, object], res) - else: - function_gen = res - try: - next(function_gen) # first yield - except StopIteration: - _raise_wrapfail(function_gen, "did not yield") - teardowns.append(function_gen) + exception: BaseException | None = None + completion_hooks: list[CompletionHook] = [] + try: + # Set up all wrappers and collect their completion hooks. + for wrapper_impl in reversed(wrapper_impls): + completion_hooks.append( + wrapper_impl.setup_and_get_completion_hook(hook_name, caller_kwargs) + ) + + # Run normal implementations. + for normal_impl in reversed(normal_impls): + args, kwargs = normal_impl._get_call_args(caller_kwargs) + if kwargs is None: + res = normal_impl.function(*args) else: - if kwargs is None: - res = hook_impl.function(*args) - else: - res = hook_impl.function(*args, **kwargs) - if res is not None: - results.append(res) - if firstresult: # halt further impl calls - break + res = normal_impl.function(*args, **kwargs) + if res is not None: + results.append(res) + if firstresult: # halt further impl calls + break except BaseException as exc: exception = exc - finally: - if firstresult: # first result hooks return a single value - result = results[0] if results else None - else: - result = results - - # run all wrapper post-yield blocks - for teardown in reversed(teardowns): - try: - if exception is not None: - try: - teardown.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 - ): - teardown.close() - continue - else: - raise - else: - teardown.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. - teardown.close() - except StopIteration as si: - result = si.value - exception = None - continue - except BaseException as e: - exception = e - continue - _raise_wrapfail(teardown, "has second yield") + + result: object | list[object] | None + if firstresult: # first result hooks return a single value + result = results[0] if results else None + else: + result = results + + # Run completion hooks in reverse order (LIFO); each may replace the + # current (result, exception) outcome. + for completion_hook in reversed(completion_hooks): + result, exception = completion_hook(result, exception) if exception is not None: raise exception diff --git a/src/pluggy/_hooks.py b/src/pluggy/_hooks.py index ef3a76f9..3b659b8d 100644 --- a/src/pluggy/_hooks.py +++ b/src/pluggy/_hooks.py @@ -11,8 +11,11 @@ from ._caller import _HookExec from ._caller import _HookRelay from ._caller import _SubsetHookCaller +from ._caller import HistoricHookCaller from ._caller import HookCaller from ._caller import HookRelay +from ._caller import NormalHookCaller +from ._caller import SubsetHookCaller from ._config import HookimplConfiguration from ._config import HookspecConfiguration from ._decorators import _Namespace @@ -30,6 +33,7 @@ __all__ = [ "CompletionHook", + "HistoricHookCaller", "HookCaller", "HookImpl", "HookRelay", @@ -38,7 +42,9 @@ "HookimplMarker", "HookspecConfiguration", "HookspecMarker", + "NormalHookCaller", "NormalImpl", + "SubsetHookCaller", "WrapperImpl", "_HookCaller", "_HookExec", diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index b830ec0c..445819f1 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -25,10 +25,14 @@ from ._hooks import _HookImplFunction from ._hooks import _Namespace from ._hooks import _Plugin -from ._hooks import _SubsetHookCaller +from ._hooks import HistoricHookCaller from ._hooks import HookCaller from ._hooks import HookImpl from ._hooks import HookRelay +from ._hooks import NormalHookCaller +from ._hooks import NormalImpl +from ._hooks import SubsetHookCaller +from ._hooks import WrapperImpl from ._pytest_compat import HookimplOpts from ._pytest_compat import HookspecOpts from ._result import Result @@ -182,13 +186,16 @@ def __init__(self, project_name: str) -> None: def _hookexec( self, hook_name: str, - methods: Sequence[HookImpl], - kwargs: Mapping[str, object], + normal_impls: Sequence[NormalImpl], + wrapper_impls: Sequence[WrapperImpl], + caller_kwargs: Mapping[str, object], firstresult: bool, ) -> object | list[object]: # called from all hookcaller instances. # enable_tracing will set its own wrapping function at self._inner_hookexec - return self._inner_hookexec(hook_name, methods, kwargs, firstresult) + return self._inner_hookexec( + hook_name, normal_impls, wrapper_impls, caller_kwargs, firstresult + ) def register(self, plugin: _Plugin, name: str | None = None) -> str | None: """Register a plugin and return its name. @@ -242,7 +249,9 @@ def register(self, plugin: _Plugin, name: str | None = None) -> str | None: plugin, plugin_name, method ) hook_name = hookimpl_config.specname or attr_name - hook: HookCaller | None = getattr(self.hook, hook_name, None) + hook: NormalHookCaller | HistoricHookCaller | None = getattr( + self.hook, hook_name, None + ) verified = False if hook is not None and hook.has_spec(): self._verify_hook(hook, hookimpl) @@ -253,7 +262,7 @@ def register(self, plugin: _Plugin, name: str | None = None) -> str | None: for hook_name, hookimpl, verified in hookimpls: hook = getattr(self.hook, hook_name, None) if hook is None: - hook = HookCaller(hook_name, self._hookexec) + hook = NormalHookCaller(hook_name, self._hookexec) setattr(self.hook, hook_name, hook) elif not verified and hook.has_spec(): # An earlier impl's historic replay added this spec after the @@ -353,6 +362,7 @@ def unregister( hookcallers = self.get_hookcallers(plugin) if hookcallers: for hookcaller in hookcallers: + assert isinstance(hookcaller, (NormalHookCaller, HistoricHookCaller)) hookcaller._remove_plugin(plugin) # if self._name2plugin[name] == None registration was blocked: ignore @@ -390,10 +400,36 @@ def add_hookspecs(self, module_or_class: _Namespace) -> None: for name in dir(module_or_class): spec_config = self._discover_hookspec_configuration(module_or_class, name) if spec_config is not None: - hc: HookCaller | None = getattr(self.hook, name, None) + hc: NormalHookCaller | HistoricHookCaller | None = getattr( + self.hook, name, None + ) if hc is None: - hc = HookCaller(name, self._hookexec, module_or_class, spec_config) + if spec_config.historic: + hc = HistoricHookCaller( + name, self._hookexec, module_or_class, spec_config + ) + else: + hc = NormalHookCaller( + name, self._hookexec, module_or_class, spec_config + ) setattr(self.hook, name, hc) + elif spec_config.historic and not hc.is_historic(): + # Plugins registered this hook before the historic spec was + # known - hand the implementations over to a + # HistoricHookCaller. + assert isinstance(hc, NormalHookCaller) + if hc.has_spec(): + # Let set_specification raise the usual error. + hc.set_specification(module_or_class, spec_config) + raise AssertionError("unreachable") # pragma: no cover + old_hookimpls = hc.get_hookimpls() + historic_hc = HistoricHookCaller( + name, self._hookexec, module_or_class, spec_config + ) + setattr(self.hook, name, historic_hc) + for hookimpl in old_hookimpls: + self._verify_hook(historic_hc, hookimpl) + historic_hc._add_hookimpl(hookimpl) else: # Plugins registered this hook without knowing the spec. hc.set_specification(module_or_class, spec_config) @@ -553,7 +589,7 @@ def check_pending(self) -> None: for name in self.hook.__dict__: if name[0] == "_": continue - hook: HookCaller = getattr(self.hook, name) + hook: NormalHookCaller | HistoricHookCaller = getattr(self.hook, name) if not hook.has_spec(): for hookimpl in hook.get_hookimpls(): if not hookimpl.optionalhook: @@ -654,13 +690,19 @@ def add_hookcall_monitoring( def traced_hookexec( hook_name: str, - hook_impls: Sequence[HookImpl], + normal_impls: Sequence[NormalImpl], + wrapper_impls: Sequence[WrapperImpl], caller_kwargs: Mapping[str, object], firstresult: bool, ) -> object | list[object]: + # For backward compatibility of the before/after callback shapes, + # combine the split lists into one. + hook_impls: list[HookImpl] = [*normal_impls, *wrapper_impls] before(hook_name, hook_impls, caller_kwargs) outcome = Result.from_call( - lambda: oldcall(hook_name, hook_impls, caller_kwargs, firstresult) + lambda: oldcall( + hook_name, normal_impls, wrapper_impls, caller_kwargs, firstresult + ) ) after(outcome, hook_name, hook_impls, caller_kwargs) return outcome.get_result() @@ -702,18 +744,18 @@ def subset_hook_caller( ) -> HookCaller: """Return a proxy :class:`~pluggy.HookCaller` for the named hook which calls all registered plugins except the ones from remove_plugins.""" - orig: HookCaller = getattr(self.hook, name) + orig: NormalHookCaller | HistoricHookCaller = getattr(self.hook, name) plugins_to_remove = set(remove_plugins) # Optimization: discard plugins to remove which don't actually implement # the hook, to make checks faster. plugins_to_remove.intersection_update( - hookimpl.plugin for hookimpl in orig._hookimpls + hookimpl.plugin for hookimpl in orig.get_hookimpls() ) # Optimization: if none of the plugins to remove actually implement the # hook, avoid the subset hook caller overhead. if not plugins_to_remove: return orig - return _SubsetHookCaller(orig, plugins_to_remove) + return SubsetHookCaller(orig, plugins_to_remove) def _formatdef(func: Callable[..., object]) -> str: diff --git a/testing/benchmark.py b/testing/benchmark.py index b5bb1879..6d021d17 100644 --- a/testing/benchmark.py +++ b/testing/benchmark.py @@ -11,8 +11,8 @@ from pluggy import HookimplMarker from pluggy import HookspecMarker from pluggy import PluginManager +from pluggy import WrapperImpl from pluggy._callers import _multicall -from pluggy._hooks import HookImpl from pluggy._hooks import varnames @@ -109,13 +109,17 @@ def wrappers(request: Any) -> list[object]: def test_hook_and_wrappers_speed(benchmark, hooks, wrappers) -> None: def setup(): hook_name = "foo" - hook_impls = [] + normal_impls = [] + wrapper_impls = [] for method in hooks + wrappers: - f = HookImpl(None, "", method, method.example_impl) - hook_impls.append(f) + f = method.example_impl.create_hookimpl(None, "", method) + if isinstance(f, WrapperImpl): + wrapper_impls.append(f) + else: + normal_impls.append(f) caller_kwargs = {"arg1": 1, "arg2": 2, "arg3": 3} firstresult = False - return (hook_name, hook_impls, caller_kwargs, firstresult), {} + return (hook_name, normal_impls, wrapper_impls, caller_kwargs, firstresult), {} benchmark.pedantic(_multicall, setup=setup, rounds=100) @@ -128,19 +132,15 @@ def test_hookimpl_with_default_speed( benchmark, impl_count: int, pass_arg: bool ) -> None: def setup(): - hook_impls = [ - HookImpl( - None, - "", - hook_with_default_argument, - hook_with_default_argument.example_impl, # type: ignore[attr-defined] - ) + config = hook_with_default_argument.example_impl # type: ignore[attr-defined] + normal_impls = [ + config.create_hookimpl(None, "", hook_with_default_argument) for _ in range(impl_count) ] caller_kwargs = {"arg1": 1} if pass_arg: caller_kwargs["arg2"] = 2 - return ("foo", hook_impls, caller_kwargs, False), {} + return ("foo", normal_impls, [], caller_kwargs, False), {} benchmark.pedantic(_multicall, setup=setup, rounds=100) diff --git a/testing/test_hookcaller.py b/testing/test_hookcaller.py index 49b01e5b..18b1f00d 100644 --- a/testing/test_hookcaller.py +++ b/testing/test_hookcaller.py @@ -5,11 +5,14 @@ import pytest +from pluggy import HistoricHookCaller +from pluggy import HookCaller from pluggy import HookimplMarker +from pluggy import HookspecConfiguration from pluggy import HookspecMarker +from pluggy import NormalHookCaller from pluggy import PluginManager from pluggy import PluginValidationError -from pluggy._hooks import HookCaller from pluggy._hooks import HookImpl @@ -18,21 +21,23 @@ @pytest.fixture -def hc(pm: PluginManager) -> HookCaller: +def hc(pm: PluginManager) -> NormalHookCaller: class Hooks: @hookspec def he_method1(self, arg: object) -> None: pass pm.add_hookspecs(Hooks) - return pm.hook.he_method1 + hc = pm.hook.he_method1 + assert isinstance(hc, NormalHookCaller) + return hc FuncT = TypeVar("FuncT", bound=Callable[..., object]) class AddMeth: - def __init__(self, hc: HookCaller) -> None: + def __init__(self, hc: NormalHookCaller) -> None: self.hc = hc def __call__( @@ -49,16 +54,15 @@ def wrap(func: FuncT) -> FuncT: hookwrapper=hookwrapper, wrapper=wrapper, )(func) - self.hc._add_hookimpl( - HookImpl(None, "", func, func.example_impl), # type: ignore[attr-defined] - ) + config = func.example_impl # type: ignore[attr-defined] + self.hc._add_hookimpl(config.create_hookimpl(None, "", func)) return func return wrap @pytest.fixture -def addmeth(hc: HookCaller) -> AddMeth: +def addmeth(hc: NormalHookCaller) -> AddMeth: return AddMeth(hc) @@ -66,7 +70,7 @@ def funcs(hookmethods: Sequence[HookImpl]) -> list[Callable[..., object]]: return [hookmethod.function for hookmethod in hookmethods] -def test_adding_nonwrappers(hc: HookCaller, addmeth: AddMeth) -> None: +def test_adding_nonwrappers(hc: NormalHookCaller, addmeth: AddMeth) -> None: @addmeth() def he_method1() -> None: pass @@ -82,7 +86,7 @@ def he_method3() -> None: assert funcs(hc.get_hookimpls()) == [he_method1, he_method2, he_method3] -def test_adding_nonwrappers_trylast(hc: HookCaller, addmeth: AddMeth) -> None: +def test_adding_nonwrappers_trylast(hc: NormalHookCaller, addmeth: AddMeth) -> None: @addmeth() def he_method1_middle() -> None: pass @@ -98,7 +102,7 @@ def he_method1_b() -> None: assert funcs(hc.get_hookimpls()) == [he_method1, he_method1_middle, he_method1_b] -def test_adding_nonwrappers_trylast3(hc: HookCaller, addmeth: AddMeth) -> None: +def test_adding_nonwrappers_trylast3(hc: NormalHookCaller, addmeth: AddMeth) -> None: @addmeth() def he_method1_a() -> None: pass @@ -123,7 +127,7 @@ def he_method1_d() -> None: ] -def test_adding_nonwrappers_trylast2(hc: HookCaller, addmeth: AddMeth) -> None: +def test_adding_nonwrappers_trylast2(hc: NormalHookCaller, addmeth: AddMeth) -> None: @addmeth() def he_method1_middle() -> None: pass @@ -139,7 +143,7 @@ def he_method1() -> None: assert funcs(hc.get_hookimpls()) == [he_method1, he_method1_middle, he_method1_b] -def test_adding_nonwrappers_tryfirst(hc: HookCaller, addmeth: AddMeth) -> None: +def test_adding_nonwrappers_tryfirst(hc: NormalHookCaller, addmeth: AddMeth) -> None: @addmeth(tryfirst=True) def he_method1() -> None: pass @@ -155,7 +159,7 @@ def he_method1_b() -> None: assert funcs(hc.get_hookimpls()) == [he_method1_middle, he_method1_b, he_method1] -def test_adding_wrappers_ordering(hc: HookCaller, addmeth: AddMeth) -> None: +def test_adding_wrappers_ordering(hc: NormalHookCaller, addmeth: AddMeth) -> None: @addmeth(hookwrapper=True) def he_method1(): yield # pragma: no cover @@ -185,7 +189,9 @@ def he_method3(): ] -def test_adding_wrappers_ordering_tryfirst(hc: HookCaller, addmeth: AddMeth) -> None: +def test_adding_wrappers_ordering_tryfirst( + hc: NormalHookCaller, addmeth: AddMeth +) -> None: @addmeth(hookwrapper=True, tryfirst=True) def he_method1(): yield # pragma: no cover @@ -201,7 +207,7 @@ def he_method3(): assert funcs(hc.get_hookimpls()) == [he_method2, he_method1, he_method3] -def test_adding_wrappers_complex(hc: HookCaller, addmeth: AddMeth) -> None: +def test_adding_wrappers_complex(hc: NormalHookCaller, addmeth: AddMeth) -> None: assert funcs(hc.get_hookimpls()) == [] @addmeth(hookwrapper=True, trylast=True) @@ -442,7 +448,7 @@ def conflict(self) -> None: ) -def test_call_extra_hook_order(hc: HookCaller, addmeth: AddMeth) -> None: +def test_call_extra_hook_order(hc: NormalHookCaller, addmeth: AddMeth) -> None: """Ensure that call_extra is calling hooks in the right order.""" order = [] @@ -513,7 +519,9 @@ def extra2() -> str: ] -def test_remove_plugin_not_found_raises(hc: HookCaller, pm: PluginManager) -> None: +def test_remove_plugin_not_found_raises( + hc: NormalHookCaller, pm: PluginManager +) -> None: """_remove_plugin() raises ValueError for a plugin that never registered an implementation on this particular hook caller.""" @@ -533,3 +541,83 @@ def he_method1(self, arg): # the failed removal must not have touched the existing registration assert len(hc.get_hookimpls()) == 1 pm.unregister(plugin) + + +def test_hookcaller_is_runtime_checkable_protocol(pm: PluginManager) -> None: + class Hooks: + @hookspec + def he_method1(self, arg: object) -> None: + pass + + @hookspec(historic=True) + def he_history(self, arg: object) -> None: + pass + + class Plugin: + @hookimpl + def he_method1(self, arg: object) -> object: + return arg + + pm.add_hookspecs(Hooks) + pm.register(Plugin()) + + normal = pm.hook.he_method1 + historic = pm.hook.he_history + subset = pm.subset_hook_caller("he_method1", []) + + assert isinstance(normal, NormalHookCaller) + assert isinstance(historic, HistoricHookCaller) + for caller in (normal, historic, subset): + assert isinstance(caller, HookCaller) + + assert not normal.is_historic() + assert historic.is_historic() + + +def test_historic_spec_after_registration_hands_over(pm: PluginManager) -> None: + """Impls registered before a historic spec move to a HistoricHookCaller.""" + out: list[object] = [] + + class Plugin: + @hookimpl + def he_history(self, arg: object) -> object: + out.append(arg) + return arg + + pm.register(Plugin()) + pre_spec_caller = pm.hook.he_history + assert isinstance(pre_spec_caller, NormalHookCaller) + + class Hooks: + @hookspec(historic=True) + def he_history(self, arg: object) -> None: + pass + + pm.add_hookspecs(Hooks) + hc = pm.hook.he_history + assert isinstance(hc, HistoricHookCaller) + assert len(hc.get_hookimpls()) == 1 + + hc.call_historic(kwargs={"arg": 1}) + assert out == [1] + + +def test_historic_rejects_direct_call_and_call_extra(pm: PluginManager) -> None: + class Hooks: + @hookspec(historic=True) + def he_history(self, arg: object) -> None: + pass + + pm.add_hookspecs(Hooks) + hc = pm.hook.he_history + with pytest.raises(AssertionError, match="use call_historic"): + hc(arg=1) + with pytest.raises(AssertionError, match="use call_historic"): + hc.call_extra([], {"arg": 1}) + with pytest.raises(ValueError, match="already registered"): + hc.set_specification(Hooks, HookspecConfiguration(historic=True)) + + +def test_normal_caller_rejects_call_historic(hc: NormalHookCaller) -> None: + with pytest.raises(AssertionError, match="not historic"): + hc.call_historic(kwargs={"arg": 1}) diff --git a/testing/test_multicall.py b/testing/test_multicall.py index e400e85a..18a5213f 100644 --- a/testing/test_multicall.py +++ b/testing/test_multicall.py @@ -7,8 +7,9 @@ from pluggy import HookCallError from pluggy import HookimplMarker from pluggy import HookspecMarker +from pluggy import NormalImpl +from pluggy import WrapperImpl from pluggy._callers import _multicall -from pluggy._hooks import HookImpl hookspec = HookspecMarker("example") @@ -20,12 +21,16 @@ def MC( kwargs: Mapping[str, object], firstresult: bool = False, ) -> object | list[object]: - caller = _multicall - hookfuncs = [] + normal_impls: list[NormalImpl] = [] + wrapper_impls: list[WrapperImpl] = [] for method in methods: - f = HookImpl(None, "", method, method.example_impl) # type: ignore[attr-defined] - hookfuncs.append(f) - return caller("foo", hookfuncs, kwargs, firstresult) + config = method.example_impl # type: ignore[attr-defined] + f = config.create_hookimpl(None, "", method) + if isinstance(f, WrapperImpl): + wrapper_impls.append(f) + else: + normal_impls.append(f) + return _multicall("foo", normal_impls, wrapper_impls, kwargs, firstresult) def test_keyword_args() -> None: diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index 96ce5603..712c2c2d 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -769,7 +769,7 @@ class PluginNo: pm.hook.he_method1(arg=1) assert out == [10] - assert repr(hc) == "<_SubsetHookCaller 'he_method1'>" + assert repr(hc) == "" def test_subset_hook_caller_with_specname(pm: PluginManager) -> None: From 2558685b0d7c59c0a86d7d29ff2f4311878f848a Mon Sep 17 00:00:00 2001 From: AryanRG Date: Sat, 17 Oct 2026 07:40:55 +0200 Subject: [PATCH 06/10] test(multicall): outer wrappers unwind after a second yield Regression test from pytest-dev/pluggy#798: a new-style wrapper that yields twice during teardown must not abort the unwind loop. Outer old- and new-style wrappers receive the RuntimeError and finish their own teardown before the hook call returns. The fix itself is the CompletionHook multicall rework in this branch; #798 was closed as its duplicate. Per #798's description, AI agents wrote and tested the original change. Co-Authored-By: Claude Opus 5.5 via Claude Code --- testing/test_multicall.py | 50 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/testing/test_multicall.py b/testing/test_multicall.py index 18a5213f..5e110d3f 100644 --- a/testing/test_multicall.py +++ b/testing/test_multicall.py @@ -147,6 +147,56 @@ def m(): MC([m], {}) +@pytest.mark.parametrize("old_style_outer", [False, True]) +@pytest.mark.parametrize("hook_raises", [False, True]) +def test_wrapper_two_yields_unwinds_outer_wrappers( + old_style_outer: bool, hook_raises: bool +) -> None: + out = [] + errors = [] + + @hookimpl + def impl(): + if hook_raises: + raise ValueError("hook failed") + return 1 + + @hookimpl(wrapper=True) + def inner(): + try: + try: + yield + except ValueError: + pass + yield + finally: + out.append("inner cleanup") + + @hookimpl(wrapper=not old_style_outer, hookwrapper=old_style_outer) + def outer(): + try: + if old_style_outer: + outcome = yield + errors.append(outcome.exception) + else: + try: + yield + except RuntimeError as exc: + errors.append(exc) + raise + finally: + out.append("outer cleanup") + + with pytest.raises(RuntimeError, match="has second yield") as excinfo: + try: + MC([impl, inner, outer], {}) + finally: + out.append("caller cleanup") + + assert errors == [excinfo.value] + assert out == ["inner cleanup", "outer cleanup", "caller cleanup"] + + def test_hookwrapper_order() -> None: out = [] From 2e2e13d07d83aa48f7e114877c3e109cdcc7ec23 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sat, 17 Oct 2026 08:12:05 +0200 Subject: [PATCH 07/10] changelog: note the second-yield unwind fix Adapted from the fragment in pytest-dev/pluggy#798, which this branch supersedes. Co-Authored-By: Claude Opus 5.5 via Claude Code --- changelog/708.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/708.bugfix.rst diff --git a/changelog/708.bugfix.rst b/changelog/708.bugfix.rst new file mode 100644 index 00000000..1c7cff42 --- /dev/null +++ b/changelog/708.bugfix.rst @@ -0,0 +1 @@ +Outer hook wrappers now receive the error and complete their teardown when a new-style hook wrapper yields more than once. From 5f2410c87ff4bcba05d8cf2c7b86e025107dfdf7 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 24 Jul 2026 15:35:12 +0200 Subject: [PATCH 08/10] feat(project): add ProjectSpec hub for markers and PluginManager Complete design step 06: ProjectSpec bundles hookspec/hookimpl markers and plugin manager creation under a single project name, with get_hookspec_config / get_hookimpl_config helpers. Markers and PluginManager accept str | ProjectSpec (strings still work; markers and manager now expose project_name as a property delegating to the spec). Co-Authored-By: Claude Fable 5 --- changelog/709.feature.rst | 6 ++ docs/api_reference.rst | 3 + src/pluggy/__init__.py | 2 + src/pluggy/_decorators.py | 33 +++++-- src/pluggy/_manager.py | 19 +++- src/pluggy/_project.py | 86 +++++++++++++++++ testing/test_project_spec.py | 173 +++++++++++++++++++++++++++++++++++ 7 files changed, 310 insertions(+), 12 deletions(-) create mode 100644 changelog/709.feature.rst create mode 100644 src/pluggy/_project.py create mode 100644 testing/test_project_spec.py diff --git a/changelog/709.feature.rst b/changelog/709.feature.rst new file mode 100644 index 00000000..1fe22c25 --- /dev/null +++ b/changelog/709.feature.rst @@ -0,0 +1,6 @@ +New :class:`pluggy.ProjectSpec` hub bundles a project's +:class:`~pluggy.HookspecMarker`, :class:`~pluggy.HookimplMarker` and +:meth:`~pluggy.ProjectSpec.create_plugin_manager` under one project name. +:class:`~pluggy.HookspecMarker`, :class:`~pluggy.HookimplMarker` and +:class:`~pluggy.PluginManager` now accept either a project name string or +a ``ProjectSpec`` instance; plain strings keep working unchanged. diff --git a/docs/api_reference.rst b/docs/api_reference.rst index c4ce49ae..1029f954 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -8,6 +8,9 @@ API Reference .. autoclass:: pluggy.PluginManager :members: +.. autoclass:: pluggy.ProjectSpec + :members: + .. autoclass:: pluggy.PluginValidationError :show-inheritance: :members: diff --git a/src/pluggy/__init__.py b/src/pluggy/__init__.py index d3858dfa..a47ebfd7 100644 --- a/src/pluggy/__init__.py +++ b/src/pluggy/__init__.py @@ -16,6 +16,7 @@ "PluggyWarning", "PluginManager", "PluginValidationError", + "ProjectSpec", "Result", "SubsetHookCaller", "WrapperImpl", @@ -35,6 +36,7 @@ from ._hooks import WrapperImpl from ._manager import PluginManager from ._manager import PluginValidationError +from ._project import ProjectSpec from ._pytest_compat import HookimplOpts from ._pytest_compat import HookspecOpts from ._result import HookCallError diff --git a/src/pluggy/_decorators.py b/src/pluggy/_decorators.py index 1f16bed3..d416199c 100644 --- a/src/pluggy/_decorators.py +++ b/src/pluggy/_decorators.py @@ -19,6 +19,7 @@ from ._config import HookimplConfiguration from ._config import HookspecConfiguration +from ._project import ProjectSpec _F = TypeVar("_F", bound=Callable[..., object]) @@ -30,15 +31,23 @@ class HookspecMarker: """Decorator for marking functions as hook specifications. - Instantiate it with a project_name to get a decorator. + Instantiate it with a project name or :class:`ProjectSpec` to get a + decorator. Calling :meth:`PluginManager.add_hookspecs` later will discover all marked functions if the :class:`PluginManager` uses the same project name. """ - __slots__ = ("project_name",) + __slots__ = ("_project_spec",) - def __init__(self, project_name: str) -> None: - self.project_name: Final = project_name + def __init__(self, project_name: str | ProjectSpec) -> None: + self._project_spec: Final = ( + ProjectSpec(project_name) if isinstance(project_name, str) else project_name + ) + + @property + def project_name(self) -> str: + """The project name from the associated :class:`ProjectSpec`.""" + return self._project_spec.project_name @overload def __call__( @@ -115,15 +124,23 @@ def setattr_hookspec_opts(func: _F) -> _F: class HookimplMarker: """Decorator for marking functions as hook implementations. - Instantiate it with a ``project_name`` to get a decorator. + Instantiate it with a project name or :class:`ProjectSpec` to get a + decorator. Calling :meth:`PluginManager.register` later will discover all marked functions if the :class:`PluginManager` uses the same project name. """ - __slots__ = ("project_name",) + __slots__ = ("_project_spec",) - def __init__(self, project_name: str) -> None: - self.project_name: Final = project_name + def __init__(self, project_name: str | ProjectSpec) -> None: + self._project_spec: Final = ( + ProjectSpec(project_name) if isinstance(project_name, str) else project_name + ) + + @property + def project_name(self) -> str: + """The project name from the associated :class:`ProjectSpec`.""" + return self._project_spec.project_name @overload def __call__( diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index 445819f1..1f523b11 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -33,6 +33,7 @@ from ._hooks import NormalImpl from ._hooks import SubsetHookCaller from ._hooks import WrapperImpl +from ._project import ProjectSpec from ._pytest_compat import HookimplOpts from ._pytest_compat import HookspecOpts from ._result import Result @@ -164,12 +165,17 @@ class PluginManager: which will subsequently send debug information to the trace helper. :param project_name: - The short project name. Prefer snake case. Make sure it's unique! + The short project name (prefer snake case, make sure it's unique!), + or a :class:`ProjectSpec` instance. + + .. versionchanged:: 1.7 + A :class:`ProjectSpec` may be passed instead of a plain name. """ - def __init__(self, project_name: str) -> None: - #: The project name. - self.project_name: Final = project_name + def __init__(self, project_name: str | ProjectSpec) -> None: + self._project_spec: Final = ( + ProjectSpec(project_name) if isinstance(project_name, str) else project_name + ) self._name2plugin: Final[dict[str, _Plugin]] = {} self._plugin_distinfo: Final[ list[tuple[_Plugin, importlib.metadata.Distribution]] @@ -183,6 +189,11 @@ def __init__(self, project_name: str) -> None: ) self._inner_hookexec = _multicall + @property + def project_name(self) -> str: + """The project name from the associated :class:`ProjectSpec`.""" + return self._project_spec.project_name + def _hookexec( self, hook_name: str, diff --git a/src/pluggy/_project.py b/src/pluggy/_project.py new file mode 100644 index 00000000..3867d411 --- /dev/null +++ b/src/pluggy/_project.py @@ -0,0 +1,86 @@ +""" +Project configuration hub for pluggy projects. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Final +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from ._config import HookimplConfiguration + from ._config import HookspecConfiguration + from ._manager import PluginManager + + +class ProjectSpec: + """Manages hook markers and plugin manager creation for a pluggy project. + + This class provides a unified interface for creating and managing the + core components of a pluggy project: :class:`HookspecMarker`, + :class:`HookimplMarker`, and :class:`PluginManager`. + + All components share the same ``project_name``, ensuring consistent + behavior across hook specifications, implementations, and plugin + management. + + :param project_name: + The short project name. Prefer snake case. Make sure it's unique! + :param plugin_manager_cls: + Custom :class:`PluginManager` subclass to use (defaults to + :class:`PluginManager`). + + .. versionadded:: 1.7 + """ + + def __init__( + self, + project_name: str, + plugin_manager_cls: type[PluginManager] | None = None, + ) -> None: + # Local imports to avoid circular imports with the marker and + # manager modules. + from ._decorators import HookimplMarker + from ._decorators import HookspecMarker + from ._manager import PluginManager as DefaultPluginManager + + #: The project name used across all components. + self.project_name: Final = project_name + self._plugin_manager_cls: Final = plugin_manager_cls or DefaultPluginManager + + # Marker instances are stateless decorators, safe to share. + #: Hook specification marker for this project. + self.hookspec: Final = HookspecMarker(self) + #: Hook implementation marker for this project. + self.hookimpl: Final = HookimplMarker(self) + + def create_plugin_manager(self) -> PluginManager: + """Create a new :class:`PluginManager` instance for this project. + + Each call returns a fresh, independent instance configured with this + project's name. + """ + return self._plugin_manager_cls(self) + + def get_hookspec_config( + self, func: Callable[..., object] + ) -> HookspecConfiguration | None: + """Extract the hook specification configuration from a decorated + function, or ``None`` if it is not decorated with this project's + hookspec marker.""" + attr_name = self.project_name + "_spec" + return getattr(func, attr_name, None) + + def get_hookimpl_config( + self, func: Callable[..., object] + ) -> HookimplConfiguration | None: + """Extract the hook implementation configuration from a decorated + function, or ``None`` if it is not decorated with this project's + hookimpl marker.""" + attr_name = self.project_name + "_impl" + return getattr(func, attr_name, None) + + def __repr__(self) -> str: + return f"ProjectSpec(project_name={self.project_name!r})" diff --git a/testing/test_project_spec.py b/testing/test_project_spec.py new file mode 100644 index 00000000..ffa54a24 --- /dev/null +++ b/testing/test_project_spec.py @@ -0,0 +1,173 @@ +""" +Tests for ProjectSpec functionality. +""" + +from __future__ import annotations + +from pluggy import HookimplMarker +from pluggy import HookspecMarker +from pluggy import PluginManager +from pluggy import ProjectSpec + + +def test_project_spec_basic_creation() -> None: + project = ProjectSpec("testproject") + + assert project.project_name == "testproject" + assert isinstance(project.hookspec, HookspecMarker) + assert isinstance(project.hookimpl, HookimplMarker) + assert project.hookspec.project_name == "testproject" + assert project.hookimpl.project_name == "testproject" + assert repr(project) == "ProjectSpec(project_name='testproject')" + + +def test_project_spec_plugin_manager_creation() -> None: + project = ProjectSpec("testproject") + + pm1 = project.create_plugin_manager() + pm2 = project.create_plugin_manager() + + assert pm1 is not pm2 + assert pm1.project_name == "testproject" + assert pm2.project_name == "testproject" + assert isinstance(pm1, PluginManager) + assert isinstance(pm2, PluginManager) + + +def test_project_spec_custom_plugin_manager_class() -> None: + class CustomPluginManager(PluginManager): + def __init__(self, project_name: str | ProjectSpec) -> None: + super().__init__(project_name) + self.custom_attr = "custom_value" + + project = ProjectSpec("testproject", plugin_manager_cls=CustomPluginManager) + pm = project.create_plugin_manager() + + assert isinstance(pm, CustomPluginManager) + assert pm.project_name == "testproject" + assert pm.custom_attr == "custom_value" + + +def test_project_spec_functional_integration() -> None: + project = ProjectSpec("testproject") + + hookspec = project.hookspec + hookimpl = project.hookimpl + + class HookSpecs: + @hookspec + def my_hook(self, arg: int) -> int: # type: ignore[empty-body] + ... + + class Plugin: + @hookimpl + def my_hook(self, arg: int) -> int: + return arg * 2 + + pm = project.create_plugin_manager() + pm.add_hookspecs(HookSpecs) + pm.register(Plugin()) + + result = pm.hook.my_hook(arg=5) + assert result == [10] + + +def test_project_spec_multiple_plugin_managers_independent() -> None: + project = ProjectSpec("testproject") + + pm1 = project.create_plugin_manager() + pm2 = project.create_plugin_manager() + + class Plugin1: + pass + + class Plugin2: + pass + + pm1.register(Plugin1(), name="plugin1") + pm2.register(Plugin2(), name="plugin2") + + assert pm1.has_plugin("plugin1") + assert not pm1.has_plugin("plugin2") + assert pm2.has_plugin("plugin2") + assert not pm2.has_plugin("plugin1") + + +def test_project_spec_hook_attribute_naming() -> None: + project = ProjectSpec("myproject") + + @project.hookspec + def test_hook() -> None: + pass + + @project.hookimpl + def test_hook_impl() -> None: + pass + + assert hasattr(test_hook, "myproject_spec") + assert hasattr(test_hook_impl, "myproject_impl") + + +def test_project_spec_get_hook_configs() -> None: + project = ProjectSpec("testproject") + + @project.hookspec(firstresult=True) + def my_hook() -> None: + pass + + @project.hookimpl(tryfirst=True, optionalhook=True) + def my_hook_impl() -> None: + pass + + spec_config = project.get_hookspec_config(my_hook) + assert spec_config is not None + assert spec_config.firstresult is True + + impl_config = project.get_hookimpl_config(my_hook_impl) + assert impl_config is not None + assert impl_config.tryfirst is True + assert impl_config.optionalhook is True + assert impl_config.wrapper is False + + def undecorated() -> None: + pass + + assert project.get_hookspec_config(undecorated) is None + assert project.get_hookimpl_config(undecorated) is None + + +def test_marker_classes_accept_project_spec() -> None: + project = ProjectSpec("testproject") + + hookspec_from_project = HookspecMarker(project) + hookimpl_from_project = HookimplMarker(project) + + assert hookspec_from_project.project_name == "testproject" + assert hookimpl_from_project.project_name == "testproject" + assert hookspec_from_project._project_spec is project + assert hookimpl_from_project._project_spec is project + + +def test_marker_classes_accept_string() -> None: + hookspec_from_string = HookspecMarker("testproject") + hookimpl_from_string = HookimplMarker("testproject") + + assert hookspec_from_string.project_name == "testproject" + assert hookimpl_from_string.project_name == "testproject" + assert hookspec_from_string._project_spec.project_name == "testproject" + assert hookimpl_from_string._project_spec.project_name == "testproject" + + +def test_plugin_manager_accepts_project_spec() -> None: + project = ProjectSpec("testproject") + pm = PluginManager(project) + + assert pm.project_name == "testproject" + assert pm._project_spec is project + + +def test_plugin_manager_accepts_string() -> None: + pm = PluginManager("testproject") + + assert pm.project_name == "testproject" + assert pm._project_spec.project_name == "testproject" From 8da218116c237d1f8dfa474160f35f172b02d0c5 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 24 Jul 2026 16:24:32 +0200 Subject: [PATCH 09/10] feat(async): greenlet Submitter and PluginManager.run_async Complete design step 07: - New _async module with a persistent Submitter: maybe_submit awaits awaitable hook results while active and passes them through otherwise (await-me-maybe); require_await hard-fails outside async context; async_generator_to_sync helps wrappers consume async generators. - Submitter.run uses a sentinel so legitimate None returns work (fixes the try-claude footgun) and forwards await failures to the submission site inside the worker greenlet. - PluginManager owns the Submitter and threads it through the callers and _hookexec into _multicall - activation is purely Submitter.run, no _inner_hookexec monkeypatching. await pm.run_async(func) is the public entry point; nested runs raise. - Packaging: new pluggy[async] extra depending on greenlet; greenlet added to the testing group and types-greenlet to the mypy hook. Co-Authored-By: Claude Fable 5 --- .pre-commit-config.yaml | 2 +- changelog/710.feature.rst | 9 ++ pyproject.toml | 5 +- src/pluggy/_async.py | 163 +++++++++++++++++++++ src/pluggy/_caller.py | 40 +++++- src/pluggy/_execution.py | 10 ++ src/pluggy/_manager.py | 73 +++++++++- testing/benchmark.py | 12 +- testing/test_async.py | 288 ++++++++++++++++++++++++++++++++++++++ testing/test_multicall.py | 5 +- uv.lock | 95 +++++++++++++ 11 files changed, 686 insertions(+), 16 deletions(-) create mode 100644 changelog/710.feature.rst create mode 100644 src/pluggy/_async.py create mode 100644 testing/test_async.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6af69994..c66a3b2c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,4 +46,4 @@ repos: - id: mypy files: ^(src/|testing/) args: [] - additional_dependencies: [pytest] + additional_dependencies: [pytest, types-greenlet] diff --git a/changelog/710.feature.rst b/changelog/710.feature.rst new file mode 100644 index 00000000..7f0f351f --- /dev/null +++ b/changelog/710.feature.rst @@ -0,0 +1,9 @@ +New async bridge for awaitable hook results: +``await pm.run_async(lambda: pm.hook.my_hook())`` runs the hook call in a +greenlet context where awaitable results from hook implementations are +awaited on the current event loop ("await-me-maybe"). Outside +``run_async``, awaitable results are passed through unchanged, as before. +Requires the optional ``greenlet`` dependency, installable via +``pluggy[async]``. The bridge is a persistent per-manager ``Submitter``; +async wrapper generators are not auto-awaited (a manual +``async_generator_to_sync`` helper is provided). diff --git a/pyproject.toml b/pyproject.toml index beacdda6..20a58126 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,9 +44,12 @@ Repository = "https://github.com/pytest-dev/pluggy" Issues = "https://github.com/pytest-dev/pluggy/issues" Funding = "https://tidelift.com/subscription/pkg/pypi-pluggy" +[project.optional-dependencies] +async = ["greenlet"] + [dependency-groups] dev = ["pre-commit"] -testing = ["pytest>=8", "pytest-benchmark", "coverage"] +testing = ["pytest>=8", "pytest-benchmark", "coverage", "greenlet"] [tool.setuptools] diff --git a/src/pluggy/_async.py b/src/pluggy/_async.py new file mode 100644 index 00000000..e5a00970 --- /dev/null +++ b/src/pluggy/_async.py @@ -0,0 +1,163 @@ +""" +Async support for pluggy using greenlets. + +This module provides async functionality for pluggy, allowing hook +implementations to return awaitable objects that are automatically awaited +when running in an async context (see :meth:`PluginManager.run_async +`). +""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from collections.abc import Awaitable +from collections.abc import Callable +from collections.abc import Generator +from typing import Any +from typing import cast +from typing import Final +from typing import TYPE_CHECKING +from typing import TypeVar + + +_T = TypeVar("_T") +_Y = TypeVar("_Y") +_S = TypeVar("_S") + +if TYPE_CHECKING: + import greenlet + + +#: Sentinel distinguishing "worker never completed" from a legitimate +#: ``None`` result. +_UNSET: Final = object() + + +class Submitter: + """Bridge between synchronous hook execution and an async event loop. + + While :meth:`run` is active, awaitables passed to :meth:`maybe_submit` or + :meth:`require_await` are switched to the awaiting parent greenlet, which + awaits them and switches the result back. When inactive, + :meth:`maybe_submit` passes awaitables through unchanged + ("await-me-maybe"). + """ + + _active_submitter: greenlet.greenlet | None + + def __init__(self) -> None: + self._active_submitter = None + + def __repr__(self) -> str: + return f"" + + def maybe_submit(self, coro: Awaitable[_T]) -> _T | Awaitable[_T]: + """Await an awaitable if active, else return it unchanged. + + This enables backward compatibility for datasette + and https://simonwillison.net/2020/Sep/2/await-me-maybe/ + """ + active = self._active_submitter + if active is not None: + # We're in a greenlet context; switch to the parent with the + # awaitable. The parent awaits it and switches back the result. + res: _T = active.switch(coro) + return res + else: + return coro + + def require_await(self, coro: Awaitable[_T]) -> _T: + """Await an awaitable, raising an error if not in async context.""" + active = self._active_submitter + if active is not None: + res: _T = active.switch(coro) + return res + else: + raise RuntimeError("require_await called outside of async context") + + async def run(self, sync_func: Callable[[], _T]) -> _T: + """Run a synchronous function in a worker greenlet with async support. + + Awaitables submitted by hook implementations during the call are + awaited on this coroutine's event loop. + """ + try: + import greenlet + except ImportError: + raise RuntimeError("greenlet is required for async support") from None + + if self._active_submitter is not None: + raise RuntimeError("Submitter is already active") + + main_greenlet = greenlet.getcurrent() + result: object = _UNSET + exception: BaseException | None = None + + def greenlet_func() -> None: + nonlocal result, exception + try: + result = sync_func() + except BaseException as e: + exception = e + + worker_greenlet = greenlet.greenlet(greenlet_func) + # Let maybe_submit/require_await switch back to this greenlet. + self._active_submitter = main_greenlet + try: + # Run the worker; every switch back carries an awaitable to + # process, until the worker finishes (switching back None). + awaitable = worker_greenlet.switch() + while awaitable is not None: + try: + awaited_result = await awaitable + except BaseException as e: + # Raise at the submission site inside the worker. + awaitable = worker_greenlet.throw(e) + else: + awaitable = worker_greenlet.switch(awaited_result) + finally: + self._active_submitter = None + + if exception is not None: + raise exception + assert result is not _UNSET, "worker greenlet did not complete" + return cast(_T, result) + + +def async_generator_to_sync( + async_gen: AsyncGenerator[_Y, _S], submitter: Submitter +) -> Generator[_Y, _S, None]: + """Convert an async generator to a sync generator using a `Submitter`. + + This helper allows wrapper implementations to use async generators while + maintaining compatibility with the sync generator interface expected by + the hook system. The submitter must be active (i.e. the generator must be + consumed under :meth:`Submitter.run`). + """ + try: + # Start the async generator. + value = submitter.require_await(async_gen.__anext__()) + + while True: + try: + sent_value = yield value + try: + value = submitter.require_await(async_gen.asend(sent_value)) + except StopAsyncIteration: + return + + except GeneratorExit: + # Generator is being closed; close the async generator too. + submitter.require_await(cast(Awaitable[Any], async_gen.aclose())) + raise + + except BaseException as exc: + # Exception was thrown into the generator; forward it into + # the async generator. + try: + value = submitter.require_await(async_gen.athrow(exc)) + except StopAsyncIteration: + return + + except StopAsyncIteration: + return diff --git a/src/pluggy/_caller.py b/src/pluggy/_caller.py index edb203e1..cda3df0a 100644 --- a/src/pluggy/_caller.py +++ b/src/pluggy/_caller.py @@ -19,6 +19,7 @@ from typing import TypeAlias from typing import TypeVar +from ._async import Submitter from ._config import HookimplConfiguration from ._config import hookspec_config_from_mapping from ._config import HookspecConfiguration @@ -31,7 +32,14 @@ _HookExec: TypeAlias = Callable[ - [str, Sequence[NormalImpl], Sequence[WrapperImpl], Mapping[str, object], bool], + [ + str, + Sequence[NormalImpl], + Sequence[WrapperImpl], + Mapping[str, object], + bool, + Submitter, + ], "object | list[object]", ] @@ -153,6 +161,7 @@ class NormalHookCaller: """A caller of all registered implementations of a hook specification.""" __slots__ = ( + "_async_submitter", "_hookexec", "_normal_hookimpls", "_wrapper_hookimpls", @@ -166,11 +175,13 @@ def __init__( hook_execute: _HookExec, specmodule_or_class: _Namespace | None = None, spec_config: HookspecConfiguration | None = None, + async_submitter: Submitter | None = None, ) -> None: """:meta private:""" #: Name of the hook getting called. self.name: Final = name self._hookexec: Final = hook_execute + self._async_submitter: Final = async_submitter or Submitter() # Split hook implementations into two lists for simpler management: # Normal hooks: [trylast, normal, tryfirst] # Wrapper hooks: [trylast, normal, tryfirst] @@ -262,6 +273,7 @@ def __call__(self, **kwargs: object) -> Any: self._wrapper_hookimpls.copy(), call_kwargs, firstresult, + self._async_submitter, ) def call_historic( @@ -297,6 +309,7 @@ def call_extra( self._wrapper_hookimpls.copy(), kwargs, firstresult, + self._async_submitter, ) def _maybe_apply_history(self, method: HookImpl) -> None: @@ -316,6 +329,7 @@ class HistoricHookCaller: """ __slots__ = ( + "_async_submitter", "_call_history", "_hookexec", "_hookimpls", @@ -331,12 +345,14 @@ def __init__( hook_execute: _HookExec, specmodule_or_class: _Namespace, spec_config: HookspecConfiguration, + async_submitter: Submitter | None = None, ) -> None: """:meta private:""" assert spec_config.historic, "HistoricHookCaller requires historic=True" #: Name of the hook getting called. self.name: Final = name self._hookexec: Final = hook_execute + self._async_submitter: Final = async_submitter or Submitter() # The hookimpls list for historic hooks (no wrappers supported). self._hookimpls: Final[list[NormalImpl]] = [] self._call_history: Final[_CallHistory] = [] @@ -410,7 +426,14 @@ def call_historic( # Historizing hooks don't return results. # Remember firstresult isn't compatible with historic. # Copy because plugins may register other plugins during iteration (#438). - res = self._hookexec(self.name, self._hookimpls.copy(), [], kwargs, False) + res = self._hookexec( + self.name, + self._hookimpls.copy(), + [], + kwargs, + False, + self._async_submitter, + ) if result_callback is None: return if isinstance(res, list): @@ -429,7 +452,9 @@ def _maybe_apply_history(self, method: HookImpl) -> None: """Apply call history to a new hookimpl.""" assert isinstance(method, NormalImpl) for kwargs, result_callback in self._call_history: - res = self._hookexec(self.name, [method], [], kwargs, False) + res = self._hookexec( + self.name, [method], [], kwargs, False, self._async_submitter + ) if res and result_callback is not None: # XXX: remember firstresult isn't compat with historic assert isinstance(res, list) @@ -511,6 +536,7 @@ def __call__(self, **kwargs: object) -> Any: self._get_filtered(orig._wrapper_hookimpls), call_kwargs, firstresult, + orig._async_submitter, ) def call_historic( @@ -532,7 +558,12 @@ def call_historic( # History is shared with the original caller. orig._call_history.append((kwargs, result_callback)) res = orig._hookexec( - self.name, self._get_filtered(orig._hookimpls), [], kwargs, False + self.name, + self._get_filtered(orig._hookimpls), + [], + kwargs, + False, + orig._async_submitter, ) if result_callback is None: return @@ -568,6 +599,7 @@ def call_extra( self._get_filtered(orig._wrapper_hookimpls), kwargs, firstresult, + orig._async_submitter, ) def __repr__(self) -> str: diff --git a/src/pluggy/_execution.py b/src/pluggy/_execution.py index ef7fd3a4..5a90e2bb 100644 --- a/src/pluggy/_execution.py +++ b/src/pluggy/_execution.py @@ -4,6 +4,7 @@ from __future__ import annotations +from collections.abc import Awaitable from collections.abc import Generator from collections.abc import Mapping from collections.abc import Sequence @@ -20,6 +21,10 @@ from ._warnings import PluggyTeardownRaisedWarning +if TYPE_CHECKING: + from ._async import Submitter + + # Need to distinguish between old- and new-style hook wrappers. # Wrapping with a tuple is the fastest type-safe way I found to do it. Teardown: TypeAlias = Generator[None, object, object] @@ -93,6 +98,7 @@ def _multicall( wrapper_impls: Sequence[WrapperImpl], caller_kwargs: Mapping[str, object], firstresult: bool, + async_submitter: Submitter, ) -> object | list[object]: """Execute a call into multiple python functions/methods and return the result(s). @@ -127,6 +133,10 @@ def _multicall( else: res = normal_impl.function(*args, **kwargs) if res is not None: + # Awaitable results are awaited when a Submitter is active + # (await-me-maybe), otherwise passed through unchanged. + if isinstance(res, Awaitable): + res = async_submitter.maybe_submit(res) results.append(res) if firstresult: # halt further impl calls break diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index 1f523b11..a9de7f87 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -11,9 +11,11 @@ from typing import Final from typing import TYPE_CHECKING from typing import TypeAlias +from typing import TypeVar import warnings from . import _tracing +from ._async import Submitter from ._callers import _multicall from ._compat import DistFacade from ._config import hookimpl_config_from_mapping @@ -43,6 +45,8 @@ import importlib.metadata +_T = TypeVar("_T") + _BeforeTrace: TypeAlias = Callable[[str, Sequence[HookImpl], Mapping[str, Any]], None] _AfterTrace: TypeAlias = Callable[ [Result[Any], str, Sequence[HookImpl], Mapping[str, Any]], None @@ -167,15 +171,24 @@ class PluginManager: :param project_name: The short project name (prefer snake case, make sure it's unique!), or a :class:`ProjectSpec` instance. + :param async_submitter: + Custom :class:`~pluggy._async.Submitter` used to await awaitable hook + results under :meth:`run_async` (defaults to a fresh one). .. versionchanged:: 1.7 A :class:`ProjectSpec` may be passed instead of a plain name. + Added ``async_submitter`` and :meth:`run_async`. """ - def __init__(self, project_name: str | ProjectSpec) -> None: + def __init__( + self, + project_name: str | ProjectSpec, + async_submitter: Submitter | None = None, + ) -> None: self._project_spec: Final = ( ProjectSpec(project_name) if isinstance(project_name, str) else project_name ) + self._async_submitter: Final = async_submitter or Submitter() self._name2plugin: Final[dict[str, _Plugin]] = {} self._plugin_distinfo: Final[ list[tuple[_Plugin, importlib.metadata.Distribution]] @@ -201,11 +214,17 @@ def _hookexec( wrapper_impls: Sequence[WrapperImpl], caller_kwargs: Mapping[str, object], firstresult: bool, + async_submitter: Submitter, ) -> object | list[object]: # called from all hookcaller instances. # enable_tracing will set its own wrapping function at self._inner_hookexec return self._inner_hookexec( - hook_name, normal_impls, wrapper_impls, caller_kwargs, firstresult + hook_name, + normal_impls, + wrapper_impls, + caller_kwargs, + firstresult, + async_submitter, ) def register(self, plugin: _Plugin, name: str | None = None) -> str | None: @@ -273,7 +292,11 @@ def register(self, plugin: _Plugin, name: str | None = None) -> str | None: for hook_name, hookimpl, verified in hookimpls: hook = getattr(self.hook, hook_name, None) if hook is None: - hook = NormalHookCaller(hook_name, self._hookexec) + hook = NormalHookCaller( + hook_name, + self._hookexec, + async_submitter=self._async_submitter, + ) setattr(self.hook, hook_name, hook) elif not verified and hook.has_spec(): # An earlier impl's historic replay added this spec after the @@ -417,11 +440,19 @@ def add_hookspecs(self, module_or_class: _Namespace) -> None: if hc is None: if spec_config.historic: hc = HistoricHookCaller( - name, self._hookexec, module_or_class, spec_config + name, + self._hookexec, + module_or_class, + spec_config, + async_submitter=self._async_submitter, ) else: hc = NormalHookCaller( - name, self._hookexec, module_or_class, spec_config + name, + self._hookexec, + module_or_class, + spec_config, + async_submitter=self._async_submitter, ) setattr(self.hook, name, hc) elif spec_config.historic and not hc.is_historic(): @@ -435,7 +466,11 @@ def add_hookspecs(self, module_or_class: _Namespace) -> None: raise AssertionError("unreachable") # pragma: no cover old_hookimpls = hc.get_hookimpls() historic_hc = HistoricHookCaller( - name, self._hookexec, module_or_class, spec_config + name, + self._hookexec, + module_or_class, + spec_config, + async_submitter=self._async_submitter, ) setattr(self.hook, name, historic_hc) for hookimpl in old_hookimpls: @@ -705,6 +740,7 @@ def traced_hookexec( wrapper_impls: Sequence[WrapperImpl], caller_kwargs: Mapping[str, object], firstresult: bool, + async_submitter: Submitter, ) -> object | list[object]: # For backward compatibility of the before/after callback shapes, # combine the split lists into one. @@ -712,7 +748,12 @@ def traced_hookexec( before(hook_name, hook_impls, caller_kwargs) outcome = Result.from_call( lambda: oldcall( - hook_name, normal_impls, wrapper_impls, caller_kwargs, firstresult + hook_name, + normal_impls, + wrapper_impls, + caller_kwargs, + firstresult, + async_submitter, ) ) after(outcome, hook_name, hook_impls, caller_kwargs) @@ -750,6 +791,24 @@ def after( return self.add_hookcall_monitoring(before, after) + async def run_async(self, func: Callable[[], _T]) -> _T: + """Run a synchronous function with async support for hook results. + + The function runs in a greenlet context in which awaitable hook + results are automatically awaited on the current event loop:: + + pm = PluginManager("myapp") + ... + results = await pm.run_async(lambda: pm.hook.my_hook()) + + :raises RuntimeError: + If ``greenlet`` is not installed (install ``pluggy[async]``), or + if a ``run_async`` call is already active for this manager. + + .. versionadded:: 1.7 + """ + return await self._async_submitter.run(func) + def subset_hook_caller( self, name: str, remove_plugins: Iterable[_Plugin] ) -> HookCaller: diff --git a/testing/benchmark.py b/testing/benchmark.py index 6d021d17..cc40e8a8 100644 --- a/testing/benchmark.py +++ b/testing/benchmark.py @@ -12,6 +12,7 @@ from pluggy import HookspecMarker from pluggy import PluginManager from pluggy import WrapperImpl +from pluggy._async import Submitter from pluggy._callers import _multicall from pluggy._hooks import varnames @@ -119,7 +120,14 @@ def setup(): normal_impls.append(f) caller_kwargs = {"arg1": 1, "arg2": 2, "arg3": 3} firstresult = False - return (hook_name, normal_impls, wrapper_impls, caller_kwargs, firstresult), {} + return ( + hook_name, + normal_impls, + wrapper_impls, + caller_kwargs, + firstresult, + Submitter(), + ), {} benchmark.pedantic(_multicall, setup=setup, rounds=100) @@ -140,7 +148,7 @@ def setup(): caller_kwargs = {"arg1": 1} if pass_arg: caller_kwargs["arg2"] = 2 - return ("foo", normal_impls, [], caller_kwargs, False), {} + return ("foo", normal_impls, [], caller_kwargs, False, Submitter()), {} benchmark.pedantic(_multicall, setup=setup, rounds=100) diff --git a/testing/test_async.py b/testing/test_async.py new file mode 100644 index 00000000..2b8c790e --- /dev/null +++ b/testing/test_async.py @@ -0,0 +1,288 @@ +""" +Tests for the greenlet-based async Submitter support. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator +from collections.abc import Awaitable +from collections.abc import Coroutine +from typing import Any +from typing import cast + +import pytest + +import pluggy +from pluggy._async import async_generator_to_sync +from pluggy._async import Submitter + + +hookspec = pluggy.HookspecMarker("test") +hookimpl = pluggy.HookimplMarker("test") + + +class HookSpecs: + @hookspec + def test_hook(self): + pass + + +class AsyncPlugin: + @hookimpl + async def test_hook(self): + await asyncio.sleep(0.01) + return "async_result" + + +class SyncPlugin: + @hookimpl + def test_hook(self): + return "sync_result" + + +def make_pm(*plugins: object) -> pluggy.PluginManager: + pm = pluggy.PluginManager("test") + pm.add_hookspecs(HookSpecs) + for plugin in plugins: + pm.register(plugin) + return pm + + +def test_run_async_mixed_sync_and_async() -> None: + pytest.importorskip("greenlet") + pm = make_pm(AsyncPlugin(), SyncPlugin()) + + async def run_test() -> list[str]: + result = await pm.run_async(lambda: pm.hook.test_hook()) + return cast(list[str], result) + + result = asyncio.run(run_test()) + assert "sync_result" in result + assert "async_result" in result + + +def test_run_async_with_sync_hooks_only() -> None: + pytest.importorskip("greenlet") + pm = make_pm(SyncPlugin()) + + async def run_test() -> list[str]: + result = await pm.run_async(lambda: pm.hook.test_hook()) + return cast(list[str], result) + + assert asyncio.run(run_test()) == ["sync_result"] + + +def test_awaitable_left_in_results_outside_run_async() -> None: + """Outside run_async, awaitables are passed through (await-me-maybe).""" + pm = make_pm(AsyncPlugin(), SyncPlugin()) + + results = pm.hook.test_hook() + assert "sync_result" in results + awaitables = [res for res in results if isinstance(res, Awaitable)] + assert len(awaitables) == 1 + + # Awaiting the leftover coroutine still works. + leftover = cast("Coroutine[Any, Any, str]", awaitables[0]) + assert asyncio.run(leftover) == "async_result" + + +def test_run_async_returning_none_succeeds() -> None: + """Regression test: a legitimate None result must not be rejected.""" + pytest.importorskip("greenlet") + pm = make_pm() + + async def run_test() -> None: + return await pm.run_async(lambda: None) + + assert asyncio.run(run_test()) is None + + +def test_nested_run_async_rejected() -> None: + pytest.importorskip("greenlet") + pm = make_pm() + inner_error: list[BaseException] = [] + + def nested() -> object: + submitter = pm._async_submitter + # Submitter is already active - a nested run must hard-fail. + coro = submitter.run(lambda: 1) + try: + coro.send(None) + except RuntimeError as e: + inner_error.append(e) + finally: + coro.close() + return "done" + + async def run_test() -> object: + return await pm.run_async(nested) + + assert asyncio.run(run_test()) == "done" + assert inner_error + assert "already active" in str(inner_error[0]) + + +def test_run_async_propagates_sync_exception() -> None: + pytest.importorskip("greenlet") + pm = make_pm() + + def boom() -> None: + raise ValueError("boom") + + async def run_test() -> None: + await pm.run_async(boom) + + with pytest.raises(ValueError, match="boom"): + asyncio.run(run_test()) + + +def test_run_async_propagates_awaited_exception() -> None: + """An exception raised while awaiting surfaces at the hook call site.""" + pytest.importorskip("greenlet") + + class FailingAsyncPlugin: + @hookimpl + async def test_hook(self): + raise ValueError("async boom") + + pm = make_pm(FailingAsyncPlugin()) + + async def run_test() -> None: + await pm.run_async(lambda: pm.hook.test_hook()) + + with pytest.raises(ValueError, match="async boom"): + asyncio.run(run_test()) + + +def test_run_async_without_greenlet() -> None: + try: + import greenlet # noqa: F401 + + pytest.skip("greenlet is available, cannot test the error case") + except ImportError: # pragma: no cover + pm = make_pm() + + async def run_test() -> None: + with pytest.raises(RuntimeError, match="greenlet is required"): + await pm.run_async(lambda: "test") + + asyncio.run(run_test()) + + +def test_require_await_outside_context() -> None: + submitter = Submitter() + + async def coro() -> None: + pass # pragma: no cover + + awaitable = coro() + with pytest.raises(RuntimeError, match="outside of async context"): + submitter.require_await(awaitable) + awaitable.close() + + +def test_maybe_submit_outside_context_returns_awaitable() -> None: + submitter = Submitter() + + async def coro() -> str: + return "value" + + awaitable = coro() + assert submitter.maybe_submit(awaitable) is awaitable + assert asyncio.run(awaitable) == "value" + + +def test_async_generator_to_sync_basic() -> None: + pytest.importorskip("greenlet") + + async def simple_async_gen() -> AsyncGenerator[str]: + yield "first" + yield "second" + + submitter = Submitter() + + def test_func() -> tuple[list[str], Any]: + sync_gen = async_generator_to_sync(simple_async_gen(), submitter) + values = [] + try: + while True: + values.append(next(sync_gen)) + except StopIteration as e: + return values, e.value + + values, final_value = asyncio.run(submitter.run(test_func)) + assert values == ["first", "second"] + assert final_value is None + + +def test_async_generator_to_sync_with_send() -> None: + pytest.importorskip("greenlet") + + async def async_gen_with_send() -> AsyncGenerator[str, str]: + sent1 = yield "initial" + sent2 = yield f"got_{sent1}" + yield f"final_{sent2}" + + submitter = Submitter() + + def test_func() -> tuple[str, str, str, Any]: + sync_gen = async_generator_to_sync(async_gen_with_send(), submitter) + value1 = next(sync_gen) + value2 = sync_gen.send("hello") + value3 = sync_gen.send("world") + try: + next(sync_gen) + raise AssertionError("should have raised StopIteration") + except StopIteration as e: + return value1, value2, value3, e.value + + value1, value2, value3, final = asyncio.run(submitter.run(test_func)) + assert value1 == "initial" + assert value2 == "got_hello" + assert value3 == "final_world" + assert final is None + + +def test_async_generator_to_sync_with_exception() -> None: + pytest.importorskip("greenlet") + + async def async_gen_with_exception() -> AsyncGenerator[str]: + try: + yield "before_exception" + yield "should_not_reach" + except ValueError as e: + yield f"caught_{e}" + + submitter = Submitter() + + def test_func() -> tuple[str, str, Any]: + sync_gen = async_generator_to_sync(async_gen_with_exception(), submitter) + value1 = next(sync_gen) + value2 = sync_gen.throw(ValueError("test_error")) + try: + next(sync_gen) + raise AssertionError("should have raised StopIteration") + except StopIteration as e: + return value1, value2, e.value + + value1, value2, final = asyncio.run(submitter.run(test_func)) + assert value1 == "before_exception" + assert value2 == "caught_test_error" + assert final is None + + +def test_async_generator_to_sync_inactive_submitter() -> None: + async def simple_async_gen() -> AsyncGenerator[str]: + yield "test" # pragma: no cover + + submitter = Submitter() + + with pytest.raises(RuntimeError, match="outside of async context"): + sync_gen = async_generator_to_sync(simple_async_gen(), submitter) + next(sync_gen) + + +def test_submitter_repr() -> None: + submitter = Submitter() + assert repr(submitter) == "" diff --git a/testing/test_multicall.py b/testing/test_multicall.py index 5e110d3f..698089b8 100644 --- a/testing/test_multicall.py +++ b/testing/test_multicall.py @@ -9,6 +9,7 @@ from pluggy import HookspecMarker from pluggy import NormalImpl from pluggy import WrapperImpl +from pluggy._async import Submitter from pluggy._callers import _multicall @@ -30,7 +31,9 @@ def MC( wrapper_impls.append(f) else: normal_impls.append(f) - return _multicall("foo", normal_impls, wrapper_impls, kwargs, firstresult) + return _multicall( + "foo", normal_impls, wrapper_impls, kwargs, firstresult, Submitter() + ) def test_keyword_args() -> None: diff --git a/uv.lock b/uv.lock index 89422ba1..237a937e 100644 --- a/uv.lock +++ b/uv.lock @@ -167,6 +167,92 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/4f/4af258e1ce388eb64be82d6466b7d0b3bc3eede154e14c3360a76a759b71/greenlet-3.5.6-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:95e7c44d072db623a1aab04ce488cf9533294a77ed9d072cd503a3596f4106ac", size = 292967, upload-time = "2026-09-14T14:26:34.475Z" }, + { url = "https://files.pythonhosted.org/packages/1d/05/dc0d54e90af2f192724936799cee990687792ff54a4638d3ae0e0a0145c0/greenlet-3.5.6-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7d501d5eb5d4f67207df364752ad697465b834268744be7581c18d81d35d41d", size = 609284, upload-time = "2026-09-14T15:11:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/c1/51/826b4fe7bc39c3f910c95e889a981c827f72a611fc035f31816a256cf043/greenlet-3.5.6-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a364c1ea75dc51b83a17f52fe0c79cf8bc4ddf740403bebd4581c7666eea017d", size = 622648, upload-time = "2026-09-14T15:20:39.198Z" }, + { url = "https://files.pythonhosted.org/packages/55/3b/f2d36fc38934588dff6d6ec9288934c9611abac7a79c866c2002fc707698/greenlet-3.5.6-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5599b380c1f28efeb724e81569eac80cd92f99a85bd9775456caaf3225d40b11", size = 629557, upload-time = "2026-09-14T15:25:03.036Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/092682ae7ca1aadd44aa36b0bc35c48c9ed55f9ffebb5794980b1e2ae74b/greenlet-3.5.6-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eed88b64a5e5da72d6a71cdc5aaeefaa5ced9b748f8d19f89800b339961dad39", size = 622813, upload-time = "2026-09-14T14:35:54.845Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/4167f0e44e795840cbc219b2e5d8f576492192cd9e3e17e827db20287daa/greenlet-3.5.6-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:5bbda3c70dd35d60671bc33b01916802707a052130d9e50cdb871d34594d35cb", size = 425482, upload-time = "2026-09-14T15:28:34.144Z" }, + { url = "https://files.pythonhosted.org/packages/07/59/9c6b723a559b53bee0980a5dc57577d434891dd454f0fe54ed6e22d4e4ce/greenlet-3.5.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:874cea8bb1ec1ddccbacbd027856f6bf496f6bc18aba97a918c20e067edab236", size = 1585817, upload-time = "2026-09-14T15:10:03.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/d4/2bceaa305ff0ecf99511480da8f0a866eb2469a68da8715008a0d3e8f65f/greenlet-3.5.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:128813fc29f2336a21b4d06eedd5e16bcc7ea46f59e9ff1cb30ea70e48195d88", size = 1650177, upload-time = "2026-09-14T14:35:46.736Z" }, + { url = "https://files.pythonhosted.org/packages/7a/33/c57855a6abada0c7bfb9c6c0f33df1fbed8e5224708478dbcdff6c5db490/greenlet-3.5.6-cp310-cp310-win_amd64.whl", hash = "sha256:dad3d233d441a022c1f7155f0fb9d5aff7b97c1ea8c7dfa02cce586b16ab2d0b", size = 322896, upload-time = "2026-09-14T14:24:52.308Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d7/41511ee2696f14be4200b524d9553dc4295e2bdeb20aa8962c3cb25e71c6/greenlet-3.5.6-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a6a4b98a9132e0f45c9fc245a63894cfd8c45fb7a0d6bffc5eab3ec327cf7324", size = 294075, upload-time = "2026-09-14T14:25:16.922Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7b/b509624970909294cd064ff7346148ca9941c21bec9026d7873dd254e9fa/greenlet-3.5.6-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45bfd2b51e38aaa5f9849f114d9c7c1d75f69187c849b3549cd64c465283abfa", size = 613429, upload-time = "2026-09-14T15:12:00.454Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5c/d2eb503067f9ba20875ef8c87681f29a64f53bbbbe4059a5d7c53179d442/greenlet-3.5.6-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c6dede9133e1da41d561bc3fb14e92b47e2ce39ae60edefaad145658ea7c5e2", size = 625405, upload-time = "2026-09-14T15:20:41.053Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/9b071d11c8bb9f5f38cccacc38fcc234d91997a4c395cc2bf43ecae89642/greenlet-3.5.6-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4fb8e59f68845d56c23c031dcd79c329f345e4a9d2ffac91c3d1ab366bdc457b", size = 633270, upload-time = "2026-09-14T15:25:04.864Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d3/63d4477ce31dff2fd802a9a20240f6606aac85977e0fb18443aae33de3f6/greenlet-3.5.6-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c20ea32a73d17b9b60e3371240e17b0068120c98a5ec01a224a7dd8c89733ba", size = 624428, upload-time = "2026-09-14T14:35:56.895Z" }, + { url = "https://files.pythonhosted.org/packages/88/17/ac11883ecc9da19c681c8b763ee39e6f7dca2aa81874eb11a075d3cbeb00/greenlet-3.5.6-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d701eab36200c36224833d07dbdb709adb7fd4253429548ddb5e547b8ed40586", size = 428068, upload-time = "2026-09-14T15:28:35.872Z" }, + { url = "https://files.pythonhosted.org/packages/ad/aa/9cde4e00688eaa2a03b91d12e4681439a87e6aad860399e0847af6a014ca/greenlet-3.5.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a0b2791239c99992a86c1b635b787fe2a877d9eaaa26f8891ce943832b585ae", size = 1588385, upload-time = "2026-09-14T15:10:05.386Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/24632b5ec186b64e21e07a8f53ce5e15a7e9cb33eddee99a5fe16379afa5/greenlet-3.5.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:188bf333769b7145e2b0b4a7f09615ec550ed44d3a2a8395fb7b36f0e9901e13", size = 1653119, upload-time = "2026-09-14T14:35:48.275Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6c/019d2ef898f4b9ac845167f1c6f73229e9a4e2439362a5e2ce50205a19b0/greenlet-3.5.6-cp311-cp311-win_amd64.whl", hash = "sha256:a6b4ff33f7e011bbaa148238d131c4fd4f8afbab3c104ddfbdb2b12b74ff7016", size = 323317, upload-time = "2026-09-14T14:22:38.836Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7a/439df999455e3bdf02b1c68f3848d4020385ef0a01f89f706b07bf148a65/greenlet-3.5.6-cp311-cp311-win_arm64.whl", hash = "sha256:59deccd347735a7774223b05a93773fddbb298aba3cea21be4337fb4752dbe32", size = 307739, upload-time = "2026-09-14T14:23:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" }, + { url = "https://files.pythonhosted.org/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f2/844dc823ff2752ad049caa6b59d57e4572f9c445934b02d3518f4c67197c/greenlet-3.5.6-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee7d9da3bf493909cf811a3f038840cb34fab5ae2956b8a263919f6e289ab188", size = 629529, upload-time = "2026-09-14T15:25:06.354Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/c0/42/b1f8dbc89a53b9e77859fc1ad1627d106fc361daa3ea4bdf43a91ebb4338/greenlet-3.5.6-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:71890d5247020c25c21a6b65202782bfc281d4e6e244842419d30e3492bb6dcc", size = 432385, upload-time = "2026-09-14T15:28:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" }, + { url = "https://files.pythonhosted.org/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" }, + { url = "https://files.pythonhosted.org/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627, upload-time = "2026-09-14T14:24:40.102Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356, upload-time = "2026-09-14T15:12:03.399Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756, upload-time = "2026-09-14T15:20:44.269Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7c/40400455f5b5a65bb83e94fde66d1be9e5ec518638113f8083ace746c309/greenlet-3.5.6-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a93ee7c6e8fd0f8a83525a51bd777be57ee17787e91d805bd8d6faf9dcada18e", size = 632632, upload-time = "2026-09-14T15:25:07.813Z" }, + { url = "https://files.pythonhosted.org/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779, upload-time = "2026-09-14T14:36:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/f9/67/1f35cff30a6c51c3f23b63d4afcc7313ab4f97490ba3676fa78178984b27/greenlet-3.5.6-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7f731ebac68ea06d628658295cb2d217b10186329fcf9a3b6a149045059bf92e", size = 434933, upload-time = "2026-09-14T15:28:38.858Z" }, + { url = "https://files.pythonhosted.org/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930, upload-time = "2026-09-14T15:10:08.234Z" }, + { url = "https://files.pythonhosted.org/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590, upload-time = "2026-09-14T14:35:51.173Z" }, + { url = "https://files.pythonhosted.org/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086, upload-time = "2026-09-14T14:23:48.428Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211, upload-time = "2026-09-14T14:28:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/631bb45fafde1dca782152377c0676d182ec924820064047f533a3627b28/greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b", size = 677688, upload-time = "2026-09-14T15:25:09.279Z" }, + { url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/2b0a73e68e1e18e30b601d0d183cfdfc2beca4de5a6843c630f0fc9fb90c/greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77", size = 480475, upload-time = "2026-09-14T15:28:40.741Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" }, + { url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" }, + { url = "https://files.pythonhosted.org/packages/71/76/3c11c21e0716b1f1dc7c1a4b3d690abb1d3b448c69a9d32049fecb64010a/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d", size = 681303, upload-time = "2026-09-14T15:25:11.088Z" }, + { url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" }, + { url = "https://files.pythonhosted.org/packages/3f/26/3ae402202452cd5941bbbd483e5a74297e2397e7aa3182c2a5e3ab7d5666/greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81", size = 510112, upload-time = "2026-09-14T15:28:42.112Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" }, + { url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" }, + { url = "https://files.pythonhosted.org/packages/f5/41/b3114c97c10e796010f00a30f51c81470072bca4b53e396ccca87484fcf7/greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4", size = 681337, upload-time = "2026-09-14T15:25:12.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" }, + { url = "https://files.pythonhosted.org/packages/48/1b/d41861c2fa00968e39e467a495ca8db9ce9b6310a5d9b57561b3d0dc48fa/greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942", size = 487339, upload-time = "2026-09-14T15:28:43.497Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" }, + { url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" }, + { url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" }, + { url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d1/039c353d5593a97a89699e989324c9bc86af499e6c6152fe0180f5742204/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577", size = 686023, upload-time = "2026-09-14T15:25:14.528Z" }, + { url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" }, + { url = "https://files.pythonhosted.org/packages/8a/62/97ceb8e0b2ea96046cdf8e95b042715020ebb12d83ea0690db80a8f03d23/greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7", size = 517058, upload-time = "2026-09-14T15:28:44.924Z" }, + { url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" }, + { url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" }, + { url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" }, +] + [[package]] name = "identify" version = "2.6.19" @@ -216,22 +302,31 @@ wheels = [ name = "pluggy" source = { editable = "." } +[package.optional-dependencies] +async = [ + { name = "greenlet" }, +] + [package.dev-dependencies] dev = [ { name = "pre-commit" }, ] testing = [ { name = "coverage" }, + { name = "greenlet" }, { name = "pytest" }, { name = "pytest-benchmark" }, ] [package.metadata] +requires-dist = [{ name = "greenlet", marker = "extra == 'async'" }] +provides-extras = ["async"] [package.metadata.requires-dev] dev = [{ name = "pre-commit" }] testing = [ { name = "coverage" }, + { name = "greenlet" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-benchmark" }, ] From d1b2fcbc2541147f75ea0b27076880c844c324c1 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 9 Sep 2026 22:07:12 +0200 Subject: [PATCH 10/10] fix(async): close awaitables require_await declines to await require_await raised before awaiting, so the awaitable handed to it was never awaited and never closed. async_generator_to_sync calls it as require_await(async_gen.__anext__()), which meant the orphaned coroutine was created inside the library and no caller could reach it to clean up. CPython emitted "RuntimeWarning: coroutine method 'asend' ... was never awaited" while finalizing the async generator; the pytest config's filterwarnings = error turned that into an exception inside __del__, surfacing as an unraisable that failed the 3.13 and 3.14 jobs. require_await now takes ownership of the awaitable either way, closing it before raising when inactive. This matches maybe_submit, which hands the awaitable back to the caller when inactive rather than dropping it. Use getattr for close since Awaitable does not guarantee it. test_require_await_outside_context papered over this with a manual close(); it now asserts the coroutine was closed, which fails without the fix on every supported version rather than only on 3.13+. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Claude Code --- src/pluggy/_async.py | 12 +++++++++++- testing/test_async.py | 6 +++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/pluggy/_async.py b/src/pluggy/_async.py index e5a00970..83190cb9 100644 --- a/src/pluggy/_async.py +++ b/src/pluggy/_async.py @@ -67,12 +67,22 @@ def maybe_submit(self, coro: Awaitable[_T]) -> _T | Awaitable[_T]: return coro def require_await(self, coro: Awaitable[_T]) -> _T: - """Await an awaitable, raising an error if not in async context.""" + """Await an awaitable, raising an error if not in async context. + + Ownership of ``coro`` is taken either way: when inactive it is closed + before raising, so callers never leak a coroutine that would later + warn about never having been awaited. + """ active = self._active_submitter if active is not None: res: _T = active.switch(coro) return res else: + # Not going to await it, so dispose of it rather than let it be + # collected unawaited (a RuntimeWarning raised inside __del__). + close = getattr(coro, "close", None) + if close is not None: + close() raise RuntimeError("require_await called outside of async context") async def run(self, sync_func: Callable[[], _T]) -> _T: diff --git a/testing/test_async.py b/testing/test_async.py index 2b8c790e..b3510831 100644 --- a/testing/test_async.py +++ b/testing/test_async.py @@ -8,6 +8,7 @@ from collections.abc import AsyncGenerator from collections.abc import Awaitable from collections.abc import Coroutine +import inspect from typing import Any from typing import cast @@ -179,7 +180,10 @@ async def coro() -> None: awaitable = coro() with pytest.raises(RuntimeError, match="outside of async context"): submitter.require_await(awaitable) - awaitable.close() + # require_await takes ownership either way: an awaitable it declines to + # await is closed, not left to warn about never being awaited when + # collected. + assert inspect.getcoroutinestate(awaitable) == inspect.CORO_CLOSED def test_maybe_submit_outside_context_returns_awaitable() -> None: