Skip to main content

Jupyter widgets with pure Python rendering via Pyodide

Project description

pywidget logo. Two connected arrows in Python blue and yellow pointing clockwise, symbolizing bidirectional sync between kernel and browser
pywidget

Write Jupyter widgets entirely in Python — no JavaScript required.

pywidget lets you define both the kernel-side state and the browser-side rendering of a Jupyter widget in a single Python class. Your rendering code runs in the browser via Pyodide (CPython compiled to WebAssembly) and syncs state bidirectionally with the kernel through anywidget.

Runs anywhere anywidget runs — Jupyter Lab, Jupyter Notebook, marimo, and more.

Why pywidget?

  • One language — define state, rendering, and interaction logic all in Python. No JavaScript, no separate frontend build step.
  • Responsive UI — event handlers run locally in the browser via Pyodide. No round-trip to the kernel on every click or keystroke.
  • Full Python in the browser — import NumPy, Pandas, scikit-learn, and 250+ other packages directly in your rendering code via Pyodide's scientific stack.
  • Real methods, not strings — write render and update as regular methods on your class with full IDE support (autocomplete, linting, go-to-definition).
  • Zero build infrastructurepip install pywidget and go. No npm, no webpack, no jupyter labextension develop.
flowchart LR
    subgraph Kernel ["Kernel (CPython)"]
        A["class MyWidget(PyWidget):\n    count = Int(0)\n    def render(self, el, model): ..."]
    end

    subgraph Browser ["Browser (Pyodide / WASM)"]
        B["def render(el, model):\n    el.innerHTML = ...\n    model.set('count', 1)"]
    end

    A -- "traitlet sync\n(WebSocket)" --> B
    B -- "model.set() +\nsave_changes()" --> A

Install

pip install pywidget

For use with marimo:

pip install pywidget marimo

Quick example

import traitlets
from pywidget import PyWidget

class CounterWidget(PyWidget):
    count = traitlets.Int(0).tag(sync=True)

    def render(self, el, model):
        count = model.get("count")
        el.innerHTML = f"""
        <div style="display:flex; align-items:center; gap:12px; font-family:sans-serif;">
            <button id="dec">-</button>
            <span id="display">{count}</span>
            <button id="inc">+</button>
        </div>
        """
        def on_inc(event):
            new = model.get("count") + 1
            model.set("count", new)
            model.save_changes()
            el.querySelector("#display").textContent = str(new)

        def on_dec(event):
            new = model.get("count") - 1
            model.set("count", new)
            model.save_changes()
            el.querySelector("#display").textContent = str(new)

        el.querySelector("#inc").addEventListener("click", create_proxy(on_inc))
        el.querySelector("#dec").addEventListener("click", create_proxy(on_dec))

    def update(self, el, model):
        display = el.querySelector("#display")
        if display:
            display.textContent = str(model.get("count"))

CounterWidget()

Usage with marimo

In marimo, wrap your widget instance with mo.ui.anywidget() to integrate with marimo's reactive execution engine:

import marimo as mo
import traitlets
from pywidget import PyWidget

class CounterWidget(PyWidget):
    count = traitlets.Int(0).tag(sync=True)

    def render(self, el, model):
        count = model.get("count")
        el.innerHTML = f"""
        <div style="display:flex; align-items:center; gap:12px; font-family:sans-serif;">
            <button id="dec">-</button>
            <span id="display">{count}</span>
            <button id="inc">+</button>
        </div>
        """
        def on_inc(event):
            new = model.get("count") + 1
            model.set("count", new)
            model.save_changes()
            el.querySelector("#display").textContent = str(new)

        def on_dec(event):
            new = model.get("count") - 1
            model.set("count", new)
            model.save_changes()
            el.querySelector("#display").textContent = str(new)

        el.querySelector("#inc").addEventListener("click", create_proxy(on_inc))
        el.querySelector("#dec").addEventListener("click", create_proxy(on_dec))

widget = mo.ui.anywidget(CounterWidget())
widget

