Anywidget Transport¶
PyWry's event system uses a unified protocol — on(), emit(), update(), display() — that works identically across native windows, IFrame+WebSocket, and anywidget. This page explains how that protocol is implemented over the anywidget transport, so you can build reusable components or introduce new integrations that work seamlessly in all three environments.
For the IFrame+WebSocket transport, see IFrame + WebSocket Transport.
The Unified Protocol¶
Every PyWry widget — regardless of rendering path — implements BaseWidget:
class BaseWidget(Protocol):
def on(self, event_type: str, callback: Callable[[dict, str, str], Any]) -> BaseWidget: ...
def emit(self, event_type: str, data: dict[str, Any]) -> None: ...
def update(self, html: str) -> None: ...
def display(self) -> None: ...
A reusable component only calls these four methods. It never knows whether it's running in a native window, a notebook widget, or a browser tab. The transport handles everything else.
How Anywidget Implements the Protocol¶
In anywidget mode, PyWryWidget extends anywidget.AnyWidget and implements BaseWidget by mapping each method to traitlet synchronization:
| BaseWidget Method | Anywidget Implementation |
|---|---|
emit(type, data) |
Serialize {type, data, ts} to JSON → set _py_event traitlet → send_state() |
on(type, callback) |
Store callback in _handlers[type] dict → _handle_js_event observer dispatches |
update(html) |
Set content traitlet → JS model.on('change:content') re-renders |
display() |
Call IPython.display.display(self) |
Traitlets¶
Six traitlets carry all state between Python and JavaScript:
| Traitlet | Direction | Purpose |
|---|---|---|
content |
Python → JS | HTML markup to render |
theme |
Bidirectional | "dark" or "light" |
width |
Python → JS | CSS width |
height |
Python → JS | CSS height |
_js_event |
JS → Python | Serialized event from browser |
_py_event |
Python → JS | Serialized event from Python |
Event Wire Format¶
Both _js_event and _py_event carry JSON strings:
The ts field ensures every event is a unique traitlet value. Jupyter only syncs on change — identical consecutive events would be dropped without unique timestamps.
JS → Python Path¶
pywry.emit("form:submit", {name: "x"})
→ JSON.stringify({type: "form:submit", data: {name: "x"}, ts: Date.now()})
→ model.set("_js_event", json_string)
→ model.save_changes()
→ Jupyter kernel syncs traitlet
→ Python observer _handle_js_event fires
→ json.loads(change["new"])
→ callback(data, "form:submit", widget_label)
Python → JS Path¶
widget.emit("pywry:set-content", {"id": "status", "text": "Done"})
→ json.dumps({"type": ..., "data": ..., "ts": uuid.hex})
→ self._py_event = json_string
→ self.send_state("_py_event")
→ Jupyter kernel syncs traitlet
→ JS model.on("change:_py_event") fires
→ JSON.parse(model.get("_py_event"))
→ pywry._fire(type, data)
→ registered on() listeners execute
The ESM Render Function¶
The widget frontend is an ESM module with a render({model, el}) function. This function must:
- Create a
.pywry-widgetcontainer div insideel - Render
model.get("content")as innerHTML - Create a local
pywrybridge object withemit(),on(), and_fire() - Also set
window.pywryfor HTMLonclickhandlers to access - Listen for
change:_py_eventand dispatch topywry._fire() - Listen for
change:contentand re-render - Listen for
change:themeand update CSS classes
The pywry bridge in the ESM implements the JavaScript side of the protocol:
const pywry = {
_handlers: {},
emit: function(type, data) {
// Write to _js_event traitlet → triggers Python observer
model.set('_js_event', JSON.stringify({type, data: data || {}, ts: Date.now()}));
model.save_changes();
// Also dispatch locally so JS listeners fire immediately
this._fire(type, data || {});
},
on: function(type, callback) {
if (!this._handlers[type]) this._handlers[type] = [];
this._handlers[type].push(callback);
},
_fire: function(type, data) {
(this._handlers[type] || []).forEach(function(h) { h(data); });
}
};
Building a Reusable Component¶
A reusable component is a Python class that takes a BaseWidget and registers event handlers. Because it only calls on() and emit(), it works on all three rendering paths without modification.
Python Side: State Mixin Pattern¶
PyWry's built-in components (GridStateMixin, PlotlyStateMixin, ChatStateMixin, ToolbarStateMixin) all follow the same pattern — they inherit from EmittingWidget and call self.emit():
from pywry.state_mixins import EmittingWidget
class CounterMixin(EmittingWidget):
"""Adds a counter widget that syncs between Python and JavaScript."""
def increment(self, amount: int = 1):
self.emit("counter:increment", {"amount": amount})
def reset(self):
self.emit("counter:reset", {})
def set_value(self, value: int):
self.emit("counter:set", {"value": value})
Any widget class that mixes this in and provides emit() gets counter functionality:
class MyWidget(PyWryWidget, CounterMixin):
pass
widget = MyWidget(content=counter_html)
widget.increment(5) # Works in notebooks (anywidget traitlets)
widget.reset() # Works in browser (WebSocket)
# Works in native windows (Tauri IPC)
JavaScript Side: Event Handlers¶
The JavaScript side registers listeners through pywry.on() — this works identically in all rendering paths because every transport creates the same pywry bridge object:
// This code works in ESM (anywidget), ws-bridge.js (IFrame), and bridge.js (native)
pywry.on('counter:increment', function(data) {
var el = document.getElementById('counter-value');
var current = parseInt(el.textContent) || 0;
el.textContent = current + data.amount;
});
pywry.on('counter:reset', function() {
document.getElementById('counter-value').textContent = '0';
});
pywry.on('counter:set', function(data) {
document.getElementById('counter-value').textContent = data.value;
});
// User clicks emit events back to Python — same pywry.emit() everywhere
document.getElementById('inc-btn').onclick = function() {
pywry.emit('counter:clicked', {action: 'increment'});
};
Wiring It Together¶
To use the component with ChatManager, app.show(), or any other entry point:
from pywry import HtmlContent, PyWry
app = PyWry()
counter_html = """
<div style="text-align:center; padding:20px">
<h1 id="counter-value">0</h1>
<button onclick="pywry.emit('counter:clicked', {action:'increment'})">+1</button>
<button onclick="pywry.emit('counter:clicked', {action:'reset'})">Reset</button>
</div>
<script>
pywry.on('counter:increment', function(d) {
var el = document.getElementById('counter-value');
el.textContent = parseInt(el.textContent || 0) + d.amount;
});
pywry.on('counter:reset', function() {
document.getElementById('counter-value').textContent = '0';
});
</script>
"""
def on_counter_click(data, event_type, label):
if data["action"] == "increment":
app.emit("counter:increment", {"amount": 1}, label)
elif data["action"] == "reset":
app.emit("counter:reset", {}, label)
widget = app.show(
HtmlContent(html=counter_html),
callbacks={"counter:clicked": on_counter_click},
)
This works in native windows, notebooks with anywidget, notebooks with IFrame fallback, and browser mode — the same HTML, the same callbacks, the same pywry.emit()/pywry.on() contract.
Specialized Widget Subclasses¶
When a component needs its own bundled JavaScript library (like Plotly, AG Grid, or TradingView), it defines a widget subclass with a custom _esm:
| Subclass | Mixin | Bundled Library | Extra Traitlets |
|---|---|---|---|
PyWryWidget |
EmittingWidget |
Base bridge only | — |
PyWryPlotlyWidget |
PlotlyStateMixin |
Plotly.js | figure_json, chart_id |
PyWryAgGridWidget |
GridStateMixin |
AG Grid | grid_config, grid_id, aggrid_theme |
PyWryChatWidget |
ChatStateMixin |
Chat handlers | _asset_js, _asset_css |
PyWryTVChartWidget |
TVChartStateMixin |
Lightweight-charts | chart_config, chart_id |
Each subclass overrides _esm with an ESM module that includes both the library code and the domain-specific event handlers. The extra traitlets carry domain state (chart data, grid config, etc.) alongside the standard content/theme/_js_event/_py_event protocol.
Lazy Asset Loading¶
PyWryChatWidget uses two additional traitlets — _asset_js and _asset_css — for on-demand library loading. When ChatManager first encounters a PlotlyArtifact, it pushes the Plotly library source through _asset_js:
# ChatManager detects anywidget and uses trait instead of HTTP
self._widget.set_trait("_asset_js", plotly_source_code)
The ESM listens for the trait change and injects the code:
model.on("change:_asset_js", function() {
var js = model.get("_asset_js");
if (js) {
var script = document.createElement("script");
script.textContent = js;
document.head.appendChild(script);
}
});
This replaces the chat:load-assets HTTP-based injection used in the IFrame transport, keeping the protocol uniform while adapting to the transport's capabilities.
Transport Comparison¶
| Aspect | Anywidget | IFrame+WebSocket | Native Window |
|---|---|---|---|
pywry.emit() |
Traitlet _js_event |
WebSocket send | Tauri IPC pyInvoke |
pywry.on() |
Local handler dict | Local handler dict | Local handler dict |
Python emit() |
Traitlet _py_event |
Async queue → WS send | Tauri event emit |
Python on() |
Traitlet observer | Callback dict lookup | Callback dict lookup |
| Asset loading | Bundled in _esm or _asset_js trait |
HTTP <script> injection |
Bundled in page HTML |
| Server required | No | Yes (FastAPI) | No (subprocess IPC) |
| Multiple widgets | Each is an independent anywidget | Shared server, per-widget WS | Each is a window |
The Python-facing API (on, emit, update, display) and the JavaScript-facing API (pywry.emit, pywry.on, pywry._fire) are identical in every column. A component built against these interfaces works everywhere.