以前、marimoからPythonの関数をPyScriptとしてブラウザ上で実行するライブラリ「marimo-pys」を作りました。
最初のmarimo-pysは、marimoから初期データを渡し、PyScriptが生成したHTMLをiframe内に表示する仕組みでした。
今回、marimoとPyScriptの間で、値を双方向に同期する機能を追加しました。
例えば、marimoのスライダーを動かすとPyScript内の表示が変わり、PyScript内のボタンを押すとmarimoのスライダーにも変更が反映されます。
ただし、この機能の目的はあくまで最新の値の同期です。個々のイベントを必ず一件ずつ届けるイベントキューではありません。この違いも含めて、使い方を紹介します。
これまでとの違い
従来の使い方は、marimoからPyScriptへ初期値を渡すものでした。
marimo ── 初期値 ──→ PyScript
今回追加したウィジェットモードでは、実行中にも値をやり取りできます。
marimo ── 最新値 ──→ PyScript
←─ 最新値 ────
実装にはAnyWidgetを利用しています。
PyScript自体は従来どおりiframe内で動作するため、marimoとPyScriptがPythonの変数やメモリを直接共有するわけではありません。両者の間で、辞書形式のデータを受け渡します。
インストール
pip install marimo marimo-pys anywidget traitlets
uvを使う場合は次のとおりです。
uv add marimo marimo-pys anywidget traitlets
PyScript本体は、ブラウザ側で読み込まれます。
追加した機能を先に紹介
widget=True:値を同期できるウィジェットを作る
従来のrun_pyscript()は、PyScriptを実行するiframeを含むmo.Htmlを返します。
run_pyscript(my_function)
今回追加したwidget=Trueを指定すると、PysWidgetを返すようになります。
pys_raw = run_pyscript(
my_function,
data={"count": 50},
widget=True,
)
marimoのリアクティブなUIとして扱うには、mo.ui.anywidget()でラップします。
pys = mo.ui.anywidget(pys_raw)
pys
set_data():marimoからPyScriptへ値を送る
生成済みのウィジェットに新しい値を渡すには、次のようにします。
pys_raw.set_data({"count": 75})
set_data()は、送信する辞書全体を新しい値に置き換えます。既存の辞書の一部を自動でマージする機能ではありません。
また、set_data()を呼ぶたびにPyScriptを起動し直すわけではありません。 既存のiframeへ更新データを送ります。
received:PyScriptから送られた値を読む
逆方向に、PyScriptからmarimoへ送られた辞書はreceivedに反映されます。
pys.received
例えば、PyScriptが{"count": 76}を送った場合、marimo側では次のように取得できます。
pys.received.get("count")
receivedも最新の辞書を保持する仕組みであり、受信履歴が自動的に蓄積されるわけではありません。
実際にスライダーとボタンを連動させる
ここからは、次の動作を試してみます。
- marimoのスライダーを動かすと、PyScript内の数値が変わる。
- PyScript内の「+」「-」ボタンを押すと、数値が増減する。
- ボタン操作で変わった数値が、marimoのスライダーにも反映される。
marimoではセル間の依存関係によってコードが再実行されるため、今回はウィジェットの生成と、値の更新を別セルにします。
セル1:PyScriptの画面と共有stateを作る
まずは、PyScriptで実行する関数を定義します。
import marimo as mo
from marimo_pys import run_pyscript
async def counter_ui(js, data):
import json
from pyscript import ffi
# 初期値はdata引数から受け取る
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)
# PyScript側のボタン操作
def change_by(amount):
nonlocal count
count = max(0, min(100, count + amount))
display_count()
# 変更した値をmarimo側へ送る
js.window.parent.postMessage(
ffi.to_js({
"channel": "marimo-pys",
"type": "set",
"payload": {"count": count},
}),
"*",
)
# marimo側からの更新を受け取る
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)
# marimo側で共有する値
get_count, set_count = mo.state(50)
# PyScriptウィジェットを作成
pys_raw = run_pyscript(
counter_ui,
data={"count": 50},
height="100px",
widget=True,
)
pys = mo.ui.anywidget(pys_raw)
pys
このコードには、値の受け渡しが3種類あります。
まず、run_pyscript()のdata={"count": 50}は初期値です。PyScript側では、関数のdata引数から取得します。
次に、marimoからの更新はreceive_update()で受け取ります。
js.window.addEventListener(
"message",
ffi.create_proxy(receive_update),
)
そして、PyScript側のボタンを押したときは、postMessage()で現在の数値をmarimoへ送ります。
js.window.parent.postMessage(
ffi.to_js({
"channel": "marimo-pys",
"type": "set",
"payload": {"count": count},
}),
"*",
)
通信に使っているJSON互換のデータには、channel、type、payloadを含めています。
セル2:スライダーを作り、PyScriptへ値を送る
次のセルでは、marimoの共有stateからスライダーを作ります。
count = get_count()
# stateが別の場所から変更された場合もPyScriptへ反映
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
スライダーを動かすと、on_slider_change()が呼ばれます。
そこでset_data()を使ってPyScriptへ最新値を送り、同時にset_count()でmarimoの共有stateも更新します。
スライダーの.valueへ直接代入して値を変えるのではなく、stateを更新し、その値を使ってスライダーを構成する形です。
セル3:PyScriptから受け取った値をstateへ反映する
最後のセルは、かなり短く書けます。
incoming = pys.received.get("count")
if type(incoming) is int and 0 <= incoming <= 100:
set_count(incoming)
PyScript側のボタンから値が送られると、pys.receivedが更新されます。
その値をset_count()へ渡すと、共有stateを参照するセル2が再実行され、スライダーにも反映されます。
これで、スライダーとPyScriptのボタンを使って、同じ数値を双方向に変更できます。
なお、今回のコードは最新値を反映するための例です。スライダーの途中の値や、連続したクリックを一件ずつ必ず処理することを保証する例ではありません。
ウィジェットの生成セルを分ける理由
開発中、marimoのセルをまとめようとして少し引っかかった部分がありました。
例えば、ウィジェットの生成時に次のように書くとします。
pys_raw = run_pyscript(
counter_ui,
data={"count": get_count()},
widget=True,
)
このセルはget_count()に依存します。
そのため、共有stateの変更でセルが再実行されると、run_pyscript()も再び呼ばれ、ウィジェットやiframeが作り直される可能性があります。
PyScript内で実行中の処理や保持していた状態までリセットしたくない場合、これは避けたい動作です。
そこで、今回の例では次のように役割を分けています。
セル1:stateとPyScriptウィジェットを生成
│
├──→ セル2:state → スライダー・PyScript
│
└──← セル3:PyScript → state
更新のたびに再実行されるセルと、ウィジェットそのものを生成するセルを分けるのがポイントです。
初期値と更新値は別物
今回の仕組みでは、初期値と実行中の更新値を区別しています。
run_pyscript(
counter_ui,
data={"count": 50},
widget=True,
)
このdataは、関数が最初に呼び出されたときの引数です。
async def counter_ui(js, data):
count = data["count"]
一方、実行中の更新はset_data()から送られます。
pys_raw.set_data({"count": 75})
こちらはPyScript内のmessageイベントで受け取り、自分のアプリケーションの変数や画面へ反映する必要があります。
set_data()を呼んでも、すでに実行中の関数のdata引数が自動で書き換わるわけではありません。
ready通知と、終了しない関数について
この仕組みを考えるとき、もう一つ気になったのがready通知のタイミングです。
現行のmarimo-pysは、関数を呼び出す前に、ウィジェットへ次の通知を送ります。
js.window.parent.postMessage(
"marimo-pys:ready",
"*",
)
ただし、これはユーザーが書いた受信ハンドラの登録完了を意味するものではありません。
「それなら、ユーザー関数のawaitが終わってからreadyを送ればいいのでは?」とも考えられますが、ゲームなどでは関数が終了しないことがあります。
async def game(js, data):
while True:
update()
draw()
await asyncio.sleep(0)
このような関数は、awaitでイベントループに制御を返していても、関数自体は完了しません。
したがって、次の書き方ではreadyが送られない可能性があります。
await game(js, data)
# game()が終了しなければ実行されない
js.window.parent.postMessage(
"marimo-pys:ready",
"*",
)
そこで、初期値は関数のdata引数から取得することを基本にしています。
受信ハンドラの準備ができる前に送られた更新については、現在の仕組みでは取りこぼす可能性があります。
もし、ユーザー側の初期化完了後に最新値を送り直したいなら、同じpostMessage()を利用して、アプリケーション独自の初期化完了通知を送る方法があります。
PyScript側で受信ハンドラを登録した後に、例えば次のように送ります。
js.window.parent.postMessage(
ffi.to_js({
"channel": "marimo-pys",
"type": "set",
"payload": {"app_ready": True},
}),
"*",
)
marimo側でpys.received.get("app_ready")を確認し、必要ならその時点の最新値をset_data()で送り直せます。
これは別の通信経路を作るのではなく、既存の値の同期を利用したアプリケーション側の工夫です。
あくまで「値の同期」であって「イベント配送」ではない
今回、機能の説明を「双方向通信」ではなく、**「双方向の値の同期」**としたのには理由があります。
例えばスライダーが50→51→52と動いたとき、アプリケーションが必要としているのが「現在値は52」という情報なら、途中の51を必ず処理する必要はありません。
しかし、「攻撃ボタンが3回押されたので攻撃を3回実行する」という処理では話が違います。
set_data()とreceivedは、それぞれ最新の辞書を保持する仕組みです。個々の更新を一件ずつ蓄積するイベントキューではありません。同じ内容の値を繰り返し設定しても、毎回変更として検出されるとは限りません。
履歴を値として扱いたいなら、例えば連番付きのリストを辞書に含める方法があります。
history = [
{"id": 1, "kind": "click"},
{"id": 2, "kind": "click"},
{"id": 3, "kind": "click"},
]
pys_raw.set_data({"history": history})
受信側で未処理のIDだけを処理すれば、履歴を使った仕組みを作れます。ただし、リストそのものの配送保証や、履歴の保持・削除は別途考える必要があります。
厳密なイベントの配送順序、受信確認、再送などが必要なら、アプリケーション側で独自のプロトコルや通信経路を用意してください。
marimo-pysは、そこまでを自動的に管理するためのライブラリではありません。
まとめ
今回の更新で、marimo-pysはmarimoとPyScriptの間で最新の値を双方向に同期することに対応しました。
使い方の基本は次のとおりです。
| やりたいこと | 使うもの |
|---|---|
| PyScriptをiframeに表示する | run_pyscript() |
| 値の同期を有効にする | widget=True |
| 初期値を渡す | data= |
| marimoから最新値を送る | pys_raw.set_data() |
| PyScriptから届いた最新値を読む | pys.received |
今回のスライダーの例のように、marimo側のUIとPyScript側のUIを連動させたい場合には、かなり使いやすくなったと思います。
一方で、値の同期とイベント配送は異なるものです。marimo-pysは前者を手軽に行うための仕組みを提供し、それ以上の制御が必要な場合は、利用者が用途に合わせて工夫できる形にしています。
関連リンク