Skip to content

Commit d23ca05

Browse files
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 <noreply@anthropic.com>
1 parent 3ce85b3 commit d23ca05

11 files changed

Lines changed: 686 additions & 15 deletions

File tree

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,4 @@ repos:
4949
- id: mypy
5050
files: ^(src/|testing/)
5151
args: []
52-
additional_dependencies: [pytest]
52+
additional_dependencies: [pytest, types-greenlet]

changelog/710.feature.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
New async bridge for awaitable hook results:
2+
``await pm.run_async(lambda: pm.hook.my_hook())`` runs the hook call in a
3+
greenlet context where awaitable results from hook implementations are
4+
awaited on the current event loop ("await-me-maybe"). Outside
5+
``run_async``, awaitable results are passed through unchanged, as before.
6+
Requires the optional ``greenlet`` dependency, installable via
7+
``pluggy[async]``. The bridge is a persistent per-manager ``Submitter``;
8+
async wrapper generators are not auto-awaited (a manual
9+
``async_generator_to_sync`` helper is provided).

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,13 @@ readme = {file = "README.rst", content-type = "text/x-rst"}
3535
requires-python = ">=3.10"
3636

3737
dynamic = ["version"]
38+
39+
[project.optional-dependencies]
40+
async = ["greenlet"]
41+
3842
[dependency-groups]
3943
dev = ["pre-commit", "tox"]
40-
testing = ["pytest", "pytest-benchmark", "coverage"]
44+
testing = ["pytest", "pytest-benchmark", "coverage", "greenlet"]
4145

4246

4347
[tool.setuptools]

src/pluggy/_async.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""
2+
Async support for pluggy using greenlets.
3+
4+
This module provides async functionality for pluggy, allowing hook
5+
implementations to return awaitable objects that are automatically awaited
6+
when running in an async context (see :meth:`PluginManager.run_async
7+
<pluggy.PluginManager.run_async>`).
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from collections.abc import AsyncGenerator
13+
from collections.abc import Awaitable
14+
from collections.abc import Callable
15+
from collections.abc import Generator
16+
from typing import Any
17+
from typing import cast
18+
from typing import Final
19+
from typing import TYPE_CHECKING
20+
from typing import TypeVar
21+
22+
23+
_T = TypeVar("_T")
24+
_Y = TypeVar("_Y")
25+
_S = TypeVar("_S")
26+
27+
if TYPE_CHECKING:
28+
import greenlet
29+
30+
31+
#: Sentinel distinguishing "worker never completed" from a legitimate
32+
#: ``None`` result.
33+
_UNSET: Final = object()
34+
35+
36+
class Submitter:
37+
"""Bridge between synchronous hook execution and an async event loop.
38+
39+
While :meth:`run` is active, awaitables passed to :meth:`maybe_submit` or
40+
:meth:`require_await` are switched to the awaiting parent greenlet, which
41+
awaits them and switches the result back. When inactive,
42+
:meth:`maybe_submit` passes awaitables through unchanged
43+
("await-me-maybe").
44+
"""
45+
46+
_active_submitter: greenlet.greenlet | None
47+
48+
def __init__(self) -> None:
49+
self._active_submitter = None
50+
51+
def __repr__(self) -> str:
52+
return f"<Submitter active={self._active_submitter is not None}>"
53+
54+
def maybe_submit(self, coro: Awaitable[_T]) -> _T | Awaitable[_T]:
55+
"""Await an awaitable if active, else return it unchanged.
56+
57+
This enables backward compatibility for datasette
58+
and https://simonwillison.net/2020/Sep/2/await-me-maybe/
59+
"""
60+
active = self._active_submitter
61+
if active is not None:
62+
# We're in a greenlet context; switch to the parent with the
63+
# awaitable. The parent awaits it and switches back the result.
64+
res: _T = active.switch(coro)
65+
return res
66+
else:
67+
return coro
68+
69+
def require_await(self, coro: Awaitable[_T]) -> _T:
70+
"""Await an awaitable, raising an error if not in async context."""
71+
active = self._active_submitter
72+
if active is not None:
73+
res: _T = active.switch(coro)
74+
return res
75+
else:
76+
raise RuntimeError("require_await called outside of async context")
77+
78+
async def run(self, sync_func: Callable[[], _T]) -> _T:
79+
"""Run a synchronous function in a worker greenlet with async support.
80+
81+
Awaitables submitted by hook implementations during the call are
82+
awaited on this coroutine's event loop.
83+
"""
84+
try:
85+
import greenlet
86+
except ImportError:
87+
raise RuntimeError("greenlet is required for async support") from None
88+
89+
if self._active_submitter is not None:
90+
raise RuntimeError("Submitter is already active")
91+
92+
main_greenlet = greenlet.getcurrent()
93+
result: object = _UNSET
94+
exception: BaseException | None = None
95+
96+
def greenlet_func() -> None:
97+
nonlocal result, exception
98+
try:
99+
result = sync_func()
100+
except BaseException as e:
101+
exception = e
102+
103+
worker_greenlet = greenlet.greenlet(greenlet_func)
104+
# Let maybe_submit/require_await switch back to this greenlet.
105+
self._active_submitter = main_greenlet
106+
try:
107+
# Run the worker; every switch back carries an awaitable to
108+
# process, until the worker finishes (switching back None).
109+
awaitable = worker_greenlet.switch()
110+
while awaitable is not None:
111+
try:
112+
awaited_result = await awaitable
113+
except BaseException as e:
114+
# Raise at the submission site inside the worker.
115+
awaitable = worker_greenlet.throw(e)
116+
else:
117+
awaitable = worker_greenlet.switch(awaited_result)
118+
finally:
119+
self._active_submitter = None
120+
121+
if exception is not None:
122+
raise exception
123+
assert result is not _UNSET, "worker greenlet did not complete"
124+
return cast(_T, result)
125+
126+
127+
def async_generator_to_sync(
128+
async_gen: AsyncGenerator[_Y, _S], submitter: Submitter
129+
) -> Generator[_Y, _S, None]:
130+
"""Convert an async generator to a sync generator using a `Submitter`.
131+
132+
This helper allows wrapper implementations to use async generators while
133+
maintaining compatibility with the sync generator interface expected by
134+
the hook system. The submitter must be active (i.e. the generator must be
135+
consumed under :meth:`Submitter.run`).
136+
"""
137+
try:
138+
# Start the async generator.
139+
value = submitter.require_await(async_gen.__anext__())
140+
141+
while True:
142+
try:
143+
sent_value = yield value
144+
try:
145+
value = submitter.require_await(async_gen.asend(sent_value))
146+
except StopAsyncIteration:
147+
return
148+
149+
except GeneratorExit:
150+
# Generator is being closed; close the async generator too.
151+
submitter.require_await(cast(Awaitable[Any], async_gen.aclose()))
152+
raise
153+
154+
except BaseException as exc:
155+
# Exception was thrown into the generator; forward it into
156+
# the async generator.
157+
try:
158+
value = submitter.require_await(async_gen.athrow(exc))
159+
except StopAsyncIteration:
160+
return
161+
162+
except StopAsyncIteration:
163+
return

