pyodide-pool 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyodide_pool-0.1.0/.gitignore +12 -0
- pyodide_pool-0.1.0/PKG-INFO +9 -0
- pyodide_pool-0.1.0/pyodide_pool/__init__.py +83 -0
- pyodide_pool-0.1.0/pyodide_pool/_bridge.py +236 -0
- pyodide_pool-0.1.0/pyodide_pool/_packages.py +99 -0
- pyodide_pool-0.1.0/pyodide_pool/loader.py +68 -0
- pyodide_pool-0.1.0/pyodide_pool/scheduler.py +209 -0
- pyodide_pool-0.1.0/pyproject.toml +24 -0
- pyodide_pool-0.1.0/wasm_multiprocessing/pyproject.toml +21 -0
- pyodide_pool-0.1.0/wasm_multiprocessing/wasm_multiprocessing/__init__.py +94 -0
- pyodide_pool-0.1.0/wasm_multiprocessing/wasm_multiprocessing/pool.py +483 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyodide-pool
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run Python callables and dask graphs on a pool of Pyodide Web Workers (driver-side API)
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: cloudpickle
|
|
8
|
+
Provides-Extra: dask
|
|
9
|
+
Requires-Dist: dask; extra == 'dask'
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Run Python callables and dask graphs on a pool of Pyodide Web Workers.
|
|
2
|
+
|
|
3
|
+
This package runs in the MAIN Pyodide instance (the driver). The embedding
|
|
4
|
+
JS creates a ``PyodidePool`` and registers it before use::
|
|
5
|
+
|
|
6
|
+
pyodide.registerJsModule('js_pyodide_pool', { pool })
|
|
7
|
+
|
|
8
|
+
Inside a Pyodide kernel with no JS embedder (JupyterLite notebooks), let
|
|
9
|
+
``loader`` pull in the self-contained JS bundle instead::
|
|
10
|
+
|
|
11
|
+
pool = await pyodide_pool.create_pool(pool_size=4)
|
|
12
|
+
|
|
13
|
+
Driver usage::
|
|
14
|
+
|
|
15
|
+
import pyodide_pool
|
|
16
|
+
|
|
17
|
+
value = await pyodide_pool.submit(fn, *args, **kwargs) # one remote call
|
|
18
|
+
result = await pyodide_pool.compute(delayed_obj) # dask collections
|
|
19
|
+
values = await pyodide_pool.get(dsk, keys) # raw dask graph
|
|
20
|
+
|
|
21
|
+
Design: docs/architecture/dask-scheduler-design.md. The package is pure
|
|
22
|
+
Python with no hard dask dependency — ``compute``/``get`` import dask lazily
|
|
23
|
+
via ``scheduler`` — so it can also back the Phase 06 multiprocessing shim.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import sys
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import cloudpickle
|
|
32
|
+
|
|
33
|
+
from ._bridge import (
|
|
34
|
+
RemoteExecutionError,
|
|
35
|
+
RemoteTraceback,
|
|
36
|
+
WorkerPool,
|
|
37
|
+
default_pool,
|
|
38
|
+
set_default_pool,
|
|
39
|
+
submit,
|
|
40
|
+
)
|
|
41
|
+
from ._packages import EXCLUDED_FROM_MIRROR, PackageSnapshot, snapshot_packages
|
|
42
|
+
|
|
43
|
+
__version__ = "0.1.0"
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"EXCLUDED_FROM_MIRROR",
|
|
47
|
+
"PackageSnapshot",
|
|
48
|
+
"RemoteExecutionError",
|
|
49
|
+
"RemoteTraceback",
|
|
50
|
+
"WorkerPool",
|
|
51
|
+
"compute",
|
|
52
|
+
"create_pool",
|
|
53
|
+
"default_pool",
|
|
54
|
+
"get",
|
|
55
|
+
"set_default_pool",
|
|
56
|
+
"snapshot_packages",
|
|
57
|
+
"submit",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
# Helpers from this package that end up inside task payloads (the
|
|
61
|
+
# scheduler's nested-expression evaluator, user wrappers) must travel BY
|
|
62
|
+
# VALUE so workers never need pyodide_pool installed. Registering the
|
|
63
|
+
# package covers all submodules — cloudpickle walks parent packages.
|
|
64
|
+
cloudpickle.register_pickle_by_value(sys.modules[__name__])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def __getattr__(name: str) -> Any:
|
|
68
|
+
# compute/get live in .scheduler, resolved lazily: scheduler pulls in
|
|
69
|
+
# dask, which must not become an import-time dependency of the package.
|
|
70
|
+
if name in ("compute", "get"):
|
|
71
|
+
from . import scheduler
|
|
72
|
+
|
|
73
|
+
value = getattr(scheduler, name)
|
|
74
|
+
globals()[name] = value
|
|
75
|
+
return value
|
|
76
|
+
# create_pool lives in .loader, resolved lazily for symmetry: it is only
|
|
77
|
+
# meaningful where the JS bundle is reachable (browser kernels, Node).
|
|
78
|
+
if name == "create_pool":
|
|
79
|
+
from .loader import create_pool
|
|
80
|
+
|
|
81
|
+
globals()[name] = create_pool
|
|
82
|
+
return create_pool
|
|
83
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Driver-side bridge to the JS ``PyodidePool``.
|
|
2
|
+
|
|
3
|
+
This module runs in the MAIN Pyodide instance (the driver), never in
|
|
4
|
+
workers: worker-executed code travels inside cloudpickle payloads (the
|
|
5
|
+
package is registered pickle-by-value in ``__init__``), so workers never
|
|
6
|
+
import this package and it is never installed there.
|
|
7
|
+
|
|
8
|
+
``submit`` (one call over ``runPickled``) and ``submit_batch`` (many calls
|
|
9
|
+
as one ``mapPickled`` run) are the only choke points between driver Python
|
|
10
|
+
and the pool: cloudpickle each ``(func, args, kwargs)`` call triple, hand
|
|
11
|
+
the bytes to the JS pool as transferable buffers together with the current
|
|
12
|
+
package snapshot, await the JS promise (directly awaitable on Pyodide's
|
|
13
|
+
event loop), and unpickle the results — or re-raise the remote exception
|
|
14
|
+
with its original traceback attached.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from typing import Any, NoReturn
|
|
21
|
+
|
|
22
|
+
import cloudpickle
|
|
23
|
+
from js import Object, Uint8Array
|
|
24
|
+
from pyodide.ffi import JsException, to_js
|
|
25
|
+
|
|
26
|
+
from ._packages import snapshot_packages
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"BatchRun",
|
|
30
|
+
"RemoteExecutionError",
|
|
31
|
+
"RemoteTraceback",
|
|
32
|
+
"WorkerPool",
|
|
33
|
+
"default_pool",
|
|
34
|
+
"set_default_pool",
|
|
35
|
+
"submit",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
#: One driver call shipped to a worker: ``(func, args, kwargs)``.
|
|
39
|
+
Call = tuple[Any, tuple[Any, ...], dict[str, Any]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RemoteTraceback(Exception):
|
|
43
|
+
"""Formatted traceback of an exception raised on a pool worker.
|
|
44
|
+
|
|
45
|
+
Attached as ``__cause__`` when the original exception re-raises on the
|
|
46
|
+
driver (the concurrent.futures pattern), so the user sees where their
|
|
47
|
+
code actually failed, not where the bridge noticed.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, formatted: str) -> None:
|
|
51
|
+
super().__init__(formatted)
|
|
52
|
+
self.formatted = formatted
|
|
53
|
+
|
|
54
|
+
def __str__(self) -> str:
|
|
55
|
+
return f'\n"""\n{self.formatted}"""'
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RemoteExecutionError(Exception):
|
|
59
|
+
"""A worker task failed and the original exception could not be
|
|
60
|
+
reconstructed (it was unpicklable); carries the remote traceback text."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, message: str, remote_traceback: str | None = None) -> None:
|
|
63
|
+
super().__init__(message)
|
|
64
|
+
self.remote_traceback = remote_traceback
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _resolve_js_pool() -> Any:
|
|
68
|
+
try:
|
|
69
|
+
import js_pyodide_pool # registered by the embedding JS
|
|
70
|
+
except ImportError as exc:
|
|
71
|
+
raise RuntimeError(
|
|
72
|
+
"No JS worker pool is registered. Create a PyodidePool in JS and "
|
|
73
|
+
"expose it with pyodide.registerJsModule('js_pyodide_pool', "
|
|
74
|
+
"{ pool }) before using pyodide_pool."
|
|
75
|
+
) from exc
|
|
76
|
+
pool = getattr(js_pyodide_pool, "pool", None)
|
|
77
|
+
if pool is None:
|
|
78
|
+
raise RuntimeError(
|
|
79
|
+
"The js_pyodide_pool module has no 'pool' attribute; register the "
|
|
80
|
+
"JS pool as pyodide.registerJsModule('js_pyodide_pool', { pool })."
|
|
81
|
+
)
|
|
82
|
+
return pool
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _to_js_buffer(data: bytes) -> Any:
|
|
86
|
+
"""bytes -> plain JS ArrayBuffer (transferable; runPickled detaches it)."""
|
|
87
|
+
view = Uint8Array.new(len(data))
|
|
88
|
+
view.assign(data)
|
|
89
|
+
return view.buffer
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _to_py_bytes(js_buffer: Any) -> bytes:
|
|
93
|
+
"""JS ArrayBuffer (or any view over one) -> bytes."""
|
|
94
|
+
return Uint8Array.new(js_buffer).to_bytes()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _raise_remote(exc: JsException) -> NoReturn:
|
|
98
|
+
"""Re-raise a failed ``runPickled`` as the original Python exception.
|
|
99
|
+
|
|
100
|
+
``JsException`` is itself a JsProxy of the JS error (``PyodideTaskError``),
|
|
101
|
+
so the worker's ``pythonTraceback`` and cloudpickled ``exceptionPayload``
|
|
102
|
+
read straight off the caught exception. Undefined properties surface as
|
|
103
|
+
AttributeError, which ``getattr`` defaults cover.
|
|
104
|
+
"""
|
|
105
|
+
formatted = getattr(exc, "pythonTraceback", None)
|
|
106
|
+
payload = getattr(exc, "exceptionPayload", None)
|
|
107
|
+
if payload is not None:
|
|
108
|
+
try:
|
|
109
|
+
remote = cloudpickle.loads(_to_py_bytes(payload))
|
|
110
|
+
except BaseException:
|
|
111
|
+
remote = None
|
|
112
|
+
if isinstance(remote, BaseException):
|
|
113
|
+
if isinstance(formatted, str) and formatted:
|
|
114
|
+
raise remote from RemoteTraceback(formatted)
|
|
115
|
+
raise remote from exc
|
|
116
|
+
message = getattr(exc, "message", None) or str(exc)
|
|
117
|
+
raise RemoteExecutionError(message, remote_traceback=formatted) from exc
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class BatchRun:
|
|
121
|
+
"""Driver-side handle over one JS ``mapPickled`` run.
|
|
122
|
+
|
|
123
|
+
Returned by :meth:`WorkerPool.start_batch` so callers (the Phase 06
|
|
124
|
+
multiprocessing shim's ``terminate``) can cancel queued-not-started
|
|
125
|
+
payloads while the run is in flight; plain callers use
|
|
126
|
+
:meth:`WorkerPool.submit_batch` and never see this class.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self, js_run: Any) -> None:
|
|
130
|
+
self._js_run = js_run
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def run_id(self) -> int:
|
|
134
|
+
"""worker-pool run id; ``-1`` for an empty batch (nothing to cancel)."""
|
|
135
|
+
return int(self._js_run.runId)
|
|
136
|
+
|
|
137
|
+
def cancel(self) -> None:
|
|
138
|
+
"""Cancel payloads not yet started; :meth:`results` then raises the
|
|
139
|
+
worker-pool cancellation error. In-flight payloads finish."""
|
|
140
|
+
self._js_run.cancel()
|
|
141
|
+
|
|
142
|
+
async def results(self) -> list[Any]:
|
|
143
|
+
"""Await the whole run; unpickled results in input order. A failed
|
|
144
|
+
payload re-raises its original remote exception — ``mapPickled``
|
|
145
|
+
rejects with the first failure once the run settles, matching stdlib
|
|
146
|
+
``Pool.map`` surfacing one exception."""
|
|
147
|
+
try:
|
|
148
|
+
buffers = await self._js_run.promise
|
|
149
|
+
except JsException as exc:
|
|
150
|
+
_raise_remote(exc)
|
|
151
|
+
return [cloudpickle.loads(_to_py_bytes(buffer)) for buffer in buffers]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class WorkerPool:
|
|
155
|
+
"""Thin Python handle over the JS ``PyodidePool``.
|
|
156
|
+
|
|
157
|
+
Wraps the pool registered as ``js_pyodide_pool.pool`` by default; pass a
|
|
158
|
+
JsProxy of another pool for explicit wiring (tests, multiple pools).
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
def __init__(self, js_pool: Any = None) -> None:
|
|
162
|
+
self._js_pool = js_pool if js_pool is not None else _resolve_js_pool()
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def pool_size(self) -> int:
|
|
166
|
+
"""Maximum number of concurrent workers in the JS pool."""
|
|
167
|
+
return int(self._js_pool.poolSize)
|
|
168
|
+
|
|
169
|
+
async def submit(self, func: Any, /, *args: Any, **kwargs: Any) -> Any:
|
|
170
|
+
"""Run ``func(*args, **kwargs)`` on a pool worker; return its result.
|
|
171
|
+
|
|
172
|
+
The call triple ships as a cloudpickle payload together with the
|
|
173
|
+
driver's current package snapshot, so the worker mirrors the
|
|
174
|
+
driver's installed packages before executing.
|
|
175
|
+
"""
|
|
176
|
+
payload = _to_js_buffer(cloudpickle.dumps((func, args, kwargs)))
|
|
177
|
+
packages, wheels = snapshot_packages()
|
|
178
|
+
options = to_js(
|
|
179
|
+
{"packages": packages, "wheels": wheels},
|
|
180
|
+
dict_converter=Object.fromEntries,
|
|
181
|
+
)
|
|
182
|
+
try:
|
|
183
|
+
result = await self._js_pool.runPickled(payload, options)
|
|
184
|
+
except JsException as exc:
|
|
185
|
+
_raise_remote(exc)
|
|
186
|
+
return cloudpickle.loads(_to_py_bytes(result))
|
|
187
|
+
|
|
188
|
+
def start_batch(self, calls: Sequence[Call]) -> BatchRun:
|
|
189
|
+
"""Dispatch ``calls`` as ONE JS ``mapPickled`` run and return its
|
|
190
|
+
handle without awaiting.
|
|
191
|
+
|
|
192
|
+
One run means one FIFO queue, input-order results, and one
|
|
193
|
+
``cancel`` handle for the whole batch, at one task message per call
|
|
194
|
+
— the batching substrate of the multiprocessing shim's ``chunksize``.
|
|
195
|
+
"""
|
|
196
|
+
payloads = [_to_js_buffer(cloudpickle.dumps(call)) for call in calls]
|
|
197
|
+
packages, wheels = snapshot_packages()
|
|
198
|
+
options = to_js(
|
|
199
|
+
{"packages": packages, "wheels": wheels},
|
|
200
|
+
dict_converter=Object.fromEntries,
|
|
201
|
+
)
|
|
202
|
+
return BatchRun(self._js_pool.mapPickled(to_js(payloads), options))
|
|
203
|
+
|
|
204
|
+
async def submit_batch(self, calls: Sequence[Call]) -> list[Any]:
|
|
205
|
+
"""Run every ``(func, args, kwargs)`` triple on the pool as one
|
|
206
|
+
batched run; return the results in input order."""
|
|
207
|
+
return await self.start_batch(calls).results()
|
|
208
|
+
|
|
209
|
+
def terminate(self) -> None:
|
|
210
|
+
"""Terminate idle workers; the pool stays usable (next task re-boots)."""
|
|
211
|
+
self._js_pool.terminate()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
_default_pool: WorkerPool | None = None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def default_pool() -> WorkerPool:
|
|
218
|
+
"""The process-wide :class:`WorkerPool` over ``js_pyodide_pool.pool``."""
|
|
219
|
+
global _default_pool
|
|
220
|
+
if _default_pool is None:
|
|
221
|
+
_default_pool = WorkerPool()
|
|
222
|
+
return _default_pool
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def set_default_pool(pool: WorkerPool | None) -> None:
|
|
226
|
+
"""Install ``pool`` as the process-wide default — the target of
|
|
227
|
+
module-level :func:`submit` and the dask scheduler. ``loader.create_pool``
|
|
228
|
+
calls this so notebooks never wire JS themselves; ``None`` resets to lazy
|
|
229
|
+
resolution via ``js_pyodide_pool``."""
|
|
230
|
+
global _default_pool
|
|
231
|
+
_default_pool = pool
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def submit(func: Any, /, *args: Any, **kwargs: Any) -> Any:
|
|
235
|
+
"""Module-level :meth:`WorkerPool.submit` on the default pool."""
|
|
236
|
+
return await default_pool().submit(func, *args, **kwargs)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Package-mirroring snapshot helpers (driver side).
|
|
2
|
+
|
|
3
|
+
Workers are stateless and must satisfy the imports of whatever user code
|
|
4
|
+
they receive. The driver therefore snapshots what its own Pyodide instance
|
|
5
|
+
has installed and rides the snapshot on every submitted task
|
|
6
|
+
(``_bridge.WorkerPool.submit``); the worker's module-scope installed-set
|
|
7
|
+
makes replaying the same snapshot cheap and idempotent, so no separate
|
|
8
|
+
driver/worker sync protocol exists.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from typing import NamedTuple
|
|
15
|
+
|
|
16
|
+
#: Never mirrored to workers: ``pyodide_pool`` and the Phase 06
|
|
17
|
+
#: ``wasm_multiprocessing`` shim travel by value inside task payloads (they
|
|
18
|
+
#: are never installed on workers, see each package's ``__init__``), the
|
|
19
|
+
#: worker's execPickled shim loads cloudpickle itself, and micropip is the
|
|
20
|
+
#: install mechanism, not a task dependency. The second group is JupyterLite kernel
|
|
21
|
+
#: machinery — never a task dependency, and piplite installs it from its
|
|
22
|
+
#: bundled index while reporting ``source == "pypi"``, so name-based
|
|
23
|
+
#: mirroring would resolve against REAL PyPI where ``pyodide-kernel`` does
|
|
24
|
+
#: not even exist (and ``ipykernel`` pulls ``tornado``, which has no pure
|
|
25
|
+
#: wheel there).
|
|
26
|
+
EXCLUDED_FROM_MIRROR = frozenset(
|
|
27
|
+
{"pyodide_pool", "wasm_multiprocessing", "cloudpickle", "micropip"}
|
|
28
|
+
| {"piplite", "pyodide_kernel", "ipykernel", "comm", "widgetsnbextension"}
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
#: ``pyodide.loadedPackages`` value for packages from the distribution.
|
|
32
|
+
_DEFAULT_CHANNEL = "default channel"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PackageSnapshot(NamedTuple):
|
|
36
|
+
"""What the driver has installed, split by worker install mechanism."""
|
|
37
|
+
|
|
38
|
+
packages: list[str]
|
|
39
|
+
"""Pyodide-distribution names, installed via ``pyodide.loadPackage``."""
|
|
40
|
+
|
|
41
|
+
wheels: list[str]
|
|
42
|
+
"""micropip targets: PyPI names or wheel URLs."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_url(value: str) -> bool:
|
|
46
|
+
return "://" in value or value.endswith(".whl")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _canonical(name: str) -> str:
|
|
50
|
+
"""PEP 503 name normalization — micropip lists ``fake_pkg`` as
|
|
51
|
+
``fake-pkg``, so exclusion and dedup must compare canonical forms."""
|
|
52
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_EXCLUDED_CANONICAL = frozenset(map(_canonical, EXCLUDED_FROM_MIRROR))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def snapshot_packages() -> PackageSnapshot:
|
|
59
|
+
"""Snapshot the driver's installed packages for mirroring to workers.
|
|
60
|
+
|
|
61
|
+
Combines the JS side's ``pyodide.loadedPackages`` (covers both
|
|
62
|
+
``pyodide.loadPackage`` and micropip installs; values are
|
|
63
|
+
``"default channel"`` for distribution packages, otherwise the install
|
|
64
|
+
source) with ``micropip.list()`` when micropip is present. Outside a
|
|
65
|
+
Pyodide driver both sources are absent and the snapshot is empty.
|
|
66
|
+
"""
|
|
67
|
+
packages: dict[str, str] = {} # canonical -> name as first seen
|
|
68
|
+
wheels: set[str] = set()
|
|
69
|
+
|
|
70
|
+
def add(name: str, source: str) -> None:
|
|
71
|
+
canonical = _canonical(name)
|
|
72
|
+
if canonical in _EXCLUDED_CANONICAL:
|
|
73
|
+
return
|
|
74
|
+
if source in (_DEFAULT_CHANNEL, "pyodide"):
|
|
75
|
+
packages.setdefault(canonical, name)
|
|
76
|
+
elif _is_url(source):
|
|
77
|
+
wheels.add(source)
|
|
78
|
+
elif canonical not in packages:
|
|
79
|
+
# Unknown source marker (e.g. "pypi"): let the worker's micropip
|
|
80
|
+
# resolve the package by name.
|
|
81
|
+
wheels.add(name)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
import pyodide_js
|
|
85
|
+
except ImportError:
|
|
86
|
+
pyodide_js = None
|
|
87
|
+
if pyodide_js is not None:
|
|
88
|
+
for name, channel in pyodide_js.loadedPackages.to_py().items():
|
|
89
|
+
add(name, channel)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
import micropip
|
|
93
|
+
except ImportError:
|
|
94
|
+
micropip = None
|
|
95
|
+
if micropip is not None:
|
|
96
|
+
for name, meta in micropip.list().items():
|
|
97
|
+
add(name, str(getattr(meta, "source", "") or ""))
|
|
98
|
+
|
|
99
|
+
return PackageSnapshot(sorted(packages.values()), sorted(wheels))
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Bootstrap the JS worker pool from inside a Pyodide kernel (JupyterLite).
|
|
2
|
+
|
|
3
|
+
Runs in the MAIN Pyodide instance (the driver), like the rest of the
|
|
4
|
+
package. An embedding web app wires the JS side itself (create a
|
|
5
|
+
``PyodidePool``, ``pyodide.registerJsModule('js_pyodide_pool', { pool })``),
|
|
6
|
+
but notebook code inside JupyterLite's Pyodide kernel has no JS embedder to
|
|
7
|
+
lean on — the kernel itself runs in a Web Worker. :func:`create_pool` pulls
|
|
8
|
+
the JS side in from Python instead: dynamically ``import()`` the
|
|
9
|
+
self-contained browser bundle (``pyodide-pool.browser.js``, built by
|
|
10
|
+
``npm run build`` and copied into the site's ``files/assets/`` by
|
|
11
|
+
``npm run build:lite``), construct the JS ``PyodidePool`` — its workers
|
|
12
|
+
spawn as NESTED Web Workers from a Blob URL inlined in the bundle — and
|
|
13
|
+
install the Python :class:`WorkerPool` wrapper as the default pool, so
|
|
14
|
+
``pyodide_pool.submit``/``compute`` work with no further setup.
|
|
15
|
+
|
|
16
|
+
Environment requirements: nested-worker support (all Chromium-based
|
|
17
|
+
browsers; Firefox and Safari in current versions) and network access to
|
|
18
|
+
the Pyodide CDN. The pool is postMessage-based, so it does NOT need a
|
|
19
|
+
cross-origin-isolated page — it runs on plain static hosts like GitHub
|
|
20
|
+
Pages (verified by the Pages deployment of demos/jupyterlite). COOP/COEP
|
|
21
|
+
headers (scripts/serve-lite.mjs sends them) only add SharedArrayBuffer
|
|
22
|
+
extras such as the JupyterLite kernel's coincident transport.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from . import _bridge
|
|
28
|
+
from ._bridge import WorkerPool
|
|
29
|
+
|
|
30
|
+
__all__ = ["DEFAULT_JS_URL", "create_pool"]
|
|
31
|
+
|
|
32
|
+
#: Where the JupyterLite site serves the bundle when hosted at the domain
|
|
33
|
+
#: root (scripts/serve-lite.mjs serves ``_output`` that way). Deployments
|
|
34
|
+
#: under a sub-path must pass ``js_url`` explicitly.
|
|
35
|
+
DEFAULT_JS_URL = "/files/assets/pyodide-pool.browser.js"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def create_pool(pool_size: int = 4, js_url: str | None = None) -> WorkerPool:
|
|
39
|
+
"""Import the JS bundle, start a worker pool, and make it the default.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
pool_size:
|
|
44
|
+
Maximum number of concurrent workers, one Pyodide interpreter each.
|
|
45
|
+
js_url:
|
|
46
|
+
URL of the self-contained browser bundle, defaulting to
|
|
47
|
+
:data:`DEFAULT_JS_URL`. Anything the JS ``import()`` accepts in the
|
|
48
|
+
current context works: an absolute path, a full URL, or a ``file://``
|
|
49
|
+
URL under Node.
|
|
50
|
+
"""
|
|
51
|
+
from js import Object
|
|
52
|
+
from pyodide.code import run_js
|
|
53
|
+
from pyodide.ffi import to_js
|
|
54
|
+
|
|
55
|
+
url = DEFAULT_JS_URL if js_url is None else js_url
|
|
56
|
+
# Dynamic import() resolves relative and path-absolute URLs against the
|
|
57
|
+
# REFERRER MODULE (pyodide.asm.js) — in JupyterLite that is the jsDelivr
|
|
58
|
+
# CDN, so "/files/assets/..." would 404 on cdn.jsdelivr.net. Resolve
|
|
59
|
+
# against the worker/page location instead, which is the site origin.
|
|
60
|
+
# Fully-qualified URLs (http://, file://) pass through new URL() as-is,
|
|
61
|
+
# and Node (no global `location`) keeps the raw url.
|
|
62
|
+
module = await run_js(
|
|
63
|
+
f"(u => import(typeof location === 'undefined' ? u : new URL(u, location.href).href))({url!r})"
|
|
64
|
+
)
|
|
65
|
+
options = to_js({"poolSize": pool_size}, dict_converter=Object.fromEntries)
|
|
66
|
+
pool = WorkerPool(module.createPool(options))
|
|
67
|
+
_bridge.set_default_pool(pool)
|
|
68
|
+
return pool
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Async dask scheduler executing task graphs on the Pyodide worker pool.
|
|
2
|
+
|
|
3
|
+
This module runs in the MAIN Pyodide instance (the driver). It implements
|
|
4
|
+
the design-doc algorithm (docs/architecture/dask-scheduler-design.md): a
|
|
5
|
+
Kahn-style in-degree executor over the raw graph dict that dispatches every
|
|
6
|
+
ready task to the worker pool at once and, as each completes, decrements its
|
|
7
|
+
dependents and dispatches the newly ready ones. Concurrency is bounded by
|
|
8
|
+
the JS pool's size, never by the scheduler — over-dispatching just fills the
|
|
9
|
+
pool queue, which is exactly what utilization wants.
|
|
10
|
+
|
|
11
|
+
dask is imported lazily inside :func:`get`/:func:`compute` so the package
|
|
12
|
+
keeps no import-time dask dependency (the Phase 06 multiprocessing shim
|
|
13
|
+
shares the substrate). Both graph dialects execute:
|
|
14
|
+
|
|
15
|
+
- **Legacy tuple tasks** (``(func, arg, ...)``) ship as a self-contained
|
|
16
|
+
recursive evaluator plus resolved dependency values, so workers never need
|
|
17
|
+
dask installed.
|
|
18
|
+
- **Modern task-spec nodes** (``dask._task_spec``, dask >= 2024.12 — what
|
|
19
|
+
``dask.delayed``/``dask.bag`` emit today) are callable with a mapping of
|
|
20
|
+
dependency values and pickle by reference to dask; they execute on workers
|
|
21
|
+
because package mirroring ships the driver's dask there.
|
|
22
|
+
|
|
23
|
+
Literal graph values, key aliases, and ``Alias``/``DataNode`` nodes resolve
|
|
24
|
+
locally without a worker round-trip.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from . import _bridge
|
|
33
|
+
|
|
34
|
+
__all__ = ["compute", "get"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _evaluate_node(node: Any, values: dict[Any, Any]) -> Any:
|
|
38
|
+
"""Recursively evaluate a legacy-dialect graph node against resolved
|
|
39
|
+
dependency ``values`` (the dask graph spec: tasks are tuples with a
|
|
40
|
+
callable head, lists are traversed, keys substitute, the rest are
|
|
41
|
+
literals).
|
|
42
|
+
|
|
43
|
+
Runs on workers inside cloudpickle payloads — the package is registered
|
|
44
|
+
pickle-by-value, so this function travels with each task. It must stay
|
|
45
|
+
self-contained: no dask, no imports, no references to other globals.
|
|
46
|
+
"""
|
|
47
|
+
if isinstance(node, list):
|
|
48
|
+
return [_evaluate_node(item, values) for item in node]
|
|
49
|
+
if type(node) is tuple and node and callable(node[0]):
|
|
50
|
+
return node[0](*(_evaluate_node(arg, values) for arg in node[1:]))
|
|
51
|
+
try:
|
|
52
|
+
if node in values:
|
|
53
|
+
return values[node]
|
|
54
|
+
except TypeError: # unhashable literal (dict, set, ...)
|
|
55
|
+
pass
|
|
56
|
+
return node
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _nested_get(keys: Any, cache: dict[Any, Any]) -> Any:
|
|
60
|
+
"""Assemble results following the nesting of ``keys``, like dask's own
|
|
61
|
+
``get``: nested key lists become tuples of results."""
|
|
62
|
+
if isinstance(keys, list):
|
|
63
|
+
return tuple(_nested_get(k, cache) for k in keys)
|
|
64
|
+
return cache[keys]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def get(dsk: Any, keys: Any, pool: Any = None, **kwargs: Any) -> Any:
|
|
68
|
+
"""Execute a dask graph on the worker pool; return the values of ``keys``.
|
|
69
|
+
|
|
70
|
+
Parameters
|
|
71
|
+
----------
|
|
72
|
+
dsk:
|
|
73
|
+
A dask graph — a mapping of key to node, or anything with a
|
|
74
|
+
``__dask_graph__()`` (HighLevelGraph, expression) to materialize one.
|
|
75
|
+
keys:
|
|
76
|
+
A key or arbitrarily nested lists of keys, like dask's own ``get``;
|
|
77
|
+
the result mirrors the nesting (lists come back as tuples).
|
|
78
|
+
pool:
|
|
79
|
+
A :class:`~pyodide_pool._bridge.WorkerPool` to dispatch through;
|
|
80
|
+
defaults to the pool registered as ``js_pyodide_pool.pool``.
|
|
81
|
+
kwargs:
|
|
82
|
+
Accepted for scheduler-call compatibility (``num_workers`` etc.) and
|
|
83
|
+
ignored: the JS pool bounds concurrency, the scheduler dispatches
|
|
84
|
+
everything that is ready.
|
|
85
|
+
"""
|
|
86
|
+
from dask.core import flatten, get_dependencies, istask, reverse_dict, toposort
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
from dask._task_spec import Alias, DataNode, GraphNode
|
|
90
|
+
except ImportError: # dask predates the task-spec dialect
|
|
91
|
+
Alias = DataNode = GraphNode = () # type: ignore[assignment]
|
|
92
|
+
|
|
93
|
+
if not isinstance(dsk, dict) and hasattr(dsk, "__dask_graph__"):
|
|
94
|
+
dsk = dsk.__dask_graph__()
|
|
95
|
+
dsk = dict(dsk)
|
|
96
|
+
|
|
97
|
+
requested = list(flatten(keys)) if isinstance(keys, list) else [keys]
|
|
98
|
+
missing = [k for k in requested if k not in dsk]
|
|
99
|
+
if missing:
|
|
100
|
+
raise KeyError(f"requested keys not in the graph: {missing!r}")
|
|
101
|
+
|
|
102
|
+
dependencies = {k: get_dependencies(dsk, k) for k in dsk}
|
|
103
|
+
toposort(dsk, dependencies=dependencies) # raises on cycles
|
|
104
|
+
dependents = reverse_dict(dependencies)
|
|
105
|
+
indegree = {k: len(deps) for k, deps in dependencies.items()}
|
|
106
|
+
|
|
107
|
+
submit = _bridge.submit if pool is None else pool.submit
|
|
108
|
+
cache: dict[Any, Any] = {}
|
|
109
|
+
pending: dict[Any, asyncio.Future[Any]] = {}
|
|
110
|
+
ready = [k for k in dsk if indegree[k] == 0]
|
|
111
|
+
|
|
112
|
+
def finish(key: Any) -> None:
|
|
113
|
+
for dependent in dependents.get(key, ()):
|
|
114
|
+
indegree[dependent] -= 1
|
|
115
|
+
if indegree[dependent] == 0:
|
|
116
|
+
ready.append(dependent)
|
|
117
|
+
|
|
118
|
+
def dispatch_ready() -> None:
|
|
119
|
+
# Drain the ready list: local nodes resolve immediately (readying
|
|
120
|
+
# further keys, hence the loop), remote tasks become pending futures.
|
|
121
|
+
while ready:
|
|
122
|
+
key = ready.pop()
|
|
123
|
+
node = dsk[key]
|
|
124
|
+
values = {dep: cache[dep] for dep in dependencies[key]}
|
|
125
|
+
if isinstance(node, GraphNode):
|
|
126
|
+
if isinstance(node, Alias) or isinstance(node, DataNode):
|
|
127
|
+
cache[key] = node(values) # cheap: no worker round-trip
|
|
128
|
+
finish(key)
|
|
129
|
+
else:
|
|
130
|
+
pending[key] = asyncio.ensure_future(submit(node, values))
|
|
131
|
+
elif istask(node):
|
|
132
|
+
pending[key] = asyncio.ensure_future(
|
|
133
|
+
submit(_evaluate_node, node, values)
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
# Literal, key alias, or container of keys/literals.
|
|
137
|
+
cache[key] = _evaluate_node(node, values)
|
|
138
|
+
finish(key)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
dispatch_ready()
|
|
142
|
+
while pending:
|
|
143
|
+
done, _ = await asyncio.wait(
|
|
144
|
+
pending.values(), return_when=asyncio.FIRST_COMPLETED
|
|
145
|
+
)
|
|
146
|
+
for key in [k for k, fut in pending.items() if fut in done]:
|
|
147
|
+
cache[key] = pending.pop(key).result()
|
|
148
|
+
finish(key)
|
|
149
|
+
dispatch_ready()
|
|
150
|
+
except BaseException:
|
|
151
|
+
# Fail fast: drop the outstanding work and let the first error
|
|
152
|
+
# (already carrying its remote traceback, see _bridge) propagate.
|
|
153
|
+
if pending:
|
|
154
|
+
for fut in pending.values():
|
|
155
|
+
fut.cancel()
|
|
156
|
+
await asyncio.gather(*pending.values(), return_exceptions=True)
|
|
157
|
+
raise
|
|
158
|
+
|
|
159
|
+
return _nested_get(keys, cache)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def compute(
|
|
163
|
+
*args: Any,
|
|
164
|
+
traverse: bool = True,
|
|
165
|
+
optimize_graph: bool = True,
|
|
166
|
+
pool: Any = None,
|
|
167
|
+
**kwargs: Any,
|
|
168
|
+
) -> Any:
|
|
169
|
+
"""Compute dask collections on the worker pool.
|
|
170
|
+
|
|
171
|
+
The async counterpart of :func:`dask.compute`: accepts ``dask.delayed``
|
|
172
|
+
objects, bags, arrays, plain values, and (with ``traverse=True``)
|
|
173
|
+
builtin containers of any of these. A single argument returns its bare
|
|
174
|
+
result — ``await compute(delayed_obj)`` matches ``delayed_obj.compute()``
|
|
175
|
+
— while multiple arguments return a tuple like ``dask.compute``.
|
|
176
|
+
|
|
177
|
+
On expression-based dask (2025+) this follows ``dask.base.compute``:
|
|
178
|
+
``collections_to_expr`` + ``FinalizeCompute`` compile each collection's
|
|
179
|
+
finalization into the graph itself. On older dask it falls back to the
|
|
180
|
+
classic ``collections_to_dsk`` + ``__dask_postcompute__`` protocol.
|
|
181
|
+
"""
|
|
182
|
+
from dask.base import unpack_collections
|
|
183
|
+
from dask.core import flatten
|
|
184
|
+
|
|
185
|
+
collections, repack = unpack_collections(*args, traverse=traverse)
|
|
186
|
+
if not collections:
|
|
187
|
+
return args[0] if len(args) == 1 else args
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
from dask._expr import FinalizeCompute
|
|
191
|
+
from dask.base import collections_to_expr
|
|
192
|
+
except ImportError: # pre-expression dask: classic collection protocol
|
|
193
|
+
from dask.base import collections_to_dsk
|
|
194
|
+
|
|
195
|
+
dsk = collections_to_dsk(collections, optimize_graph, **kwargs)
|
|
196
|
+
nested_keys = [c.__dask_keys__() for c in collections]
|
|
197
|
+
postcomputes = [c.__dask_postcompute__() for c in collections]
|
|
198
|
+
results = await get(dsk, nested_keys, pool=pool)
|
|
199
|
+
out = repack(
|
|
200
|
+
[f(r, *extra) for r, (f, extra) in zip(results, postcomputes)]
|
|
201
|
+
)
|
|
202
|
+
else:
|
|
203
|
+
expr = FinalizeCompute(collections_to_expr(collections, optimize_graph))
|
|
204
|
+
expr = expr.optimize()
|
|
205
|
+
keys = list(flatten(expr.__dask_keys__()))
|
|
206
|
+
results = await get(expr.__dask_graph__(), keys, pool=pool)
|
|
207
|
+
out = repack(results)
|
|
208
|
+
|
|
209
|
+
return out[0] if len(args) == 1 else out
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyodide-pool"
|
|
7
|
+
description = "Run Python callables and dask graphs on a pool of Pyodide Web Workers (driver-side API)"
|
|
8
|
+
license = "MIT"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
# cloudpickle is the wire format of every submitted task (_bridge); dask is
|
|
11
|
+
# only needed for compute/get and stays an extra. Both resolve in Pyodide:
|
|
12
|
+
# cloudpickle ships in the distribution, dask installs from PyPI via
|
|
13
|
+
# micropip/piplite.
|
|
14
|
+
dependencies = ["cloudpickle"]
|
|
15
|
+
dynamic = ["version"]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dask = ["dask"]
|
|
19
|
+
|
|
20
|
+
[tool.hatch.version]
|
|
21
|
+
path = "pyodide_pool/__init__.py"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["pyodide_pool"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "wasm-multiprocessing"
|
|
7
|
+
description = "multiprocessing.Pool-compatible API over a pool of Pyodide Web Workers (driver-side shim)"
|
|
8
|
+
license = "MIT"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
# pyodide-pool is the single choke point for cloudpickle + JS bridging
|
|
11
|
+
# (_bridge.submit / submit_batch); the shim adds no second copy — it only
|
|
12
|
+
# needs cloudpickle itself for the pickle-by-value registration. pyodide-pool
|
|
13
|
+
# is not on PyPI: install both wheels together (JupyterLite ships both).
|
|
14
|
+
dependencies = ["pyodide-pool", "cloudpickle"]
|
|
15
|
+
dynamic = ["version"]
|
|
16
|
+
|
|
17
|
+
[tool.hatch.version]
|
|
18
|
+
path = "wasm_multiprocessing/__init__.py"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["wasm_multiprocessing"]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""``multiprocessing.Pool`` for Pyodide: same API, Web Workers underneath.
|
|
2
|
+
|
|
3
|
+
Change one import line and existing ``multiprocessing.Pool`` code runs on
|
|
4
|
+
the Pyodide worker pool::
|
|
5
|
+
|
|
6
|
+
# was: from multiprocessing import Pool
|
|
7
|
+
from wasm_multiprocessing import Pool
|
|
8
|
+
|
|
9
|
+
with Pool(4) as pool:
|
|
10
|
+
results = await pool.amap(count_primes, ranges) # portable everywhere
|
|
11
|
+
results = pool.map(count_primes, ranges) # blocks only under JSPI
|
|
12
|
+
|
|
13
|
+
Like ``pyodide_pool``, this package runs ONLY in the driver: the chunk
|
|
14
|
+
runners it ships inside task payloads travel by value (pickle-by-value
|
|
15
|
+
registration below), so workers never install it.
|
|
16
|
+
|
|
17
|
+
Sync methods (``map``/``starmap``/``apply``/``join``, ``AsyncResult.get``/
|
|
18
|
+
``wait``) truly block only where JSPI stack switching is available
|
|
19
|
+
(``pyodide.ffi.can_run_sync()``, detected per call); everywhere else they
|
|
20
|
+
raise a ``RuntimeError`` naming the exact async replacement. Anything
|
|
21
|
+
fork-shaped (``Process``, ``Queue``, ``Manager``, shared memory, locks)
|
|
22
|
+
raises ``NotImplementedError`` — WebAssembly workers share nothing.
|
|
23
|
+
|
|
24
|
+
Design + full API mapping: docs/architecture/multiprocessing-shim-design.md.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import sys
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import cloudpickle
|
|
33
|
+
|
|
34
|
+
from .pool import AsyncResult, Pool, TimeoutError, cpu_count
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
__all__ = ["AsyncResult", "Pool", "TimeoutError", "cpu_count"]
|
|
39
|
+
|
|
40
|
+
# The chunk runners (pool._run_chunk / _run_star_chunk) ship inside
|
|
41
|
+
# cloudpickle payloads; registering the package makes them travel BY VALUE
|
|
42
|
+
# so workers never need wasm_multiprocessing installed (registering the
|
|
43
|
+
# parent package covers submodules — cloudpickle walks parent packages).
|
|
44
|
+
cloudpickle.register_pickle_by_value(sys.modules[__name__])
|
|
45
|
+
|
|
46
|
+
#: stdlib ``multiprocessing`` names that cannot exist on WebAssembly — no
|
|
47
|
+
#: processes, no fork, no shared memory, no channels between workers.
|
|
48
|
+
#: Attribute access raises immediately instead of failing obscurely
|
|
49
|
+
#: mid-task; see the design doc's "out of scope" section.
|
|
50
|
+
_UNSUPPORTED = frozenset(
|
|
51
|
+
{
|
|
52
|
+
"Process",
|
|
53
|
+
"Queue",
|
|
54
|
+
"SimpleQueue",
|
|
55
|
+
"JoinableQueue",
|
|
56
|
+
"Pipe",
|
|
57
|
+
"Manager",
|
|
58
|
+
"Value",
|
|
59
|
+
"Array",
|
|
60
|
+
"RawValue",
|
|
61
|
+
"RawArray",
|
|
62
|
+
"Lock",
|
|
63
|
+
"RLock",
|
|
64
|
+
"Semaphore",
|
|
65
|
+
"BoundedSemaphore",
|
|
66
|
+
"Event",
|
|
67
|
+
"Condition",
|
|
68
|
+
"Barrier",
|
|
69
|
+
"shared_memory",
|
|
70
|
+
"current_process",
|
|
71
|
+
"parent_process",
|
|
72
|
+
"active_children",
|
|
73
|
+
"freeze_support",
|
|
74
|
+
"get_context",
|
|
75
|
+
"get_start_method",
|
|
76
|
+
"set_start_method",
|
|
77
|
+
"get_all_start_methods",
|
|
78
|
+
"set_executable",
|
|
79
|
+
"set_forkserver_preload",
|
|
80
|
+
"log_to_stderr",
|
|
81
|
+
"get_logger",
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def __getattr__(name: str) -> Any:
|
|
87
|
+
if name in _UNSUPPORTED:
|
|
88
|
+
raise NotImplementedError(
|
|
89
|
+
f"wasm_multiprocessing.{name} is intentionally unsupported: "
|
|
90
|
+
"WebAssembly workers share nothing and cannot fork — tasks "
|
|
91
|
+
"communicate only through arguments and results. See "
|
|
92
|
+
"docs/architecture/multiprocessing-shim-design.md (out of scope)."
|
|
93
|
+
)
|
|
94
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""The ``multiprocessing.Pool`` shim over ``pyodide_pool``.
|
|
2
|
+
|
|
3
|
+
Every remote execution goes through ``pyodide_pool`` — batched chunks as
|
|
4
|
+
one JS ``mapPickled`` run (``WorkerPool.start_batch``), streamed chunks as
|
|
5
|
+
single ``submit`` calls — so cloudpickle/JS bridging and remote-exception
|
|
6
|
+
re-raising (original type with ``RemoteTraceback`` chained) live in exactly
|
|
7
|
+
one place.
|
|
8
|
+
|
|
9
|
+
The async-native core is the portable surface: ``amap`` / ``astarmap`` /
|
|
10
|
+
``aapply`` / ``imap`` / ``imap_unordered`` / ``ajoin`` plus
|
|
11
|
+
``AsyncResult.aget`` / ``await_ready``. The stdlib-shaped sync methods are
|
|
12
|
+
thin wrappers over it via ``_block_on``: JSPI ``run_sync`` where the
|
|
13
|
+
runtime supports stack switching, a ``RuntimeError`` naming the exact
|
|
14
|
+
async replacement everywhere else.
|
|
15
|
+
|
|
16
|
+
Design + API mapping: docs/architecture/multiprocessing-shim-design.md.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
from collections.abc import AsyncIterator, Callable, Coroutine, Iterable, Mapping, Sequence
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import pyodide_pool
|
|
26
|
+
from pyodide_pool._bridge import BatchRun, Call
|
|
27
|
+
|
|
28
|
+
try: # the class users already catch; importable on Emscripten
|
|
29
|
+
from multiprocessing import TimeoutError
|
|
30
|
+
except Exception: # pragma: no cover — stdlib multiprocessing unavailable
|
|
31
|
+
|
|
32
|
+
class TimeoutError(Exception): # type: ignore[no-redef]
|
|
33
|
+
"""Raised when a blocking ``AsyncResult`` call times out."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = ["AsyncResult", "Pool", "TimeoutError", "cpu_count"]
|
|
37
|
+
|
|
38
|
+
# Pool lifecycle states (stdlib names).
|
|
39
|
+
_RUN, _CLOSE, _TERMINATE = "RUN", "CLOSE", "TERMINATE"
|
|
40
|
+
|
|
41
|
+
_JSPI_HINT = (
|
|
42
|
+
"Synchronous blocking needs WebAssembly JSPI stack switching "
|
|
43
|
+
"(pyodide.ffi.run_sync): on by default in Chrome 137+, in Node 24 via "
|
|
44
|
+
"--experimental-wasm-jspi, behind a flag in Firefox, not yet in Safari."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _block_on(awaitable: Coroutine[Any, Any, Any], replacement: str) -> Any:
|
|
49
|
+
"""Run ``awaitable`` to completion synchronously where the runtime can,
|
|
50
|
+
else raise the guidance error naming the exact async replacement.
|
|
51
|
+
|
|
52
|
+
Capability is detected PER CALL, not at import: ``can_run_sync()``
|
|
53
|
+
depends on how Python was entered (async entry via ``runPythonAsync`` /
|
|
54
|
+
``callPromising`` allows stack switching; plain ``runPython`` does not).
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
from pyodide.ffi import can_run_sync, run_sync
|
|
58
|
+
|
|
59
|
+
capable = can_run_sync()
|
|
60
|
+
except ImportError: # non-Pyodide Python (CI helpers, docs builds)
|
|
61
|
+
capable = False
|
|
62
|
+
if capable:
|
|
63
|
+
return run_sync(awaitable)
|
|
64
|
+
awaitable.close() # don't leak a never-awaited coroutine
|
|
65
|
+
raise RuntimeError(
|
|
66
|
+
f"this runtime cannot block on pool results — use the async form: "
|
|
67
|
+
f"{replacement}\n{_JSPI_HINT}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cpu_count() -> int:
|
|
72
|
+
"""``navigator.hardwareConcurrency`` (browsers, workers, Node >= 21);
|
|
73
|
+
1 where unavailable."""
|
|
74
|
+
try:
|
|
75
|
+
import js
|
|
76
|
+
|
|
77
|
+
count = int(js.navigator.hardwareConcurrency)
|
|
78
|
+
if count >= 1:
|
|
79
|
+
return count
|
|
80
|
+
except (ImportError, AttributeError, TypeError, ValueError):
|
|
81
|
+
pass
|
|
82
|
+
return 1
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _run_chunk(func: Callable[..., Any], items: list[Any]) -> list[Any]:
|
|
86
|
+
"""Executes ON WORKERS (ships by value): one ``map`` chunk."""
|
|
87
|
+
return [func(item) for item in items]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _run_star_chunk(func: Callable[..., Any], items: list[Any]) -> list[Any]:
|
|
91
|
+
"""Executes ON WORKERS (ships by value): one ``starmap`` chunk."""
|
|
92
|
+
return [func(*item) for item in items]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _default_chunksize(n_items: int, processes: int) -> int:
|
|
96
|
+
"""CPython's own ``Pool._map_async`` heuristic: ~4 chunks per (soft-cap)
|
|
97
|
+
worker, so per-message overhead amortizes over coarse batches."""
|
|
98
|
+
chunksize, extra = divmod(n_items, processes * 4)
|
|
99
|
+
if extra:
|
|
100
|
+
chunksize += 1
|
|
101
|
+
return max(chunksize, 1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _split(items: list[Any], chunksize: int) -> list[list[Any]]:
|
|
105
|
+
return [items[i : i + chunksize] for i in range(0, len(items), chunksize)]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class AsyncResult:
|
|
109
|
+
"""Result handle of the ``*_async`` methods, backed by an asyncio task.
|
|
110
|
+
|
|
111
|
+
``ready``/``successful`` are non-blocking and stdlib-shaped. The
|
|
112
|
+
portable completion forms are awaitables — ``await result`` (sugar for
|
|
113
|
+
``aget()``) and ``await_ready()``; the blocking ``get``/``wait`` work
|
|
114
|
+
only under JSPI (see ``_block_on``).
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
task: asyncio.Task[Any],
|
|
120
|
+
callback: Callable[[Any], object] | None = None,
|
|
121
|
+
error_callback: Callable[[BaseException], object] | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
self._task = task
|
|
124
|
+
self._callback = callback
|
|
125
|
+
self._error_callback = error_callback
|
|
126
|
+
task.add_done_callback(self._fire_callbacks)
|
|
127
|
+
|
|
128
|
+
def _fire_callbacks(self, task: asyncio.Task[Any]) -> None:
|
|
129
|
+
if task.cancelled():
|
|
130
|
+
return
|
|
131
|
+
# .exception() also marks the failure as retrieved, so an abandoned
|
|
132
|
+
# failed result never trips asyncio's never-retrieved warning.
|
|
133
|
+
exc = task.exception()
|
|
134
|
+
if exc is None:
|
|
135
|
+
if self._callback is not None:
|
|
136
|
+
self._callback(task.result())
|
|
137
|
+
elif self._error_callback is not None:
|
|
138
|
+
self._error_callback(exc)
|
|
139
|
+
|
|
140
|
+
def ready(self) -> bool:
|
|
141
|
+
return self._task.done()
|
|
142
|
+
|
|
143
|
+
def successful(self) -> bool:
|
|
144
|
+
if not self._task.done():
|
|
145
|
+
raise ValueError(f"{self!r} not ready")
|
|
146
|
+
return not self._task.cancelled() and self._task.exception() is None
|
|
147
|
+
|
|
148
|
+
async def aget(self, timeout: float | None = None) -> Any:
|
|
149
|
+
"""Await the result; a failed task re-raises its original exception
|
|
150
|
+
(worker traceback chained as ``RemoteTraceback``). On ``timeout``
|
|
151
|
+
raises :class:`TimeoutError` and leaves the computation running —
|
|
152
|
+
a later ``aget()`` can still collect it (stdlib ``get`` semantics,
|
|
153
|
+
hence the ``shield``)."""
|
|
154
|
+
if timeout is None:
|
|
155
|
+
return await asyncio.shield(self._task)
|
|
156
|
+
try:
|
|
157
|
+
return await asyncio.wait_for(asyncio.shield(self._task), timeout)
|
|
158
|
+
except asyncio.TimeoutError:
|
|
159
|
+
raise TimeoutError from None
|
|
160
|
+
|
|
161
|
+
async def await_ready(self, timeout: float | None = None) -> None:
|
|
162
|
+
"""Awaitable form of ``wait``: returns once ready or after
|
|
163
|
+
``timeout`` seconds, never raising on timeout (stdlib ``wait``)."""
|
|
164
|
+
await asyncio.wait({self._task}, timeout=timeout)
|
|
165
|
+
|
|
166
|
+
def get(self, timeout: float | None = None) -> Any:
|
|
167
|
+
return _block_on(self.aget(timeout), "await result.aget(timeout)")
|
|
168
|
+
|
|
169
|
+
def wait(self, timeout: float | None = None) -> None:
|
|
170
|
+
return _block_on(
|
|
171
|
+
self.await_ready(timeout), "await result.await_ready(timeout)"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def __await__(self) -> Any:
|
|
175
|
+
return self.aget().__await__()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class Pool:
|
|
179
|
+
"""``multiprocessing.Pool`` over the shared Pyodide worker pool.
|
|
180
|
+
|
|
181
|
+
The JS pool's ``poolSize`` is the HARD concurrency cap; ``processes``
|
|
182
|
+
is a driver-side soft cap (``asyncio.Semaphore``) on in-flight chunks,
|
|
183
|
+
which is what makes ``Pool(1)``/``Pool(2)``/``Pool(4)`` behave
|
|
184
|
+
differently on one shared 4-worker JS pool.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def __init__(
|
|
188
|
+
self,
|
|
189
|
+
processes: int | None = None,
|
|
190
|
+
initializer: Callable[..., object] | None = None,
|
|
191
|
+
initargs: Sequence[Any] = (),
|
|
192
|
+
maxtasksperchild: int | None = None,
|
|
193
|
+
context: Any = None,
|
|
194
|
+
*,
|
|
195
|
+
pool: pyodide_pool.WorkerPool | None = None,
|
|
196
|
+
) -> None:
|
|
197
|
+
if initializer is not None:
|
|
198
|
+
raise NotImplementedError(
|
|
199
|
+
"Pool(initializer=...) is not supported yet (planned as a "
|
|
200
|
+
"cloudpickled per-worker prelude); ship state inside func "
|
|
201
|
+
"instead. See docs/architecture/multiprocessing-shim-design.md."
|
|
202
|
+
)
|
|
203
|
+
if maxtasksperchild is not None or context is not None:
|
|
204
|
+
raise NotImplementedError(
|
|
205
|
+
"Pool(maxtasksperchild=/context=) has no meaning here — "
|
|
206
|
+
"worker recycling is the JS pool's policy. See "
|
|
207
|
+
"docs/architecture/multiprocessing-shim-design.md."
|
|
208
|
+
)
|
|
209
|
+
if processes is None:
|
|
210
|
+
processes = cpu_count()
|
|
211
|
+
if not isinstance(processes, int) or processes < 1:
|
|
212
|
+
raise ValueError("Number of processes must be at least 1")
|
|
213
|
+
self._processes = processes
|
|
214
|
+
self._pool = pool if pool is not None else pyodide_pool.default_pool()
|
|
215
|
+
self._semaphore = asyncio.Semaphore(processes)
|
|
216
|
+
self._state = _RUN
|
|
217
|
+
self._active_batches: set[BatchRun] = set()
|
|
218
|
+
self._pending: set[asyncio.Task[Any]] = set()
|
|
219
|
+
|
|
220
|
+
def __repr__(self) -> str:
|
|
221
|
+
return f"<wasm_multiprocessing.Pool processes={self._processes} state={self._state}>"
|
|
222
|
+
|
|
223
|
+
# -- internals ----------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
def _check_running(self) -> None:
|
|
226
|
+
if self._state != _RUN:
|
|
227
|
+
raise ValueError("Pool not running")
|
|
228
|
+
|
|
229
|
+
def _spawn(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:
|
|
230
|
+
"""Create a tracked asyncio task (``join`` awaits the tracked set;
|
|
231
|
+
``terminate`` cancels it). Works from sync driver code too:
|
|
232
|
+
Pyodide's WebLoop accepts task creation outside a coroutine."""
|
|
233
|
+
try:
|
|
234
|
+
loop = asyncio.get_running_loop()
|
|
235
|
+
except RuntimeError:
|
|
236
|
+
loop = asyncio.get_event_loop()
|
|
237
|
+
task = loop.create_task(coro)
|
|
238
|
+
self._pending.add(task)
|
|
239
|
+
task.add_done_callback(self._forget)
|
|
240
|
+
return task
|
|
241
|
+
|
|
242
|
+
def _forget(self, task: asyncio.Task[Any]) -> None:
|
|
243
|
+
self._pending.discard(task)
|
|
244
|
+
if not task.cancelled():
|
|
245
|
+
task.exception() # abandoned failures stay quiet (see AsyncResult)
|
|
246
|
+
|
|
247
|
+
def _map_calls(
|
|
248
|
+
self,
|
|
249
|
+
runner: Callable[..., Any],
|
|
250
|
+
func: Callable[..., Any],
|
|
251
|
+
iterable: Iterable[Any],
|
|
252
|
+
chunksize: int | None,
|
|
253
|
+
) -> list[Call]:
|
|
254
|
+
"""Chunk ``iterable`` into ``(runner, (func, chunk), {})`` call
|
|
255
|
+
triples — one chunk = one task message. Consumed eagerly (v1)."""
|
|
256
|
+
items = list(iterable)
|
|
257
|
+
if chunksize is None:
|
|
258
|
+
chunksize = _default_chunksize(len(items), self._processes) if items else 1
|
|
259
|
+
elif chunksize < 1:
|
|
260
|
+
raise ValueError("Chunksize must be 1+")
|
|
261
|
+
return [(runner, (func, chunk), {}) for chunk in _split(items, chunksize)]
|
|
262
|
+
|
|
263
|
+
async def _submit_call(self, call: Call) -> Any:
|
|
264
|
+
"""One chunk as a single ``submit``, gated by the soft cap."""
|
|
265
|
+
func, args, kwargs = call
|
|
266
|
+
async with self._semaphore:
|
|
267
|
+
return await self._pool.submit(func, *args, **kwargs)
|
|
268
|
+
|
|
269
|
+
async def _run_chunks(self, calls: list[Call]) -> list[Any]:
|
|
270
|
+
"""Chunk results in input order.
|
|
271
|
+
|
|
272
|
+
Fast path: when the soft cap cannot bind tighter than the JS pool's
|
|
273
|
+
own worker cap, the whole call is ONE ``mapPickled`` run (single
|
|
274
|
+
FIFO queue, one cancel handle). Otherwise chunks go through
|
|
275
|
+
semaphore-gated single submits so at most ``processes`` are ever in
|
|
276
|
+
flight.
|
|
277
|
+
"""
|
|
278
|
+
if self._processes >= self._pool.pool_size:
|
|
279
|
+
batch = self._pool.start_batch(calls)
|
|
280
|
+
self._active_batches.add(batch)
|
|
281
|
+
try:
|
|
282
|
+
return await batch.results()
|
|
283
|
+
finally:
|
|
284
|
+
self._active_batches.discard(batch)
|
|
285
|
+
return list(await asyncio.gather(*(self._submit_call(call) for call in calls)))
|
|
286
|
+
|
|
287
|
+
async def _gather_map(self, calls: list[Call]) -> list[Any]:
|
|
288
|
+
if not calls:
|
|
289
|
+
return []
|
|
290
|
+
chunk_results = await self._run_chunks(calls)
|
|
291
|
+
return [result for chunk in chunk_results for result in chunk]
|
|
292
|
+
|
|
293
|
+
def _stream_chunks(
|
|
294
|
+
self,
|
|
295
|
+
runner: Callable[..., Any],
|
|
296
|
+
func: Callable[..., Any],
|
|
297
|
+
iterable: Iterable[Any],
|
|
298
|
+
chunksize: int,
|
|
299
|
+
) -> list[asyncio.Task[Any]]:
|
|
300
|
+
"""Per-chunk submit futures for ``imap*`` — a ``mapPickled`` run
|
|
301
|
+
settles only as a whole, so streaming needs one future per chunk."""
|
|
302
|
+
if chunksize < 1:
|
|
303
|
+
raise ValueError("Chunksize must be 1+")
|
|
304
|
+
calls = self._map_calls(runner, func, iterable, chunksize)
|
|
305
|
+
return [self._spawn(self._submit_call(call)) for call in calls]
|
|
306
|
+
|
|
307
|
+
# -- async-native core --------------------------------------------------
|
|
308
|
+
|
|
309
|
+
async def amap(
|
|
310
|
+
self,
|
|
311
|
+
func: Callable[[Any], Any],
|
|
312
|
+
iterable: Iterable[Any],
|
|
313
|
+
chunksize: int | None = None,
|
|
314
|
+
) -> list[Any]:
|
|
315
|
+
"""Async ``map``: results in input order."""
|
|
316
|
+
self._check_running()
|
|
317
|
+
return await self._spawn(self._gather_map(self._map_calls(_run_chunk, func, iterable, chunksize)))
|
|
318
|
+
|
|
319
|
+
async def astarmap(
|
|
320
|
+
self,
|
|
321
|
+
func: Callable[..., Any],
|
|
322
|
+
iterable: Iterable[Sequence[Any]],
|
|
323
|
+
chunksize: int | None = None,
|
|
324
|
+
) -> list[Any]:
|
|
325
|
+
"""Async ``starmap``: each item is an argument tuple."""
|
|
326
|
+
self._check_running()
|
|
327
|
+
return await self._spawn(self._gather_map(self._map_calls(_run_star_chunk, func, iterable, chunksize)))
|
|
328
|
+
|
|
329
|
+
async def aapply(
|
|
330
|
+
self,
|
|
331
|
+
func: Callable[..., Any],
|
|
332
|
+
args: Sequence[Any] = (),
|
|
333
|
+
kwds: Mapping[str, Any] | None = None,
|
|
334
|
+
) -> Any:
|
|
335
|
+
"""Async ``apply``: one unbatched call."""
|
|
336
|
+
self._check_running()
|
|
337
|
+
return await self._spawn(self._pool.submit(func, *args, **dict(kwds or {})))
|
|
338
|
+
|
|
339
|
+
def imap(
|
|
340
|
+
self,
|
|
341
|
+
func: Callable[[Any], Any],
|
|
342
|
+
iterable: Iterable[Any],
|
|
343
|
+
chunksize: int = 1,
|
|
344
|
+
) -> AsyncIterator[Any]:
|
|
345
|
+
"""Async generator over results IN INPUT ORDER, yielding as chunks
|
|
346
|
+
complete. Chunks dispatch eagerly at call time (v1), bounded in
|
|
347
|
+
flight by the soft cap."""
|
|
348
|
+
self._check_running()
|
|
349
|
+
futures = self._stream_chunks(_run_chunk, func, iterable, chunksize)
|
|
350
|
+
|
|
351
|
+
async def iterate() -> AsyncIterator[Any]:
|
|
352
|
+
for future in futures:
|
|
353
|
+
for result in await future:
|
|
354
|
+
yield result
|
|
355
|
+
|
|
356
|
+
return iterate()
|
|
357
|
+
|
|
358
|
+
def imap_unordered(
|
|
359
|
+
self,
|
|
360
|
+
func: Callable[[Any], Any],
|
|
361
|
+
iterable: Iterable[Any],
|
|
362
|
+
chunksize: int = 1,
|
|
363
|
+
) -> AsyncIterator[Any]:
|
|
364
|
+
"""Like ``imap`` but yields whole chunks in COMPLETION order."""
|
|
365
|
+
self._check_running()
|
|
366
|
+
futures = self._stream_chunks(_run_chunk, func, iterable, chunksize)
|
|
367
|
+
|
|
368
|
+
async def iterate() -> AsyncIterator[Any]:
|
|
369
|
+
for next_done in asyncio.as_completed(futures):
|
|
370
|
+
for result in await next_done:
|
|
371
|
+
yield result
|
|
372
|
+
|
|
373
|
+
return iterate()
|
|
374
|
+
|
|
375
|
+
async def ajoin(self) -> None:
|
|
376
|
+
"""Await every outstanding result. Stdlib rule kept: ``ValueError``
|
|
377
|
+
unless the pool was closed/terminated first."""
|
|
378
|
+
if self._state == _RUN:
|
|
379
|
+
raise ValueError("Pool is still running")
|
|
380
|
+
while self._pending:
|
|
381
|
+
await asyncio.wait(set(self._pending))
|
|
382
|
+
|
|
383
|
+
# -- stdlib-shaped API --------------------------------------------------
|
|
384
|
+
|
|
385
|
+
def map(
|
|
386
|
+
self,
|
|
387
|
+
func: Callable[[Any], Any],
|
|
388
|
+
iterable: Iterable[Any],
|
|
389
|
+
chunksize: int | None = None,
|
|
390
|
+
) -> list[Any]:
|
|
391
|
+
self._check_running()
|
|
392
|
+
return _block_on(self.amap(func, iterable, chunksize), "await pool.amap(func, iterable)")
|
|
393
|
+
|
|
394
|
+
def starmap(
|
|
395
|
+
self,
|
|
396
|
+
func: Callable[..., Any],
|
|
397
|
+
iterable: Iterable[Sequence[Any]],
|
|
398
|
+
chunksize: int | None = None,
|
|
399
|
+
) -> list[Any]:
|
|
400
|
+
self._check_running()
|
|
401
|
+
return _block_on(self.astarmap(func, iterable, chunksize), "await pool.astarmap(func, iterable)")
|
|
402
|
+
|
|
403
|
+
def apply(
|
|
404
|
+
self,
|
|
405
|
+
func: Callable[..., Any],
|
|
406
|
+
args: Sequence[Any] = (),
|
|
407
|
+
kwds: Mapping[str, Any] | None = None,
|
|
408
|
+
) -> Any:
|
|
409
|
+
self._check_running()
|
|
410
|
+
return _block_on(self.aapply(func, args, kwds), "await pool.aapply(func, args, kwds)")
|
|
411
|
+
|
|
412
|
+
def map_async(
|
|
413
|
+
self,
|
|
414
|
+
func: Callable[[Any], Any],
|
|
415
|
+
iterable: Iterable[Any],
|
|
416
|
+
chunksize: int | None = None,
|
|
417
|
+
callback: Callable[[Any], object] | None = None,
|
|
418
|
+
error_callback: Callable[[BaseException], object] | None = None,
|
|
419
|
+
) -> AsyncResult:
|
|
420
|
+
self._check_running()
|
|
421
|
+
calls = self._map_calls(_run_chunk, func, iterable, chunksize)
|
|
422
|
+
return AsyncResult(self._spawn(self._gather_map(calls)), callback, error_callback)
|
|
423
|
+
|
|
424
|
+
def starmap_async(
|
|
425
|
+
self,
|
|
426
|
+
func: Callable[..., Any],
|
|
427
|
+
iterable: Iterable[Sequence[Any]],
|
|
428
|
+
chunksize: int | None = None,
|
|
429
|
+
callback: Callable[[Any], object] | None = None,
|
|
430
|
+
error_callback: Callable[[BaseException], object] | None = None,
|
|
431
|
+
) -> AsyncResult:
|
|
432
|
+
self._check_running()
|
|
433
|
+
calls = self._map_calls(_run_star_chunk, func, iterable, chunksize)
|
|
434
|
+
return AsyncResult(self._spawn(self._gather_map(calls)), callback, error_callback)
|
|
435
|
+
|
|
436
|
+
def apply_async(
|
|
437
|
+
self,
|
|
438
|
+
func: Callable[..., Any],
|
|
439
|
+
args: Sequence[Any] = (),
|
|
440
|
+
kwds: Mapping[str, Any] | None = None,
|
|
441
|
+
callback: Callable[[Any], object] | None = None,
|
|
442
|
+
error_callback: Callable[[BaseException], object] | None = None,
|
|
443
|
+
) -> AsyncResult:
|
|
444
|
+
self._check_running()
|
|
445
|
+
task = self._spawn(self._pool.submit(func, *args, **dict(kwds or {})))
|
|
446
|
+
return AsyncResult(task, callback, error_callback)
|
|
447
|
+
|
|
448
|
+
def close(self) -> None:
|
|
449
|
+
"""Stop accepting submissions; outstanding work keeps running —
|
|
450
|
+
drain it with ``await pool.ajoin()`` (or ``join()`` under JSPI)."""
|
|
451
|
+
if self._state == _RUN:
|
|
452
|
+
self._state = _CLOSE
|
|
453
|
+
|
|
454
|
+
def join(self) -> None:
|
|
455
|
+
if self._state == _RUN:
|
|
456
|
+
raise ValueError("Pool is still running")
|
|
457
|
+
return _block_on(self.ajoin(), "await pool.ajoin()")
|
|
458
|
+
|
|
459
|
+
def terminate(self) -> None:
|
|
460
|
+
"""Stop without draining, approximately (documented): cancel batched
|
|
461
|
+
chunks not yet started plus every tracked pending future, then
|
|
462
|
+
terminate the JS pool's idle workers. In-flight worker calls run to
|
|
463
|
+
completion — no preemption without an interrupt buffer."""
|
|
464
|
+
self._state = _TERMINATE
|
|
465
|
+
for batch in list(self._active_batches):
|
|
466
|
+
batch.cancel()
|
|
467
|
+
for task in list(self._pending):
|
|
468
|
+
task.cancel()
|
|
469
|
+
self._pool.terminate()
|
|
470
|
+
|
|
471
|
+
def __enter__(self) -> Pool:
|
|
472
|
+
self._check_running()
|
|
473
|
+
return self
|
|
474
|
+
|
|
475
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
476
|
+
self.terminate()
|
|
477
|
+
|
|
478
|
+
async def __aenter__(self) -> Pool:
|
|
479
|
+
self._check_running()
|
|
480
|
+
return self
|
|
481
|
+
|
|
482
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
483
|
+
self.terminate()
|