Add browser_fetch helpers and async HTTP demos for Pyodide
Ship pyfetch-based fetch utilities in lib/, run asyncio scripts via nest-asyncio in the worker, and add sample demos for HTTPS in the browser. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
38
lib/browser_fetch.py
Normal file
38
lib/browser_fetch.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Browser-friendly async HTTP for the Pyodide worker.
|
||||
|
||||
These helpers use ``pyodide.http.pyfetch``, which maps to the browser
|
||||
``fetch`` API (no Python TLS stack). Prefer them for ``https://`` in
|
||||
Pyodide: ``aiohttp`` in the worker often raises
|
||||
``RuntimeError('SSL is not supported.')`` for HTTPS even though the wheel
|
||||
exists, because user-level SSL is not wired the same as on CPython.
|
||||
|
||||
The browser's normal rules apply: the page is served over HTTPS so
|
||||
``http://`` URLs are blocked as mixed content, and the response host must
|
||||
send permissive CORS headers (e.g. ``Access-Control-Allow-Origin``) or the
|
||||
browser hides the body even if the request succeeded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def fetch_text(url: str) -> str:
|
||||
from pyodide.http import pyfetch
|
||||
|
||||
r = await pyfetch(url)
|
||||
return await r.text()
|
||||
|
||||
|
||||
async def fetch_bytes(url: str) -> bytes:
|
||||
from pyodide.http import pyfetch
|
||||
|
||||
r = await pyfetch(url)
|
||||
return await r.bytes()
|
||||
|
||||
|
||||
async def fetch_json(url: str, **kwargs: Any) -> Any:
|
||||
from pyodide.http import pyfetch
|
||||
|
||||
r = await pyfetch(url)
|
||||
return await r.json(**kwargs)
|
||||
32
src/static/bundled-demos/demo/aiohttp_fetch_demo.py
Normal file
32
src/static/bundled-demos/demo/aiohttp_fetch_demo.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Async HTTPS GET of Pyodide’s lock file (browser-safe).
|
||||
|
||||
Older Pyodide + ``aiohttp`` examples used ``ClientSession`` here, but the
|
||||
Pyodide ``aiohttp`` wheel often raises ``RuntimeError('SSL is not supported.')``
|
||||
for ``https://`` — there is no real Python TLS stack in the worker; traffic must
|
||||
go through the browser’s ``fetch``.
|
||||
|
||||
This demo uses the shared ``browser_fetch`` helpers (``pyodide.http.pyfetch``
|
||||
under the hood), same idea as ``async_fetch_demo.py``.
|
||||
|
||||
CORS still applies — jsDelivr allows cross-origin GETs. ``print(..., flush=True)``
|
||||
helps batched worker stdout appear before the run finishes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from browser_fetch import fetch_json
|
||||
|
||||
|
||||
async def main():
|
||||
url = "https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide-lock.json"
|
||||
print("Fetching", url, "via browser_fetch.fetch_json ...", flush=True)
|
||||
data = await fetch_json(url)
|
||||
info = data.get("info") or {}
|
||||
packages = data.get("packages") or {}
|
||||
print("pyodide-lock version:", info.get("version"), flush=True)
|
||||
print("python runtime :", info.get("python"), flush=True)
|
||||
print("indexed packages :", len(packages), flush=True)
|
||||
print("aiohttp wheel in lock:", "aiohttp" in packages, flush=True)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
27
src/static/bundled-demos/demo/async_fetch_demo.py
Normal file
27
src/static/bundled-demos/demo/async_fetch_demo.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Async HTTP in the browser (Pyodide).
|
||||
|
||||
``aiohttp`` and similar clients use OS sockets, which Wasm does not provide, so
|
||||
a ``session.get(...)`` can hang after ``print("Running")`` with no body.
|
||||
|
||||
Use ``pyodide.http.pyfetch`` or the shared ``browser_fetch`` helpers. From an
|
||||
HTTPS editor page, use ``https://`` URLs (mixed content blocks ``http://``).
|
||||
|
||||
Many sites do not send CORS headers, so the browser blocks the response even
|
||||
when the URL is valid. This demo uses jsDelivr JSON that allows cross-origin
|
||||
GET (same host Pyodide loads from).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from browser_fetch import fetch_json
|
||||
|
||||
|
||||
async def main():
|
||||
print("Running")
|
||||
url = "https://cdn.jsdelivr.net/pyodide/v0.26.4/full/repodata.json"
|
||||
data = await fetch_json(url)
|
||||
info = data.get("info") or {}
|
||||
print("Fetched repodata; pyodide lock says:", info.get("version"), info.get("python"))
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
38
src/static/bundled-lib/browser_fetch.py
Normal file
38
src/static/bundled-lib/browser_fetch.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Browser-friendly async HTTP for the Pyodide worker.
|
||||
|
||||
These helpers use ``pyodide.http.pyfetch``, which maps to the browser
|
||||
``fetch`` API (no Python TLS stack). Prefer them for ``https://`` in
|
||||
Pyodide: ``aiohttp`` in the worker often raises
|
||||
``RuntimeError('SSL is not supported.')`` for HTTPS even though the wheel
|
||||
exists, because user-level SSL is not wired the same as on CPython.
|
||||
|
||||
The browser's normal rules apply: the page is served over HTTPS so
|
||||
``http://`` URLs are blocked as mixed content, and the response host must
|
||||
send permissive CORS headers (e.g. ``Access-Control-Allow-Origin``) or the
|
||||
browser hides the body even if the request succeeded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def fetch_text(url: str) -> str:
|
||||
from pyodide.http import pyfetch
|
||||
|
||||
r = await pyfetch(url)
|
||||
return await r.text()
|
||||
|
||||
|
||||
async def fetch_bytes(url: str) -> bytes:
|
||||
from pyodide.http import pyfetch
|
||||
|
||||
r = await pyfetch(url)
|
||||
return await r.bytes()
|
||||
|
||||
|
||||
async def fetch_json(url: str, **kwargs: Any) -> Any:
|
||||
from pyodide.http import pyfetch
|
||||
|
||||
r = await pyfetch(url)
|
||||
return await r.json(**kwargs)
|
||||
@@ -6,6 +6,26 @@ const PYODIDE_INDEX_URL = 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/';
|
||||
let pyodide = null;
|
||||
let loadingPromise = null;
|
||||
|
||||
/**
|
||||
* Install PyPI wheels into this Pyodide interpreter via micropip.
|
||||
* Pure-Python wheels (or Pyodide-built packages) work; manylinux
|
||||
* binary wheels that are not built for Emscripten will fail.
|
||||
*/
|
||||
async function micropipInstallSpecs(p, specs) {
|
||||
const list = specs
|
||||
.map((s) => String(s || '').trim())
|
||||
.filter(Boolean);
|
||||
if (!list.length) {
|
||||
return;
|
||||
}
|
||||
p.globals.set('__micropip_specs', p.toPy(list));
|
||||
await p.runPythonAsync(`
|
||||
import micropip
|
||||
specs = list(__micropip_specs)
|
||||
await micropip.install(specs)
|
||||
`);
|
||||
}
|
||||
|
||||
async function ensurePyodide() {
|
||||
if (pyodide) {
|
||||
return pyodide;
|
||||
@@ -20,9 +40,17 @@ async function ensurePyodide() {
|
||||
batched: (txt) => self.postMessage({ type: 'io', stream: 'stderr', text: txt }),
|
||||
});
|
||||
await p.loadPackage('micropip');
|
||||
/* Optional wheels (numpy, scipy, pandas, scikit-learn, …) are not preloaded —
|
||||
first worker init stays small. Install from user code with
|
||||
`await micropip.install("…", index_urls=[…])` (same host as `loadPyodide`).
|
||||
HTTPS: use `pyodide.http.pyfetch` / `browser_fetch`, not `aiohttp`. */
|
||||
await p.runPythonAsync(`
|
||||
import micropip
|
||||
await micropip.install("jedi")
|
||||
try:
|
||||
await micropip.install("nest-asyncio")
|
||||
except Exception:
|
||||
pass
|
||||
`);
|
||||
return p;
|
||||
})();
|
||||
@@ -137,13 +165,47 @@ self.onmessage = async (event) => {
|
||||
self.__serial_in_capacity = 0;
|
||||
}
|
||||
}
|
||||
await ensurePyodide();
|
||||
self.postMessage({ id, type: 'init', ok: true });
|
||||
const p = await ensurePyodide();
|
||||
const rawExtra =
|
||||
payload && Array.isArray(payload.persistedMicropipPackages)
|
||||
? payload.persistedMicropipPackages
|
||||
: [];
|
||||
const extra = rawExtra.map((s) => String(s || '').trim()).filter(Boolean);
|
||||
let micropipRestoreError = null;
|
||||
if (extra.length) {
|
||||
try {
|
||||
await micropipInstallSpecs(p, extra);
|
||||
} catch (err) {
|
||||
micropipRestoreError = err && err.message ? String(err.message) : String(err);
|
||||
}
|
||||
}
|
||||
const initReply = { id, type: 'init', ok: true };
|
||||
if (micropipRestoreError) {
|
||||
initReply.micropipRestoreError = micropipRestoreError;
|
||||
}
|
||||
self.postMessage(initReply);
|
||||
return;
|
||||
}
|
||||
|
||||
const p = await ensurePyodide();
|
||||
|
||||
if (type === 'micropipInstall') {
|
||||
const raw =
|
||||
payload && payload.specs != null
|
||||
? payload.specs
|
||||
: payload && payload.spec != null
|
||||
? [payload.spec]
|
||||
: [];
|
||||
const specs = (Array.isArray(raw) ? raw : [raw]).map((s) => String(s || '').trim()).filter(Boolean);
|
||||
if (!specs.length) {
|
||||
self.postMessage({ id, type: 'micropipInstall', ok: false, error: 'No package names given' });
|
||||
return;
|
||||
}
|
||||
await micropipInstallSpecs(p, specs);
|
||||
self.postMessage({ id, type: 'micropipInstall', ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'complete') {
|
||||
const rel = String(payload.path || 'scratch.py').replace(/^\/+/, '');
|
||||
const vpath = `/workspace/${rel}`;
|
||||
@@ -168,12 +230,12 @@ for rel_path, body in extra.items():
|
||||
os.makedirs(os.path.dirname(__cm_path), exist_ok=True)
|
||||
with open(__cm_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(__cm_code)
|
||||
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
|
||||
for entry in ("/workspace/code", "/workspace/demos", "/workspace/lib", "/workspace"):
|
||||
if entry not in sys.path:
|
||||
sys.path.insert(0, entry)
|
||||
sys.path.append(entry)
|
||||
proj = jedi.Project(
|
||||
"/workspace",
|
||||
added_sys_path=["/workspace/code", "/workspace/lib", "/workspace"],
|
||||
added_sys_path=["/workspace/code", "/workspace/demos", "/workspace/lib", "/workspace"],
|
||||
)
|
||||
s = jedi.Script(code=__cm_code, path=__cm_path, project=proj)
|
||||
items = s.complete(line=__cm_line, column=__cm_col)
|
||||
@@ -206,12 +268,12 @@ for rel_path, body in extra.items():
|
||||
os.makedirs(os.path.dirname(__diag_path), exist_ok=True)
|
||||
with open(__diag_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(__diag_code)
|
||||
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
|
||||
for entry in ("/workspace/code", "/workspace/demos", "/workspace/lib", "/workspace"):
|
||||
if entry not in sys.path:
|
||||
sys.path.insert(0, entry)
|
||||
sys.path.append(entry)
|
||||
proj = jedi.Project(
|
||||
"/workspace",
|
||||
added_sys_path=["/workspace/code", "/workspace/lib", "/workspace"],
|
||||
added_sys_path=["/workspace/code", "/workspace/demos", "/workspace/lib", "/workspace"],
|
||||
)
|
||||
s = jedi.Script(code=__diag_code, path=__diag_path, project=proj)
|
||||
errs = s.get_syntax_errors()
|
||||
@@ -243,15 +305,62 @@ for rel, body in files.items():
|
||||
with open(full, "w", encoding="utf-8") as fh:
|
||||
fh.write(str(body))
|
||||
|
||||
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
|
||||
for entry in ("/workspace/code", "/workspace/demos", "/workspace/lib", "/workspace"):
|
||||
if entry not in sys.path:
|
||||
sys.path.insert(0, entry)
|
||||
sys.path.append(entry)
|
||||
|
||||
os.chdir("/workspace")
|
||||
main = __run_main
|
||||
sys.argv = [main] + list(__run_args)
|
||||
# runpy.run_path is synchronous; user asyncio.run() needs an *active* loop (nest_asyncio).
|
||||
# Await an async wrapper so get_running_loop() works during the script.
|
||||
import asyncio
|
||||
import asyncio.events
|
||||
import asyncio.base_events
|
||||
def _patch_set_debug(cls):
|
||||
_orig = cls.set_debug
|
||||
def set_debug(self, enabled):
|
||||
try:
|
||||
return _orig(self, enabled)
|
||||
except NotImplementedError:
|
||||
pass
|
||||
cls.set_debug = set_debug
|
||||
_patch_set_debug(asyncio.events.AbstractEventLoop)
|
||||
_patch_set_debug(asyncio.base_events.BaseEventLoop)
|
||||
async def __pyodide_run_user_script():
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply(loop)
|
||||
except Exception:
|
||||
try:
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _pyodide_asyncio_run(coro, *, debug=False):
|
||||
task = asyncio.ensure_future(coro, loop=loop)
|
||||
return loop.run_until_complete(task)
|
||||
|
||||
asyncio.run = _pyodide_asyncio_run
|
||||
import runpy
|
||||
runpy.run_path(main, run_name="__main__")
|
||||
|
||||
await __pyodide_run_user_script()
|
||||
`);
|
||||
try {
|
||||
await p.runPythonAsync(`
|
||||
import sys
|
||||
for _s in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_s.flush()
|
||||
except Exception:
|
||||
pass
|
||||
`);
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
self.postMessage({ id, type: 'run', ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user