src/pluggy/_caller.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from typing import TypeAlias
2020
from typing import TypeVar
2121

22+
from ._async import Submitter
2223
from ._config import HookimplConfiguration
2324
from ._config import hookspec_config_from_mapping
2425
from ._config import HookspecConfiguration
@@ -31,7 +32,14 @@
3132

3233

3334
_HookExec: TypeAlias = Callable[
34-
[str, Sequence[NormalImpl], Sequence[WrapperImpl], Mapping[str, object], bool],
35+
[
36+
str,
37+
Sequence[NormalImpl],
38+
Sequence[WrapperImpl],
39+
Mapping[str, object],
40+
bool,
41+
Submitter,
42+
],
3543
"object | list[object]",
3644
]
3745

@@ -158,6 +166,7 @@ class NormalHookCaller:
158166
"_hookexec",
159167
"_normal_hookimpls",
160168
"_wrapper_hookimpls",
169+
"_async_submitter",
161170
)
162171

163172
def __init__(
@@ -166,11 +175,13 @@ def __init__(
166175
hook_execute: _HookExec,
167176
specmodule_or_class: _Namespace | None = None,
168177
spec_config: HookspecConfiguration | None = None,
178+
async_submitter: Submitter | None = None,
169179
) -> None:
170180
""":meta private:"""
171181
#: Name of the hook getting called.
172182
self.name: Final = name
173183
self._hookexec: Final = hook_execute
184+
self._async_submitter: Final = async_submitter or Submitter()
174185
# Split hook implementations into two lists for simpler management:
175186
# Normal hooks: [trylast, normal, tryfirst]
176187
# Wrapper hooks: [trylast, normal, tryfirst]
@@ -263,6 +274,7 @@ def __call__(self, **kwargs: object) -> Any:
263274
self._wrapper_hookimpls.copy(),
264275
kwargs,
265276
firstresult,
277+
self._async_submitter,
266278
)
267279

268280
def call_historic(
@@ -296,6 +308,7 @@ def call_extra(
296308
self._wrapper_hookimpls.copy(),
297309
kwargs,
298310
firstresult,
311+
self._async_submitter,
299312
)
300313

301314
def _maybe_apply_history(self, method: HookImpl) -> None:
@@ -320,6 +333,7 @@ class HistoricHookCaller:
320333
"_hookexec",
321334
"_hookimpls",
322335
"_call_history",
336+
"_async_submitter",
323337
)
324338

