Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,4 @@ repos:
- id: mypy
files: ^(src/|testing/)
args: []
additional_dependencies: [pytest]
additional_dependencies: [pytest, types-greenlet]
9 changes: 9 additions & 0 deletions changelog/710.feature.rst
Original file line number Diff line number Diff line change
@@ -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).
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
173 changes: 173 additions & 0 deletions src/pluggy/_async.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"""
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
<pluggy.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"<Submitter active={self._active_submitter is not None}>"

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.

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:
"""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
40 changes: 36 additions & 4 deletions src/pluggy/_caller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,7 +32,14 @@


_HookExec: TypeAlias = Callable[

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (complexity): Consider keeping _HookExec independent of Submitter and binding the async submitter once in a central factory to avoid signature churn and per-caller async state.

The added Submitter parameter on _HookExec and the per-caller _async_submitter field do introduce avoidable coupling and signature churn. You can keep all async functionality while simplifying by binding the Submitter once (e.g. in PluginManager) and restoring the original _HookExec surface.

Concretely:

  1. Keep _HookExec unaware of Submitter
    Change _HookExec back to the original signature and wrap the underlying implementation with a closure/partial that captures the Submitter:
# restore _HookExec type
_HookExec: TypeAlias = Callable[
    [str, Sequence[NormalImpl], Sequence[WrapperImpl], Mapping[str, object], bool],
    "object | list[object]",
]

# in PluginManager (or wherever _hookexec is created)
from functools import partial
from ._async import Submitter

def _create_hookexec(base_hookexec: Callable[..., object | list[object]],
                     submitter: Submitter) -> _HookExec:
    def hookexec(
        name: str,
        normal_impls: Sequence[NormalImpl],
        wrapper_impls: Sequence[WrapperImpl],
        kwargs: Mapping[str, object],
        firstresult: bool,
    ) -> object | list[object]:
        return base_hookexec(
            name,
            normal_impls,
            wrapper_impls,
            kwargs,
            firstresult,
            submitter,  # async plumbing stays here
        )

    return hookexec

# usage in PluginManager:
self._submitter = Submitter()
self._hookexec = _create_hookexec(base_hookexec, self._submitter)
  1. Drop per-caller async_submitter attributes and parameters
    Let NormalHookCaller and HistoricHookCaller just hold a bound _hookexec that already encapsulates the Submitter. Their call sites then remain simple:
class NormalHookCaller:
    __slots__ = ("name", "spec", "_hookexec", "_normal_hookimpls", "_wrapper_hookimpls")

    def __init__(
        self,
        name: str,
        hook_execute: _HookExec,
        specmodule_or_class: _Namespace | None = None,
        spec_config: HookspecConfiguration | None = None,
    ) -> None:
        self.name = name
        self._hookexec = hook_execute
        # ...

    def __call__(self, **kwargs: object) -> Any:
        self._verify_all_args_are_provided(kwargs)
        firstresult = self.spec.config.firstresult if self.spec else False
        return self._hookexec(
            self.name,
            self._normal_hookimpls.copy(),
            self._wrapper_hookimpls.copy(),
            kwargs,
            firstresult,
        )
class HistoricHookCaller:
    __slots__ = ("name", "spec", "_hookexec", "_hookimpls", "_call_history")

    def __init__(
        self,
        name: str,
        hook_execute: _HookExec,
        specmodule_or_class: _Namespace,
        spec_config: HookspecConfiguration,
    ) -> None:
        self.name = name
        self._hookexec = hook_execute
        # ...

    def call_historic(
        self,
        result_callback: Callable[[Any], None] | None = None,
        kwargs: Mapping[str, object] | None = None,
    ) -> None:
        kwargs = kwargs or {}
        self.spec.verify_all_args_are_provided(kwargs)
        self._call_history.append((kwargs, result_callback))
        res = self._hookexec(self.name, self._hookimpls.copy(), [], kwargs, False)
        # ...
  1. Simplify SubsetHookCaller to just delegate
    Once _hookexec is bound with the Submitter, SubsetHookCaller doesn’t need to reach into orig._async_submitter or propagate it:
class SubsetHookCaller:
    def __call__(self, **kwargs: object) -> Any:
        orig = self._orig
        assert isinstance(orig, NormalHookCaller)
        if orig.spec:
            orig.spec.verify_all_args_are_provided(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),
            kwargs,
            firstresult,
        )

    def call_historic(
        self,
        result_callback: Callable[[Any], None] | None = None,
        kwargs: Mapping[str, object] | None = None,
    ) -> None:
        orig = self._orig
        assert isinstance(orig, HistoricHookCaller)
        kwargs = kwargs or {}
        orig.spec.verify_all_args_are_provided(kwargs)
        orig._call_history.append((kwargs, result_callback))
        res = orig._hookexec(self.name, self._get_filtered(orig._hookimpls), [], kwargs, False)
        # ...

This keeps the async plumbing localized to the PluginManager (or central factory) that owns a single Submitter, avoids multiple instances, and restores a cleaner, decoupled hook-caller surface without changing behaviour.

[str, Sequence[NormalImpl], Sequence[WrapperImpl], Mapping[str, object], bool],
[
str,
Sequence[NormalImpl],
Sequence[WrapperImpl],
Mapping[str, object],
bool,
Submitter,
],
"object | list[object]",
]

Expand Down Expand Up @@ -153,6 +161,7 @@ class NormalHookCaller:
"""A caller of all registered implementations of a hook specification."""

__slots__ = (
"_async_submitter",
"_hookexec",
"_normal_hookimpls",
"_wrapper_hookimpls",
Expand All @@ -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]
Expand Down Expand Up @@ -262,6 +273,7 @@ def __call__(self, **kwargs: object) -> Any:
self._wrapper_hookimpls.copy(),
call_kwargs,
firstresult,
self._async_submitter,
)

def call_historic(
Expand Down Expand Up @@ -297,6 +309,7 @@ def call_extra(
self._wrapper_hookimpls.copy(),
kwargs,
firstresult,
self._async_submitter,
)

def _maybe_apply_history(self, method: HookImpl) -> None:
Expand All @@ -316,6 +329,7 @@ class HistoricHookCaller:
"""

__slots__ = (
"_async_submitter",
"_call_history",
"_hookexec",
"_hookimpls",
Expand All @@ -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] = []
Expand Down Expand Up @@ -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):
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -568,6 +599,7 @@ def call_extra(
self._get_filtered(orig._wrapper_hookimpls),
kwargs,
firstresult,
orig._async_submitter,
)

def __repr__(self) -> str:
Expand Down
10 changes: 10 additions & 0 deletions src/pluggy/_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading