Skip to content

Re-track: Pyodide/PyScript browser support (rebased on latest main) - #135

Open
evnchn wants to merge 13 commits into
mainfrom
pyodide-support-v2-rebased
Open

Re-track: Pyodide/PyScript browser support (rebased on latest main)#135
evnchn wants to merge 13 commits into
mainfrom
pyodide-support-v2-rebased

Conversation

@evnchn

@evnchn evnchn commented Apr 15, 2026

Copy link
Copy Markdown
Owner

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.

evnchn and others added 4 commits April 15, 2026 15:07
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>
@evnchn

evnchn commented Apr 15, 2026

Copy link
Copy Markdown
Owner Author

Fixed CI failures: mypy name-clash in context.py (introduced local page_cls: Any annotation), base-class drift in server.py (_ConfigBase/_ServerBase: type[Any]), list-variance in runtime.py (typed files: list[FileUpload]); pylint ignores for Pyodide-runtime-only imports (js, pyodide.ffi), staticfiles/server dynamic inheritance, api_router ui_page unbound branch, and except-order in run.py.

@evnchn

evnchn commented Apr 15, 2026

Copy link
Copy Markdown
Owner Author

Pylint: silenced 21 remaining warnings (docstrings on pyodide stubs, disables for intentional protected access / super-init skip / unused kwargs in socket.io-compatible signatures).

evnchn and others added 2 commits April 15, 2026 18:17
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>
@evnchn

evnchn commented Apr 15, 2026

Copy link
Copy Markdown
Owner Author

Fixed unclosed file handle in nicegui/app/range_response.py - swapped aiofiles async context manager for a synchronous with file.open('rb') inside the content_reader. aiofiles closes via run_in_executor, which was racing with worker-pool shutdown and leaking the underlying BufferedReader/FileIO when the streaming generator was aclose'd before the close task drained. Sync reads are fine here since chunks are 1-8 KB. Local run of test_malicious_chunk_size_is_clamped and the full test_serving_files.py now passes (13/13). Pushed as ec35dc4.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 nicegui working in Pyodide.
  • Add a Pyodide runtime with a socket.io replacement bridge + outbox flushing, and route ui.page to 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).

Comment thread nicegui/pyodide_compat.py Outdated
Comment on lines +7 to +9
def is_pyodide() -> bool:
"""Check if running in a Pyodide (WebAssembly/Emscripten) environment."""
return 'pyodide' in sys.modules or hasattr(sys, '_emscripten_info')

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 0644b2f — now prefers sys.platform=='emscripten' and _emscripten_info, with sys.modules check as last-resort fallback.

Comment thread nicegui/welcome.py
Comment on lines +3 to +6
try:
import ifaddr
except ImportError:
ifaddr = None # type: ignore

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 0644b2f — _get_all_ips now returns [] when ifaddr is None.

Comment on lines +3 to +6
try:
import aiofiles
except ImportError:
aiofiles = None # type: ignore

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 0644b2f — initialize() falls back to initialize_sync() and backup() falls back to sync write when aiofiles is unavailable.

Comment thread nicegui/page_pyodide.py Outdated
Comment on lines +18 to +58
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

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +59 to +67
# 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))

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread nicegui/pyodide/runtime.py Outdated
Comment on lines +129 to +133
try:
rel = comp.path.relative_to(ELEMENTS_DIR)
except ValueError:
continue
components.append({'url': f'./components/{rel}', 'tag': comp.tag})

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 0644b2f — using rel.as_posix() so component URLs work on Windows.

Comment thread nicegui/static/nicegui.js
Comment on lines +494 to +496
window.clientId = options.query.client_id;
const url = window.location.protocol === "https:" ? "wss://" : "ws://" + window.location.host;
options.query.document_id = window.documentId;

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 0644b2f — restored parenthesized form: (protocol === 'https:' ? 'wss://' : 'ws://') + host.

Comment thread nicegui/static/nicegui.js
Comment on lines +459 to 464
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),

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 0644b2f — download handler now detects {__b64: ...} and decodes via atob() into a Uint8Array before calling download().

Comment thread nicegui/api_router.py
Comment on lines +13 to +16
if not IS_PYODIDE:
from .page import page as ui_page
else:
ui_page = None # type: ignore

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 0644b2f — APIRouter.page() now raises a clear RuntimeError when ui_page is None (Pyodide mode, no HTTP routes).

Comment on lines +80 to +84
rel = c.path.relative_to(ELEMENTS_DIR)
except ValueError:
continue
comp_list.append({'url': f'./components/{rel}', 'tag': c.tag})
if comp_list:

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 0644b2f — using rel.as_posix() here too.

evnchn and others added 6 commits April 15, 2026 20:28
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants