Re-track: Pyodide/PyScript browser support (rebased on latest main) - #135
Re-track: Pyodide/PyScript browser support (rebased on latest main)#135evnchn wants to merge 13 commits into
Conversation
Enable NiceGUI apps to run fully client-side using Pyodide (Python compiled to WebAssembly). No server is required — the app loads as a static page. Core changes: - Add nicegui/pyodide/ package: PyodideBridge (replaces socket.io), PyodideOutbox (microtask-based auto-flush), PyodideRuntime (mount/render) - Add nicegui/pyodide_compat.py and nicegui/page_pyodide.py for Pyodide environment detection and page configuration - Guard server-only imports (FastAPI, Starlette, uvicorn, aiofiles) behind IS_PYODIDE checks across ~20 core modules - Extend nicegui.js with Pyodide bridge mode: handleMessage dispatcher, createNiceGUIApp() global, event/JS-response routing via window.niceguiBridge - Add factory-based file upload in upload.js for client-side file reading (FileReader + base64) instead of HTTP POST - Add Pyodide-aware CSS loading in markdown.js for codehilite resources Example & tooling: - examples/pyodide/: full SPA demo with sub-page routing, data binding, timers, forms, markdown, mermaid diagrams, file upload/download, dark mode - prepare.py: copies static assets, components, ESM bundles; generates import maps; builds stripped wheel (19MB → 330KB) - test_pyodide.py: comprehensive Playwright test suite (22 checks, 0 errors) - test_pyodide_import.py: fast offline import chain validation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Fixed CI failures: mypy name-clash in context.py (introduced local |
|
Pylint: silenced 21 remaining warnings (docstrings on pyodide stubs, disables for intentional protected access / super-init skip / unused kwargs in socket.io-compatible signatures). |
Ruff RUF100 flagged unused `# noqa: E402` on lines where E402 doesn't fire (nicegui/app/app.py, nicegui/elements/upload.py, nicegui/run.py). For nicegui/ui.py the multi-line from-import needed the noqa on the first line not the continuation.
Switch aiofiles' async-context-manager open to a synchronous with-block in get_range_response's content_reader. aiofiles closes via run_in_executor, which races with worker-pool shutdown and leaks the underlying BufferedReader / FileIO when the generator is aclose'd before the close task drains (surfaced by test_malicious_chunk_size_is_clamped's three back-to-back requests). A sync open inside a with-block guarantees the handle is closed before the generator frame exits, eliminating the ResourceWarning. File reads are ~1-8 KB; blocking impact is negligible and dominated by the network send anyway. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Fixed unclosed file handle in |
There was a problem hiding this comment.
Pull request overview
Rebases and re-tracks upstream Pyodide/PyScript browser support work, aiming to let NiceGUI import and run in a Pyodide environment by conditionally disabling server-only functionality and introducing a browser-side runtime/bridge.
Changes:
- Make many server-only dependencies optional at import time (FastAPI/Starlette/Uvicorn/aiofiles/ifaddr, etc.) to keep
import niceguiworking in Pyodide. - Add a Pyodide runtime with a socket.io replacement bridge + outbox flushing, and route
ui.pageto a Pyodide-specific page implementation. - Extend the frontend (
nicegui.js) to support a “bridge mode” (no socket.io), including a new global entrypoint to mount the app in-browser and support uploads/events/JS responses.
Reviewed changes
Copilot reviewed 43 out of 43 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| nicegui/welcome.py | Makes ifaddr optional for Pyodide-friendly imports. |
| nicegui/version.py | Adds a fallback __version__ when package metadata is unavailable. |
| nicegui/ui.py | Patches lazy imports for Pyodide mode (route page, remove run/run_with). |
| nicegui/sub_pages_router.py | Adds Pyodide-aware behavior when no server request/routes exist. |
| nicegui/storage.py | Makes FastAPI/Starlette imports optional to avoid import failures in Pyodide. |
| nicegui/staticfiles.py | Avoids hard dependency on Starlette StaticFiles for Pyodide importability. |
| nicegui/static/nicegui.js | Adds “bridge mode” message transport, Pyodide entrypoint, upload/event routing changes. |
| nicegui/server.py | Makes uvicorn optional to support environments without server dependencies. |
| nicegui/run.py | Makes concurrent.futures-related imports optional for Pyodide compatibility. |
| nicegui/pyodide_compat.py | Introduces Pyodide environment detection. |
| nicegui/pyodide/runtime.py | Adds browser runtime to mount NiceGUI and wire Python↔JS callbacks. |
| nicegui/pyodide/outbox_pyodide.py | Adds Pyodide-specific outbox with microtask-based auto-flush. |
| nicegui/pyodide/bridge.py | Adds a socket.io-like bridge that forwards emits to window.niceguiBridge. |
| nicegui/pyodide/init.py | Exposes PyodideRuntime in the nicegui.pyodide package. |
| nicegui/persistence/file_persistent_dict.py | Makes aiofiles optional for Pyodide importability. |
| nicegui/page_pyodide.py | Adds a minimal page stub for Pyodide (no routes). |
| nicegui/page_arguments.py | Makes QueryParams import optional to avoid Starlette dependency in Pyodide. |
| nicegui/native/native_config.py | Makes native event manager import optional. |
| nicegui/native/init.py | Makes native imports optional (avoid import failures). |
| nicegui/json/builtin_wrapper.py | Makes FastAPI JSONResponse optional. |
| nicegui/favicon.py | Makes FastAPI response imports optional. |
| nicegui/event_listener.py | Makes FastAPI Request import optional. |
| nicegui/elements/upload_files.py | Makes server-side upload parsing deps optional (aiofiles/anyio/starlette). |
| nicegui/elements/upload.py | Disables server upload route registration in Pyodide mode. |
| nicegui/elements/upload.js | Adds QUploader factory for client-side upload via bridge in Pyodide mode. |
| nicegui/elements/sub_pages.py | Makes QueryParams optional for Pyodide importability. |
| nicegui/elements/markdown.py | Makes FastAPI PlainTextResponse optional. |
| nicegui/elements/markdown.js | Loads dynamic resources via local files in Pyodide mode. |
| nicegui/core.py | Relaxes core.sio typing to allow Pyodide bridge. |
| nicegui/context.py | Uses page_pyodide for script-mode client creation under Pyodide. |
| nicegui/client.py | Makes FastAPI/Jinja templates optional for Pyodide importability. |
| nicegui/app/range_response.py | Changes range streaming implementation; makes FastAPI imports optional. |
| nicegui/app/app.py | Introduces Pyodide stubs (no HTTP server) and conditional server imports. |
| nicegui/api_router.py | Makes FastAPI APIRouter optional and attempts to handle Pyodide mode. |
| nicegui/init.py | Adjusts public exports and initialization for Pyodide mode. |
| examples/pyodide/test_pyodide_import.py | Adds a fast offline import-chain test simulating Pyodide by blocking modules. |
| examples/pyodide/test_pyodide.py | Adds a Playwright test script for the Pyodide demo. |
| examples/pyodide/pyscript.toml | Adds PyScript config for the demo. |
| examples/pyodide/prepare.py | Adds demo preparation script (assets, components, import map, stripped wheel). |
| examples/pyodide/index.html | Adds PyScript-based demo HTML that pre-defines niceguiBridge. |
| examples/pyodide/entrypoint.py | Adds PyScript bootstrap: installs wheel + mounts runtime. |
| examples/pyodide/app.py | Adds a full in-browser demo app exercising core UI features. |
| examples/pyodide/.gitignore | Ignores generated demo artifacts (vendor files/components/wheel/screenshots). |
| def is_pyodide() -> bool: | ||
| """Check if running in a Pyodide (WebAssembly/Emscripten) environment.""" | ||
| return 'pyodide' in sys.modules or hasattr(sys, '_emscripten_info') |
There was a problem hiding this comment.
is_pyodide() checks 'pyodide' in sys.modules, which can become true on CPython if some unrelated package/module named pyodide is imported, potentially disabling server functionality unexpectedly. A more robust primary check is typically sys.platform == 'emscripten' (and/or hasattr(sys, '_emscripten_info')), with the sys.modules check as a fallback if needed.
There was a problem hiding this comment.
Applied in 0644b2f — now prefers sys.platform=='emscripten' and _emscripten_info, with sys.modules check as last-resort fallback.
| try: | ||
| import ifaddr | ||
| except ImportError: | ||
| ifaddr = None # type: ignore |
There was a problem hiding this comment.
The optional ifaddr import is set to None on ImportError, but _get_all_ips() later calls ifaddr.get_adapters() unconditionally. That will raise an AttributeError in environments where ifaddr isn't installed. Consider either (a) keeping ifaddr a required dependency and failing fast, or (b) returning an empty IP list / skipping URL collection when ifaddr is None (and optionally logging a short note).
There was a problem hiding this comment.
Applied in 0644b2f — _get_all_ips now returns [] when ifaddr is None.
| try: | ||
| import aiofiles | ||
| except ImportError: | ||
| aiofiles = None # type: ignore |
There was a problem hiding this comment.
aiofiles is made optional by assigning None on ImportError, but this module later uses aiofiles.open(...) and aiofiles.tempfile... unconditionally (e.g., in initialize() and backup()). If this change is intended for Pyodide/optional installs, please add a safe fallback (e.g., use Path.read_text/write_text or run.io_bound in async contexts) or raise a clear RuntimeError when async file persistence is requested without aiofiles.
There was a problem hiding this comment.
Applied in 0644b2f — initialize() falls back to initialize_sync() and backup() falls back to sync write when aiofiles is unavailable.
| def __init__(self, | ||
| path: str = '/', *, | ||
| title: str | None = None, | ||
| viewport: str | None = None, | ||
| favicon: str | Path | None = None, | ||
| dark: bool | None = None, | ||
| language: Language = ..., # type: ignore | ||
| response_timeout: float = 3.0, | ||
| reconnect_timeout: float | None = None, | ||
| **kwargs: Any, | ||
| ) -> None: | ||
| self._path = path | ||
| self.title = title | ||
| self.viewport = viewport | ||
| self.favicon = favicon | ||
| self.dark = dark | ||
| self.language = language | ||
| self.response_timeout = response_timeout | ||
| self.reconnect_timeout = reconnect_timeout | ||
| self.kwargs = kwargs | ||
|
|
||
| @property | ||
| def path(self) -> str: | ||
| """Return the page path.""" | ||
| return self._path | ||
|
|
||
| def resolve_title(self) -> str: | ||
| """Resolve the page title, falling back to the default.""" | ||
| return self.title or 'NiceGUI' | ||
|
|
||
| def resolve_viewport(self) -> str: | ||
| """Resolve the viewport meta value, falling back to the default.""" | ||
| return self.viewport or 'width=device-width, initial-scale=1' | ||
|
|
||
| def resolve_dark(self) -> bool | None: | ||
| """Resolve the dark mode setting.""" | ||
| return self.dark | ||
|
|
||
| def resolve_language(self) -> Language: | ||
| """Resolve the page language, falling back to ``'en-US'``.""" | ||
| return self.language if self.language is not ... else 'en-US' # type: ignore |
There was a problem hiding this comment.
page_pyodide.page currently hard-codes defaults (e.g., title -> 'NiceGUI', language -> 'en-US') instead of falling back to core.app.config.* like the server page implementation. This makes Pyodide behavior diverge from server mode and also ignores the run config that PyodideRuntime sets up (notably dark=False becomes None/auto). Consider using the same ellipsis-sentinel defaults as nicegui/page.py and resolving via core.app.config when values are unspecified.
There was a problem hiding this comment.
Applied in 0644b2f — resolve_* now reads from core.app.config like page.py, using the ellipsis sentinel for dark/language. PyodideRuntime seeds the run config before any page resolution.
| # NOTE: use synchronous file I/O with an explicit context manager to guarantee the | ||
| # handle is closed even when the client disconnects mid-stream; aiofiles' threadpool | ||
| # close was racing with worker shutdown and leaking the underlying file descriptor | ||
| # (see test_malicious_chunk_size_is_clamped). | ||
| with file.open('rb') as data: | ||
| data.seek(start) | ||
| remaining_bytes = end - start + 1 | ||
| while remaining_bytes > 0: | ||
| chunk = await data.read(min(chunk_size, remaining_bytes)) | ||
| chunk = data.read(min(chunk_size, remaining_bytes)) |
There was a problem hiding this comment.
content_reader is an async generator but now performs synchronous file reads (file.open + data.read(...)) on the event loop thread. For large files or slow disks this can block the event loop and degrade responsiveness for other requests. If aiofiles is problematic, consider using anyio.to_thread.run_sync (or similar) for the blocking read/seek while still ensuring the file handle is deterministically closed.
There was a problem hiding this comment.
Intentional — see the NOTE added in ec35dc4: aiofiles' threadpool close was racing with worker-pool shutdown and leaking the underlying BufferedReader when the generator was aclose'd mid-stream (test_malicious_chunk_size_is_clamped). Chunks here are 1-8 KB so sync reads are fine; switching to anyio.to_thread.run_sync would re-introduce the same cross-thread close race. Keeping the synchronous context manager for deterministic cleanup.
| try: | ||
| rel = comp.path.relative_to(ELEMENTS_DIR) | ||
| except ValueError: | ||
| continue | ||
| components.append({'url': f'./components/{rel}', 'tag': comp.tag}) |
There was a problem hiding this comment.
rel is a Path produced by relative_to(...) and is interpolated directly into a URL ('./components/{rel}'). On Windows this will use backslashes, producing invalid module URLs for import(...). Consider using rel.as_posix() (or PurePosixPath) when constructing URLs in both the runtime and outbox component lists.
There was a problem hiding this comment.
Applied in 0644b2f — using rel.as_posix() so component URLs work on Windows.
| window.clientId = options.query.client_id; | ||
| const url = window.location.protocol === "https:" ? "wss://" : "ws://" + window.location.host; | ||
| options.query.document_id = window.documentId; |
There was a problem hiding this comment.
In standard (socket.io) mode the websocket base URL is now computed as window.location.protocol === "https:" ? "wss://" : "ws://" + window.location.host. For HTTPS this yields just "wss://" (missing host), breaking connections on secure deployments. Use the previous parenthesized form so the host is appended for both schemes.
There was a problem hiding this comment.
Applied in 0644b2f — restored parenthesized form: (protocol === 'https:' ? 'wss://' : 'ws://') + host.
| const url = msg.path.startsWith("/") ? (options.prefix || "") + msg.path : msg.path; | ||
| const target = msg.new_tab ? "_blank" : "_self"; | ||
| window.open(url, target); | ||
| }, | ||
| download: (msg) => download(msg.src, msg.filename, msg.media_type, options.prefix), | ||
| download: (msg) => download(msg.src, msg.filename, msg.media_type, options.prefix || ""), | ||
| notify: (msg) => Quasar.Notify.create(msg), |
There was a problem hiding this comment.
In Pyodide mode, PyodideBridge JSON-encodes bytes as objects like { "__b64": "..." }. The JS download handler forwards msg.src directly to download(...), which treats non-strings as raw Blob parts; passing the {__b64: ...} object will produce an invalid downloaded file. Consider decoding msg.src.__b64 to a Uint8Array (via atob) before calling download, or change the bridge to send a plain base64 string and handle it explicitly here.
There was a problem hiding this comment.
Applied in 0644b2f — download handler now detects {__b64: ...} and decodes via atob() into a Uint8Array before calling download().
| if not IS_PYODIDE: | ||
| from .page import page as ui_page | ||
| else: | ||
| ui_page = None # type: ignore |
There was a problem hiding this comment.
When IS_PYODIDE is true, ui_page is set to None, but APIRouter.page() later unconditionally calls ui_page(...). That will raise at runtime if APIRouter.page is used in Pyodide mode. Consider making APIRouter.page explicitly raise a clear RuntimeError in Pyodide mode, or refactor so the method doesn't call None.
There was a problem hiding this comment.
Applied in 0644b2f — APIRouter.page() now raises a clear RuntimeError when ui_page is None (Pyodide mode, no HTTP routes).
| rel = c.path.relative_to(ELEMENTS_DIR) | ||
| except ValueError: | ||
| continue | ||
| comp_list.append({'url': f'./components/{rel}', 'tag': c.tag}) | ||
| if comp_list: |
There was a problem hiding this comment.
Component URLs are built from Path objects (rel = c.path.relative_to(...)) and interpolated directly into './components/{rel}'. On Windows this will yield backslashes in the URL, which breaks dynamic import(...) in the browser. Use rel.as_posix() when constructing these URLs.
There was a problem hiding this comment.
Applied in 0644b2f — using rel.as_posix() here too.
…onal deps, cross-platform URLs, JS bridge fixes - pyodide_compat: prefer sys.platform=='emscripten' and _emscripten_info over sys.modules check - welcome: guard _get_all_ips when ifaddr is None - file_persistent_dict: fall back to sync I/O when aiofiles is unavailable - page_pyodide: resolve defaults via core.app.config.* (matches page.py behavior) - runtime/outbox_pyodide: use rel.as_posix() so component URLs work on Windows - nicegui.js: fix wss URL operator precedence bug on HTTPS; decode bridge __b64 payloads before download() - api_router: raise a clear RuntimeError when page() is called in Pyodide mode
…feedback) Wires the existing examples/pyodide/test_pyodide.py --click into CI as a new reusable workflow (_pyodide_e2e.yml), gated by ci-gate.yml. The test boots the Pyodide example in headless Chromium, waits for __pyodide_ready, and asserts end-to-end round trips: counter binding (3 rapid-fire clicks -> Count: 3), timer ticks, run_javascript, notifications, async task (sleep), refreshables, form elements (input/slider/checkbox/select/switch/radio/toggle), markdown + tables + tabs + dialogs + expansion + tooltip + badge, data URI image, mermaid ESM rendering + node click handler, and file upload byte roundtrip (exercises PyodideBridge._encode_bytes). Page errors and console errors fail the job.
PEP 562 lazy imports (PR zauberzeug#5303) mean element classes aren't defined until their ui.* name is accessed. prepare.py's iteration of js_components was seeing an empty (or near-empty) dict, so component JS files like dark_mode.js never got copied to the demo dir, causing 404s at browser-side mount time. Also sort importmap entries for deterministic index.html output.
ESM-backed elements (mermaid, etc.) call dist.stat() at import time for server-side cache-busting. In Pyodide the stripped wheel drops dist/ dirs (ESM bundles are served via importmap, not the server), so the stat raises FileNotFoundError before the element class finishes loading. Guard the stat with a try/except so import-time side effects stay benign when the bundle is shipped out-of-band.
Pyodide sub_pages navigation re-triggers setup_esm_package() for elements like mermaid, which previously asserted on duplicate names. Same-path re-registration is now a silent no-op; only genuine conflicts (same name, different path) still raise.
…RL warning
The upload E2E test failed because QUploader's auto_upload defaults to
False — files were queued but never sent to the bridge. Setting
auto_upload=True makes the demo upload immediately on file selection.
Also replace the empty-string factory URL with a data-URI ("data:,") to
suppress Quasar's "invalid or no URL specified" console error without
causing a real XHR.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tracking PR in fork for user review.
Context: Upstream PR zauberzeug#5776 is still open but stale (last updated 2026-03-13). This branch rebases onto latest upstream main (e9d9d19) to resolve drift conflicts. Do NOT push this back to the upstream PR — user will decide whether to update zauberzeug#5776 separately.
Status: Rebased, py_compile clean (modulo pyodide entrypoint top-level await which is Pyodide-runtime-only). Needs user review.