Shiny for Python でタブの切替え
February 8, 2023
computer
shiny
python
目的
Shiny for Python で複数アプリをタブで切り替えられるようにするとき,次のような機能があると便利。
- URI のクエリパラメータに応じて開くタブを変更する。
- UI を通してタブを移動するたびにアドレスバーが更新されるようにする。
ゴールは動画のような感じ。
警告
この投稿の作成時点(2023/02/08)で Shiny for Python はα版です。ここで紹介したコードには,現時点では公式ドキュメントに記載されていない機能を使っているので,今後動かなくなる可能性があります。
環境
- Python 3.8.15
- shiny 0.2.9
ファイルの準備
次のような構成にする。
.
├── app.py
└── www/
└── js/
└── msg.js
考え方
1. クエリパラメータを読み込んでアプリに反映させる
UI
からHTTPリクエストの情報を取得するためには,UI を次のような関数として定義する。(参考1, 参考2)
def app_ui(request):
request.query_params.get(...)
_ui = ...
return _ui
request.query_params
が辞書ライクなオブジェクトとしてクエリパラメータを保有している。
2. URI を更新する
執筆時点では URI を更新させるためのインターフェースがなさそうだったので,JavaScript の関数を呼び出す session.send_custom_message
を使う。
Sends messages to the client which can be handled in JavaScript with
Shiny.addCustomMessageHandler(type, function(message){...})
. Once the message handler is added, it will be invoked each time
send_custom_message()
is called on the server. (ソース)
- JavaScript 側では,
Shiny.addCustomMessageHandler('replaceState', fn)
のようなメッセージハンドラとコールバック関数fn
を用意する。fn
は無名関数を引数リストの中にそのまま書いてもよい。 - Python 側で
session.send_custom_message("replaceState", msg)
が実行されると,JavaScript 側でfn(msg)
が実行される。
具体的には,GitHub 掲載の使用例 を参考にする。async def
, await
の使い方が重要。
JavaScript との連携は R でも使われている方法と同じなので,Communicating with Shiny via JavaScript も参考にするとよい。
コード
JavaScript コード
コールバック関数は History.replaceState
を使って URI を書き換えるようなものにする。
// www/js/msg.js
Shiny.addCustomMessageHandler('replaceState', function(message) {
const theUrl = new URL(window.location);
theUrl.search = new URLSearchParams(message);
window.history.replaceState({}, '', theUrl);
});
Python コード
まずは前半部分。
Panel A と Panel B をそれぞれモジュールとして定義している。実際のアプリではこの部分が長くなるはずなので,ファイルを分けて管理するとよい。
# app.py
import pathlib
from shiny import App, module, ui, render, reactive
## Page A
@module.ui
def a_ui():
return ui.TagList(
ui.input_numeric("num", "Number for Panel A", value=0),
ui.output_text_verbatim("x")
)
@module.server
def a_server(input, output, session):
@output
@render.text
def x():
n = input.num()
return f"{n} times 2 = { n * 2 }"
## Page B
@module.ui
def b_ui():
return ui.TagList(
ui.input_numeric("num", "Number for Panel B", value=0),
ui.output_text_verbatim("x")
)
@module.server
def b_server(input, output, session):
@output
@render.text
def x():
n = input.num()
return f"{n} times 3 = {n * 3}"
Module A と Module B で同じ名前の input (num
) と同じ名前の output (x
) がある。モジュールの呼び出し側で設定する名前空間が名前の衝突を防いでくれる。
続いて後半部分,UI側から。
- (1) が JavaScript を読み込むコードを HTMLヘッダに埋め込む。
- (2a), (2b) の
value="a"
などによってタブ毎に ID をつけている。- (3) で設定した navset の
id
パラメータを使って,サーバー側でinput.page()
とすると,開かれているタブの ID を取得できる。 - (4) の
selected
パラメータを “b” に設定すると,ID が “b” であるタブが開かれる。
- (3) で設定した navset の
- (4) の
selected=request.query_params.get("page", "a")
は,「クエリパラメータにpage
が含まれていれば,selected
をその値で設定する,さもなくば “a” に設定する,という意味である」。例えば,http://localhost:4321/?page=b のようなアドレスでアプリにアクセスすると,Page B が開く。
## app.py 続き
##
## Put them together...
def app_ui(request):
_ui = ui.page_fixed(
ui.head_content(
ui.tags.script(src="js/msg.js") # (1)
),
ui.panel_title("Main Title"),
ui.navset_tab_card(
ui.nav("Page A", a_ui("pageA"), value="a"), # (2a)
ui.nav("Page B", b_ui("pageB"), value="b"), # (2b)
id="page", # (3)
selected=request.query_params.get("page", "a") # (4)
)
)
return _ui
最後に,サーバー側の設定。
- (5) は 「
input.page
が変更されたら以下を実行せよ」という意味。input.page
は開いているタブを表しているので,「タブが移動したら以下を実行せよ」という意味になる。 - (6) は,クエリパラメータを作るための変数を辞書(JSONに変換される)として定義している。これがメッセージハンドラに渡される。
- (7) が JavaScript のコードを呼び出す。
- (8) は JavaScript ファイルの場所を設定して,これを
shiny.App()
に渡す。
## app.py 続き
##
def app_server(input, output, session):
a_server("pageA")
b_server("pageB")
@reactive.Effect
@reactive.event(input.page) # (5)
async def _():
msg = { "page": input.page() } # (6)
await session.send_custom_message("replaceState", msg) # (7)
www_dir = pathlib.Path(__file__).parent / "www" # (8)
app = App(app_ui, app_server, static_assets=www_dir)
おわりに
将来的には Bookmarking in Mastering Shiny のような機能が Shiny for Python にも提供されるかもしれない。さしあたり,JavaScript のコードを自前で用意して解決できた。