325339
spec: HookSpec
@@ -330,12 +344,14 @@ def __init__(
330344
hook_execute: _HookExec,
331345
specmodule_or_class: _Namespace,
332346
spec_config: HookspecConfiguration,
347+
async_submitter: Submitter | None = None,
333348
) -> None:
334349
""":meta private:"""
335350
assert spec_config.historic, "HistoricHookCaller requires historic=True"
336351
#: Name of the hook getting called.
337352
self.name: Final = name
338353
self._hookexec: Final = hook_execute
354+
self._async_submitter: Final = async_submitter or Submitter()
339355
# The hookimpls list for historic hooks (no wrappers supported).
340356
self._hookimpls: Final[list[NormalImpl]] = []
341357
self._call_history: Final[_CallHistory] = []
@@ -408,7 +424,14 @@ def call_historic(
408424
# Historizing hooks don't return results.
409425
# Remember firstresult isn't compatible with historic.
410426
# Copy because plugins may register other plugins during iteration (#438).
411-
res = self._hookexec(self.name, self._hookimpls.copy(), [], kwargs, False)
427+
res = self._hookexec(
428+
self.name,
429+
self._hookimpls.copy(),
430+
[],
431+
kwargs,
432+
False,
433+
self._async_submitter,
434+
)
412435
if result_callback is None:
413436
return
414437
if isinstance(res, list):
@@ -427,7 +450,9 @@ def _maybe_apply_history(self, method: HookImpl) -> None:
427450
"""Apply call history to a new hookimpl."""
428451
assert isinstance(method, NormalImpl)
429452
for kwargs, result_callback in self._call_history:
430-
res = self._hookexec(self.name, [method], [], kwargs, False)
453+
res = self._hookexec(
454+
self.name, [method], [], kwargs, False, self._async_submitter
455+
)
431456
if res and result_callback is not None:
432457
# XXX: remember firstresult isn't compat with historic
433458
assert isinstance(res, list)
@@ -507,6 +532,7 @@ def __call__(self, **kwargs: object) -> Any:
507532
self._get_filtered(orig._wrapper_hookimpls),
508533
kwargs,
509534
firstresult,
535+
orig._async_submitter,
510536
)
511537

512538
def call_historic(
@@ -527,7 +553,12 @@ def call_historic(
527553
# History is shared with the original caller.
528554
orig._call_history.append((kwargs, result_callback))
529555
res = orig._hookexec(
530-
self.name, self._get_filtered(orig._hookimpls), [], kwargs, False
556+
self.name,
557+
self._get_filtered(orig._hookimpls),
558+
[],
559+
kwargs,
560+
False,
561+
orig._async_submitter,
531562
)
532563
if result_callback is None:
533564
return
@@ -562,6 +593,7 @@ def call_extra(
562593
self._get_filtered(orig._wrapper_hookimpls),
563594
kwargs,
564595
firstresult,
596+
orig._async_submitter,
565597
)
566598

567599
def __repr__(self) -> str:

src/pluggy/_execution.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from __future__ import annotations
66

7+
from collections.abc import Awaitable
78
from collections.abc import Generator
89
from collections.abc import Mapping
910
from collections.abc import Sequence
@@ -20,6 +21,10 @@
2021
from ._warnings import PluggyTeardownRaisedWarning
2122

2223

24+
if TYPE_CHECKING:
25+
from ._async import Submitter
26+
27+
2328
# Need to distinguish between old- and new-style hook wrappers.
2429
# Wrapping with a tuple is the fastest type-safe way I found to do it.
2530
Teardown: TypeAlias = Generator[None, object, object]
@@ -86,6 +91,7 @@ def _multicall(
8691
wrapper_impls: Sequence[WrapperImpl],
8792
caller_kwargs: Mapping[str, object],
8893
firstresult: bool,
94+
async_submitter: Submitter,
8995
) -> object | list[object]:
9096
"""Execute a call into multiple python functions/methods and return the
9197
result(s).
@@ -117,6 +123,10 @@ def _multicall(
117123
args = normal_impl._get_call_args(caller_kwargs)
118124
res = normal_impl.function(*args)
119125
if res is not None:
126+
# Awaitable results are awaited when a Submitter is active
127+
# (await-me-maybe), otherwise passed through unchanged.
128+
if isinstance(res, Awaitable):
129+
res = async_submitter.maybe_submit(res)
120130
results.append(res)
121131
if firstresult: # halt further impl calls
122132
break

0 commit comments

Comments
 (0)