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.
@@ -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))
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any