marimo-pys
Run Python in the browser with PyScript and embed the result in a marimo notebook. Use a simple HTML iframe for one-way rendering, or opt into an AnyWidget for two-way messages between marimo and PyScript.
Two display modes:
widget=False(default): returnsmarimo.Htmlcontaining a PyScript iframe.widget=True: returns aPysWidgetwith synchronizeddata(marimo → PyScript) andreceived(PyScript → marimo) dictionaries.
Each iframe runs its own browser-side Python environment. The Python code running in marimo and the Python code running in PyScript do not share imports, globals, or memory.
Install
pip install marimo marimo-pys anywidget traitlets
Or with uv:
uv add marimo marimo-pys anywidget traitlets
The current package imports marimo, anywidget, and traitlets, so make sure all three are installed. PyScript itself is loaded in the browser from pyscript.net; the initial page load requires access to those assets.
Quick start: render HTML
from marimo_pys import run_pyscript
async def hello(js, data):
heading = js.document.createElement("h1")
heading.textContent = f"Hello, {data['name']}!"
js.document.body.appendChild(heading)
run_pyscript(hello, data={"name": "marimo"})
run_pyscript() extracts the source of hello, loads it into PyScript, and calls it in the browser as await hello(js, data). Here, js provides access to JavaScript and browser APIs, while data is a JSON-decoded Python dictionary. The function's return value is not automatically displayed: create DOM elements or use another browser-side rendering API.
Callable inputs must be defined with async def and have source code available to inspect.getsource(). Put imports needed by the browser runtime inside the callable; its enclosing Python globals and closure are not transferred.
Two-way communication with widget=True
For a live widget, request widget=True and wrap the returned PysWidget with marimo's mo.ui.anywidget():
import marimo as mo
from marimo_pys import run_pyscript
async def show_count(js, data):
label = js.document.createElement("div")
label.textContent = str(data["count"])
js.document.body.appendChild(label)
pys_raw = run_pyscript(
show_count,
widget=True,
data={"count": 50},
)
pys = mo.ui.anywidget(pys_raw)
pys
The initial data={"count": 50} is passed as the callable's data argument. Later calls to pys_raw.set_data(...) update the widget's synchronized data trait and send an update message to the running iframe. They do not reassign the callable's original data argument or intentionally reload the iframe; your PyScript code must register a message listener if it needs to handle subsequent updates.
Message protocol
The widget uses browser postMessage with the following message shapes:
marimo → PyScript:
{
channel: "marimo-pys",
type: "update",
payload: { count: 75 }
}
PyScript → marimo:
{
channel: "marimo-pys",
type: "set",
payload: { count: 76 }
}
The payload sent to marimo must be a JSON-serializable object (a Python dictionary), not a bare number or array. Incoming set payloads replace the widget's received dictionary; they are not merged with previous payloads. data and received are separate channels, not one automatically synchronized shared object.
The widget also listens for a string readiness signal, "marimo-pys:ready". For callable inputs, the generated script emits it automatically just before calling the async function. Keep the initial value in the callable's data argument: the first update message may arrive before a listener created inside that function is registered. Once the listener is registered, later updates are received normally. When code is a raw source string, no readiness signal is added automatically; see Raw Python source.
Complete example: a marimo slider and PyScript buttons
The following example lets you move a marimo slider to update the number inside PyScript, or click PyScript's − and + buttons to update the marimo slider. Put the three Python snippets into three separate marimo cells, in order. Keeping widget construction out of cells that read get_count() avoids recreating the iframe on every state update.
Cell 1 — create state and the widget once:
import marimo as mo
from marimo_pys import run_pyscript
async def counter_ui(js, data):
import json
from pyscript import ffi
count = int(data.get("count", 50))
minus = js.document.createElement("button")
minus.textContent = "−"
label = js.document.createElement("span")
label.style.margin = "0 1rem"
plus = js.document.createElement("button")
plus.textContent = "+"
def display_count():
label.textContent = str(count)
def change_by(amount):
nonlocal count
count = max(0, min(100, count + amount))
display_count()
js.window.parent.postMessage(
ffi.to_js({
"channel": "marimo-pys",
"type": "set",
"payload": {"count": count},
}),
"*",
)
def receive_update(event):
nonlocal count
try:
message = json.loads(js.JSON.stringify(event.data))
except (TypeError, ValueError):
return
if not isinstance(message, dict):
return
if message.get("channel") != "marimo-pys" or message.get("type") != "update":
return
payload = message.get("payload")
if not isinstance(payload, dict):
return
incoming = payload.get("count")
if type(incoming) is int and 0 <= incoming <= 100:
count = incoming
display_count()
minus.addEventListener("click", ffi.create_proxy(lambda event: change_by(-1)))
plus.addEventListener("click", ffi.create_proxy(lambda event: change_by(1)))
js.window.addEventListener("message", ffi.create_proxy(receive_update))
display_count()
js.document.body.appendChild(minus)
js.document.body.appendChild(label)
js.document.body.appendChild(plus)
get_count, set_count = mo.state(50)
pys_raw = run_pyscript(
counter_ui,
widget=True,
data={"count": 50},
height="100px",
)
pys = mo.ui.anywidget(pys_raw)
pys
Cell 2 — show the slider and send state changes to the existing iframe:
count = get_count()
# Also handles state changes coming from outside the slider.
if pys_raw.data.get("count") != count:
pys_raw.set_data({"count": count})
def on_slider_change(value):
pys_raw.set_data({"count": value})
set_count(value)
slider = mo.ui.slider(
start=0,
stop=100,
step=1,
value=count,
on_change=on_slider_change,
label="Count",
)
slider
Cell 3 — reflect messages from the PyScript buttons in marimo state:
incoming = pys.received.get("count")
if type(incoming) is int and 0 <= incoming <= 100:
set_count(incoming)
Use pys.received in a reactive marimo cell to process incoming messages. The underlying PysWidget also exposes a traitlets received trait, but the marimo-facing reactive property is a straightforward integration path. This example deliberately separates initialization, marimo → PyScript, and PyScript → marimo to avoid a circular cell dependency or an unnecessary PyScript restart.
For a direct Python-side check without a slider, you can also call:
pys_raw.set_data({"count": 25})
Run that statement in a separate cell or a callback; don't put a top-level set_data() call in a cell that re-runs for unrelated reasons.
Other usage
Choose MicroPython or Pyodide
The default PyScript type is MicroPython ("mpy"). Use "py" for Pyodide or "py-game" for PyScript's game-oriented script type:
run_pyscript(hello, data={"name": "Pyodide"}, pys_type="py")
Python package availability differs between MicroPython and Pyodide. See PyScript's package configuration guide.
Customize the iframe and runtime
run_pyscript(
hello,
data={"name": "custom view"},
width="100%",
height="320px",
body_style="background: #20242a; color: white; padding: 12px;",
iframe_style="border: 1px solid #777; border-radius: 8px;",
terminal=True,
pys_version="2026.7.3",
)
Use config for PyScript settings, and add_script, add_module, or add_css to include additional resources inside the iframe:
run_pyscript(
hello,
data={"name": "NumPy"},
pys_type="py",
config={"packages": ["numpy"]},
add_css=["https://example.com/styles.css"],
)
Replace example URLs with real, trusted resources. Extra Python packages must be compatible with your selected browser-side interpreter.
Raw Python source
code may also be a string instead of an async callable. The source is executed as provided; marimo-pys does not automatically invoke a function or add a readiness message in this mode.
run_pyscript(
"""
import js
message = js.document.createElement("p")
message.textContent = "Hello from raw PyScript source!"
js.document.body.appendChild(message)
"""
)
If you use raw source with widget=True and want set_data() updates, register your message listener first, then signal readiness yourself:
# Inside the raw PyScript source, after registering the listener:
js.window.parent.postMessage("marimo-pys:ready", "*")
HTML inserted before execution
add_dangerous_html inserts the supplied HTML directly into the iframe body, without sanitization. Use only trusted content:
run_pyscript(
hello,
data={"name": "marimo"},
add_dangerous_html='<div id="app"></div>',
)
API reference
run_pyscript(...)
run_pyscript(
code,
width="100%",
height="200px",
body_style="",
iframe_style="",
iframe_sandbox="allow-scripts",
config=None,
data=None,
add_script=None,
add_module=None,
add_css=None,
add_dangerous_html="",
terminal=False,
pys_version="2026.7.3",
pys_type="mpy",
widget=False,
)
| Parameter | Meaning |
|---|---|
code |
Async callable or raw Python source string. |
width, height |
iframe dimensions; defaults: "100%", "200px". |
body_style |
Extra CSS declarations for the iframe document's <body>. |
iframe_style |
Extra CSS declarations for the iframe element. |
iframe_sandbox |
iframe sandbox tokens; default: "allow-scripts". |
config |
PyScript configuration dictionary. |
data |
JSON-serializable dictionary passed initially to the callable; also the widget's initial outgoing state. |
add_script |
URLs of extra classic JavaScript scripts. |
add_module |
URLs of extra JavaScript modules. |
add_css |
URLs of extra CSS stylesheets. |
add_dangerous_html |
Unsanitized HTML inserted into the iframe body. |
terminal |
Add the PyScript terminal attribute; default: False. |
pys_version |
PyScript release to load; default: "2026.7.3". |
pys_type |
"mpy" (default), "py", or "py-game". |
widget |
Return PysWidget when True; otherwise return marimo.Html (default). |
PysWidget
Returned by run_pyscript(..., widget=True).
| Member | Meaning |
|---|---|
set_data(mapping) |
Replace outgoing data with a dictionary; the iframe receives the latest value after its ready signal. |
data |
Synchronized outgoing dictionary. |
received |
Synchronized dictionary last received from a PyScript set message. |
srcdoc |
Generated HTML document loaded into the iframe. |
width, height, iframe_style, iframe_sandbox |
Synchronized iframe attributes read when its view is rendered. |
set_data() replaces the whole dictionary; it does not merge individual keys or await a reply. Multiple updates before readiness are represented by the latest data value. When changing srcdoc or the iframe's creation-time attributes, do not assume an existing view will automatically rebuild: the view reads them on initial render.
How it works
- For async callables, extract and dedent the function source, then append an invocation with the JSON-decoded initial
data. - Encode the Python source as Base64 and include it in a generated PyScript HTML document.
- Load the HTML through an iframe's
srcdocattribute. - In widget mode, use AnyWidget's synchronized traits and per-iframe
postMessagehandling to exchange dictionaries without deliberately reloading the iframe for ordinarydatachanges. - Check each incoming message's
event.sourceagainst the specific iframe window so multiple widgets do not consume one another's messages. The implementation accepts the parent origin or"null"for sandboxed iframe messages.
PyScript's URL resolution inside about:srcdoc needs a compatibility workaround: marimo-pys patches the iframe's URL constructor to use the selected PyScript release URL as a fallback for empty or about: base URLs. This behavior was written for PyScript 2026.7.3; recheck it when upgrading PyScript.
Limitations and security
- Browser-side isolation: The callable's imports, module globals, and closure are not copied to PyScript. The browser must be able to load PyScript and any extra resources.
- Source extraction:
inspect.getsource()must be able to find the callable's source. Decorated functions and dynamically created functions are not supported as documented callable inputs. - JSON messages only: Use JSON-serializable dictionaries for
data,set_data(), and inboundsetpayloads. The widget stores only the most recent inbound payload; it is not an event queue. - Iframe lifecycle: If marimo recreates the widget/view, its iframe and browser-side Python runtime start again. Keep the widget construction cell independent of rapidly changing state.
- Sandbox and browser storage: The default
iframe_sandbox="allow-scripts"gives the iframe an opaque origin and may block IndexedDB or other storage APIs. You might see a browser-consoleSecurityErroreven when your PyScript code runs. allow-same-originis a security trade-off:iframe_sandbox="allow-scripts allow-same-origin"may permit origin-dependent APIs, but withsrcdocit makes the iframe same-origin with its parent. Scripts may then access the parent page and potentially remove the sandbox. Use this combination only with code you fully trust; see MDN's iframe security warning.- Trusted content:
add_dangerous_htmlis not sanitized, and external scripts run inside the iframe. Do not use untrusted HTML or JavaScript. ThepostMessageprotocol is a data exchange mechanism, not a security boundary. - Readiness timing: For callable inputs, the automatically generated ready message is sent before the user function runs. Use its initial
dataargument for the first render, and install your listener to receive subsequentupdatemessages.
Links
Release files for marimo-pys 0.0.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| marimo_pys-0.0.2.tar.gz | 16.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| marimo_pys-0.0.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 27.6 kB
Release files / marimo_pys-0.0.2.tar.gz
| Download URL | marimo_pys-0.0.2.tar.gz |
|---|---|
| Size | 16.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8b3d1b50e560f17bbc9156f7c99ea59ef216cbad9f0b90a548b0eb261d801336
|
|
BLAKE2b-256 checksum How to use checksums |
8aa184a4363c2f09d78a2c451ac509932d219c7e50510bed104fb91f24cd3f94
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.4
|
Release files / marimo_pys-0.0.2-py3-none-any.whl
| Download URL | marimo_pys-0.0.2-py3-none-any.whl |
|---|---|
| Size | 11.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
fdea2dc98dc01ed6c716d17efb883bd2d69b47b2ed43006a28d04804212f32bd
|
|
BLAKE2b-256 checksum How to use checksums |
b79adfe50ad24cb253e157c5a3f34932dc98a56695bd49d90c5558d664574204
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.4
|