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.
@@ -0,0 +1,12 @@
1
+ node_modules/
2
+ dist/
3
+ .venv/
4
+ test-results/
5
+ _output/
6
+ bench/results/node-smoke*
7
+ playwright-report/
8
+ .jupyterlite.doit.db
9
+ demos/jupyterlite/files/assets/
10
+ demos/jupyterlite/files/wheels/
11
+ demos/jupyterlite/pypi/
12
+ demos/jupyterlite/.cache/
@@ -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()