pyodide-pool 0.1.0__py3-none-any.whl
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/__init__.py +83 -0
- pyodide_pool/_bridge.py +236 -0
- pyodide_pool/_packages.py +99 -0
- pyodide_pool/loader.py +68 -0
- pyodide_pool/scheduler.py +209 -0
- pyodide_pool-0.1.0.dist-info/METADATA +9 -0
- pyodide_pool-0.1.0.dist-info/RECORD +8 -0
- pyodide_pool-0.1.0.dist-info/WHEEL +4 -0
pyodide_pool/__init__.py
ADDED
|
@@ -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}")
|
pyodide_pool/_bridge.py
ADDED
|
@@ -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))
|
pyodide_pool/loader.py
ADDED
|
@@ -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,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,8 @@
|
|
|
1
|
+
pyodide_pool/__init__.py,sha256=mIl2RE3dlASJYBJwVsr7UGZQKRg-HUbUXC_m0xS4UyQ,2616
|
|
2
|
+
pyodide_pool/_bridge.py,sha256=i7BH39YXr4yY3bToEdMFInH7xoDfAknLKgtFe1OrG3U,8913
|
|
3
|
+
pyodide_pool/_packages.py,sha256=zf0fOQaatYv7-mGqny9BYppPyB_s3m-WytoymrokM1w,3770
|
|
4
|
+
pyodide_pool/loader.py,sha256=zCZhBQkZL5FD_L0308xsDSfsiba3j5epbVVX8kWHO3Q,3264
|
|
5
|
+
pyodide_pool/scheduler.py,sha256=ItS14NLiV5nQeLTO5SnQrzWiC2rhNoRzQehmvP8CwHo,8598
|
|
6
|
+
pyodide_pool-0.1.0.dist-info/METADATA,sha256=Esy-HZEL5-d0MBsQJQKAhoKMWSpKK9_GZxzVKmi22m8,286
|
|
7
|
+
pyodide_pool-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
pyodide_pool-0.1.0.dist-info/RECORD,,
|