Changes to synced traitlets propagate through marimo's dependency graph, so downstream cells re-execute automatically. See examples/pywidget_marimo_demo.py for a full walkthrough.

The render and update methods look like regular Python, but they execute in the browser inside a Pyodide runtime. At class-creation time pywidget extracts their source via inspect.getsource(), strips self, and sends the code to the frontend. The kernel never runs these methods.

The model API

Inside render and update, the model object supports:

Method Description
model.get(name) Read a synced traitlet (returns Python-native types)
model.set(name, value) Write a synced traitlet (local until save_changes)
model.save_changes() Push pending set() calls to the kernel
model.on(event, callback) Subscribe to events (e.g. "change:count")

Browser-side builtins

The rendering namespace automatically includes:

  • create_proxy(fn) — prevent GC of Python callbacks passed to JS APIs like addEventListener
  • to_js(obj) — explicitly convert a Python object to a JS value
  • document — the browser's document object
  • console — the browser's console object

Installing packages in the browser

Use _py_packages to install packages via micropip before rendering:

class StatsWidget(PyWidget):
    data = traitlets.List(traitlets.Float(), []).tag(sync=True)
    _py_packages = ["numpy"]

    def render(self, el, model):
        import numpy as np
        arr = np.array(list(model.get("data")))
        el.innerHTML = f"Mean: {arr.mean():.2f}, Std: {arr.std():.2f}"

Any pure-Python wheel or package bundled with Pyodide works here (numpy, pandas, scipy, and ~200 others).

String-based alternative

If you prefer, you can set _py_render directly instead of defining methods:

class HelloWidget(PyWidget):
    _py_render = """
def render(el, model):
    el.innerHTML = "<h2>Hello from Pyodide!</h2>"
"""

When _py_render is set explicitly, method extraction is skipped.

Performance

  • First render: 3–5 s — Pyodide (~11 MB WASM) downloads once per page. Subsequent page loads use the browser cache (1–2 s).
  • Subsequent widgets: instant — all instances share a single Pyodide runtime.
  • Interaction latency: near-zero — event handlers run locally in the browser; kernel sync happens asynchronously.

How pywidget relates to the ecosystem

ipywidgets anywidget pywidget
Custom rendering No (fixed set of widgets) Yes, in JavaScript Yes, in Python
Interaction latency Kernel round-trip Local in browser Local in browser
Browser runtime None None Pyodide (~11 MB, cached)
Jupyter support Yes Yes Yes
marimo support No Yes Yes

pywidget is a thin layer on top of anywidget. PyWidget subclasses anywidget.AnyWidget and sets _esm to a JS bridge (~170 lines) that loads Pyodide, runs your Python rendering code in an isolated namespace, and proxies the anywidget model API. No modifications to anywidget are needed.

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pywidget-0.1.0.tar.gz (16.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pywidget-0.1.0-py3-none-any.whl (10.0 kB view details)

Uploaded Python 3

File details

Details for the file pywidget-0.1.0.tar.gz.

File metadata

  • Download URL: pywidget-0.1.0.tar.gz
  • Upload date:
  • Size: 16.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.7

File hashes

Hashes for pywidget-0.1.0.tar.gz
Algorithm Hash digest
SHA256 af57adf071e373f65d5ae1f77ed3a6938756d97e9afcce7fd585a02c6930eae7
MD5 0eec22db177dac5948fd0b6f8472a73c
BLAKE2b-256 4641259b244a81272298c3f4346ec812706c8ee21489fca2e3de1865eee59f0e

See more details on using hashes here.

File details

Details for the file pywidget-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: pywidget-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 10.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.7

File hashes

Hashes for pywidget-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 4c445e1ff90036adc147f13057edd2c5e84e7e3d4fa0e86ab61a37e41b658c93
MD5 f90993a6f558d08eb1c8fce8b9f911c7
BLAKE2b-256 06574f64a97334d88b7813c89d56a203a6565be5280b8a5ede14d2688c751e2f

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page