Streamlit 1.41.0の新機能紹介

## Streamlitとは

StreamlitはPython向けのWebアプリケーションフレームワークです。  

https://streamlit.io/

Streamlitを使うとフロントエンドの知識ゼロでもデータを可視化するためのWebアプリケーションを簡単に作れます。以下のような機能が特徴。  
- 変数を地の文に書くだけで、GUIに出力される(**マジックコマンド**)
- `st.radio`や`st.text_input`のようなWidget APIを呼び出すだけで、GUIコンポーネントが生成される

## Streamlitの最新バージョン

Streamlitの最新バージョンは[Changelog](https://docs.streamlit.io/library/changelog)で確認できます。  
2024/12/24時点で最新バージョンは1.41.1です。  

https://docs.streamlit.io/library/changelog

以下に記載した変更点は、1.41.0でアナウンスされたものの抜粋です。  

- ```st.metric```および```st.columns```にボーダーを描画
- Markdownのテキストの色としてStreamlitの```theme.primaryColor```を参照
- DataFrameの列の固定
- ターシャリ(tertiary)ボタン
- パス指定に```pathlib.Path```を使用
- ```st.date_input```および```st.time_input```の初期値をISO形式の文字列で指定
- ```st.write_stream```にAsync Generatorを渡せる
- エラーメッセージの抑止設定
- ```st.line_chart```にツールチップを表示
- ```st.html```が```_repr_html_()```を呼び出せるか試行するように変更
- Python 3.13のサポート

見た目や使い勝手が着実に改善されています。  

## ```st.metric```および```st.columns```にボーダーを描画

```st.metric```および```st.columns```でボーダーを描画するオプションが追加されました。  
それぞれデザイン的に使いやすいシーンがありそうですね。  

```py:borders.py
import streamlit as st

st.subheader("st.columes()でボーダーを追加する")

left, middle, right = st.columns(3, border=True)

left.markdown("この枠の外側にボーダーが描画されます。" * 5)
middle.markdown("この枠の外側にボーダーが描画されます。" * 5)

with right:
    st.write("複数のコンポーネントがあっても大丈夫です。")
    st.write("2つめのコンポーネント")
    st.write("3つめのコンポーネント")
    import pandas as pd
    import numpy as np
    chart_data = pd.DataFrame(np.random.randn(20, 3), columns=["a", "b", "c"])
    st.line_chart(chart_data)

st.divider()

st.subheader("st.metric()でボーダーを追加する")

# 従来のst.metric()
st.metric("従来のst.metric", 100)
# ボーダーを追加する
st.metric("新しいオプションを使ったst.metric", 100, border=True)
```

![border](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/3d735a22-a79a-cb26-b089-2fc3ef691856.png)

## Markdownのテキストの色としてStreamlitの```theme.primaryColor```を参照

Streamlitでは以下のサンプルのようにMarkdownでテキストの色が指定できるのですが、こちらにテーマで指定した値を参照できるようになりました。  

```py:new_markdown_color.py
import streamlit as st

st.markdown("## Markdownにおける色指定")

markdown_text = '''
    :red[Streamlit] :orange[では] :green[Markdown] :blue[で] :violet[色指定]
    :gray[が] :rainbow[できるということを知っていましたか？]  
    :blue-background[背景色も変更できます。]  
    そして…  
    :primary[Streamlitで指定したprimaryカラーも使えるようになりました。]  
'''
st.markdown(markdown_text)

st.write('実際のMarkdown：')
st.code(markdown_text)
```

こんな感じで表示されます。  

![theming](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/d1c50133-172c-0fd1-bcf6-bcb3822ac5a1.png)

ちなみに```theme.primaryColor```というのはStreamlitの右上のハンバーガーメニューから設定できるテーマの設定値の一つです。  

![theme.primaryColor](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/2f2f975b-aa00-387a-7f60-94e869d17141.png)

このテーマ、[もっと色々な設定](https://advanced-theming.streamlit.app/)ができるように検討が進められているみたいですよ。

## DataFrameの列の固定

```st.dataframe```および```st.data_editor```で列を固定できるようになりました。  
横に長いDataFrameを表示するときに威力を発揮します！  
DataFrameの内容があらかじめ分かっているときが特に使いやすいですが、内容が未知であってもユーザに固定する列を選択させることも考えられますね。  
ちなみに、飛び飛びの列を固定するとそれらの列は左側に寄せて表示されます。  

```py:column_pinning.py
import streamlit as st
import pandas as pd
import numpy as np

# 横に長いDataFrameを作成する。カラム名はaからzまでとする
columns_src = "abcdefghijklmnopqrstuvwxyz"
if "df" not in st.session_state:
    st.session_state.df = pd.DataFrame(
        np.random.randn(12, len(columns_src)), columns=list(columns_src)
    )

st.dataframe(
    st.session_state.df,
    # カラムaとカラムcを固定する
    column_config = {
        "a": st.column_config.NumberColumn(pinned=True),
        "c": st.column_config.NumberColumn(pinned=True)
    }
)

st.data_editor(
    st.session_state.df,
    # カラムaのみを固定する
    column_config = {
        "a": st.column_config.NumberColumn(pinned=True),
    }
)
```

![columns pinning](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/304d3426-6ccd-a3d0-b718-23d2e312752f.png)

## ターシャリ(tertiary)ボタン

```st.button```、```st.download_button```、```st.link_button```および```st.form_submit_button```の見た目をターシャリ(tertiary[^1])ボタンにする```tertiary```オプションが追加されました。  
非常に目立つ見た目の```primary```ボタン、普通な見た目の```secondary```ボタンに対してまったく目立たない見た目なのがターシャリボタンです。実際に試してみると、ボーダーのないボタンが表示されます。  
普段は使わないんだけどいざというときにすぐアクセスしたいんだよね、という機能を割り当てるのに適しています。例えば編集や印刷などに切り替えるボタンとして使えるかも。  

[^1]: 「三次」「3位の」みたいな意味

```py:tertiary_buttons.py
import streamlit as st

# 以下のボタンのそれぞれについて、typeオプションなしと、"primary"、"secondary"、"tertiary"を指定するサンプルコード
# st.button
# st.download_button
# st.link_button

c1, c2, c3 = st.columns([1, 1, 1], border=True)

with c1:
    st.write("st.button")
    st.button("オプションなし", use_container_width=True)
    st.button("primary", type="primary", use_container_width=True)
    st.button("secondary", type="secondary", use_container_width=True)
    st.button("teriary", type="tertiary", use_container_width=True)

with c2:
    st.write("st.download_button")
    st.download_button("オプションなし", "download", use_container_width=True)
    st.download_button("primary", "download", type="primary", use_container_width=True)
    st.download_button("secondary", "download", type="secondary", use_container_width=True)
    st.download_button("teriary", "download", type="tertiary", use_container_width=True)

with c3:
    st.write("st.link_button")
    st.link_button("オプションなし", "https://streamlit.io", use_container_width=True)
    st.link_button("primary", "https://streamlit.io", type="primary", use_container_width=True)
    st.link_button("secondary", "https://streamlit.io", type="secondary", use_container_width=True)
    st.link_button("teriary", "https://streamlit.io", type="tertiary", use_container_width=True)

# サンプルコードには含まないが、st.form_submit_buttonについても同様にtypeオプションが使える
```

![st_tertiary_buttons.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/7abe5c61-d10c-039d-b6bf-81263e43467f.png)

## パス指定に```pathlib.Path```を使用

ファイル名を指定するコンポーネントに```pathlib.Path```をそのまま渡せるようになりました。今までは文字列にキャストする必要があったんですね。  
数ファイルで完結する小規模なアプリだとあまり恩恵はないですが、ディレクトリ構造が複雑になってくると嬉しいですね。  

```py:pathlib_test.py
import streamlit as st
from pathlib import Path

# Pathlibでファイルパスを操作する
# カレントディレクトリのオブジェクトを作成して、そこから"Pathlib_test.png"というファイルを指定
# 試すためにはカレントディレクトリに"Pathlib_test.png"という画像ファイルを置いておく必要がある
path = Path.cwd() / "Pathlib_test.png"

# ファイルが存在するか確認
if path.exists():
    st.write(f"{path} exists")

# ファイルを表示
st.image(path)
```

![pathlib](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/da772a08-54a8-0994-0352-6410a515788d.png)

## ```st.date_input```および```st.time_input```の初期値をISO形式の文字列で指定

日付や時間を指定するとき、これまでは```datetime```型を指定していたのですが、ISO形式の文字列で指定できるようになりました。  
また、Pandasから日付や時間を渡すときもキャストが不要になったので地味に便利に。  

```py:iso_time_initialization.py
import streamlit as st
import datetime
import pandas as pd

# これまでの使い方
result = st.date_input("日付指定", datetime.date(2024, 12, 23))
st.write(result)

# 新しい使い方
result = st.date_input("日付指定", "2024-12-23")
st.write(result)

# PandasのTimestampを入力する事例
date = pd.Timestamp("2024-12-23 12:34:56")
st.write(date)
result = st.time_input("時間指定", date)
st.write(result)
```

![iso format](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/8efbd8f4-8b6d-879c-d607-516ec7452866.png)

## ```st.write_stream```にAsync Generatorを渡せる

意欲的な機能ですね。  
LLMが回答をちょっとづつ出力しているとき、例えばAsync Generatorとして受け取ることになりますが、これをそのまま```st.write_stream```に渡すことができるようになりました。  
```st.write_stream```に渡されたAsync Generatorは、内部で通常のGeneratorに変換されるので、並列処理的な意味では嬉しさはないのですが、インタフェースを変換する必要がなくなりましたね。  

```py:async_generator.py
import streamlit as st
import asyncio
import time

# Async Generatorのサンプル。total_len文字のランダムな文字列を生成する
async def async_generator(total_len: int):
    import random
    src_str = "abcdefghijklmnopqrstuvwxyz"
    ret_str = ""
    for i in range(total_len):
        if len(ret_str) == 0:
            # 0.2秒待つ
            await asyncio.sleep(0.2)
        # ランダムな文字を生成してret_strに追加
        ret_str += random.choice(src_str)
        # ret_strが5文字になったらyieldする
        if len(ret_str) == 5:
            yield ret_str
            ret_str = ""
    # 最後にret_strが残っていたらyieldする
    if len(ret_str) > 0:
        yield ret_str

# これまではこういう感じのGeneratorに対応していた
def sync_generator(total_len: int):
    import random
    src_str = "abcdefghijklmnopqrstuvwxyz"
    ret_str = ""
    for i in range(total_len):
        if len(ret_str) == 0:
            # 0.2秒待つ
            time.sleep(0.2)
        # ランダムな文字を生成してret_strに追加
        ret_str += random.choice(src_str)
        # ret_strが5文字になったらyieldする
        if len(ret_str) == 5:
            yield ret_str
            ret_str = ""
    # 最後にret_strが残っていたらyieldする
    if len(ret_str) > 0:
        yield ret_str

# 以下のいずれの表示にも対応
st.write("Async Generator:")
st.write_stream(async_generator(100))
st.write("Generator:")
st.write_stream(sync_generator(100))

# 以下は実装についての詳細解説のためのコード
st.divider()
st.subheader("Async Generatorのサンプル")

# 内部の実装としてはこうなっている
from typing import Any, AsyncGenerator, Generator
def async_generator_to_sync(async_gen: AsyncGenerator[Any, Any]) -> Generator[Any, Any, Any]:
    # イベントループを作成する。他にイベントループがないといいなー
    loop = asyncio.new_event_loop()

    try:
        # イベントが発生するたびにyeildする
        while True:
            try:
                yield loop.run_until_complete(async_gen.__anext__())
            except StopAsyncIteration:
                # Async Generatorが終了するとこの例外が発生する
                break
    finally:
        loop.close()

# 上記のasync_generator_to_syncを使って、Async GeneratorをGeneratorに変換して表示する
generator = async_generator_to_sync(async_generator(100))
for text in generator:
    st.write(text)
```

![Async Generator](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/8fcc9c10-e132-eaf4-0385-4211d7c89c2e.png)

## エラーメッセージの抑止設定

エラーメッセージが以下の4種類から選べるようになりました。  

- ```full```：```Exception```も含めてすべて表示します。  
- ```stacktrace```：```Exception```の詳細を伏せます。ただ、スタックトレース(Traceback)は表示されるので、意味は薄いかも。コンソールにはすべてログが出力されています。 
- ```type```：```Exception```名のみ表示されます。これだと内部構造や変数名が分かりにくくなりますね。  
- ```none```：エラーが起こったことのみ表示されます。  

内部構造や変数名、ファイルパスなどが悪意のある攻撃者にバレるとリスクがあるので、アプリをリリースするときはエラーメッセージを抑止するのがいいでしょう。  

```py:error_message_test.py
import streamlit as st

# helloというオブジェクトはないので必ずエラーが発生する
st.write(hello)
```

具体的な表示は順に以下のようになります。  

![error1](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/4febb77f-8033-b855-679a-e2ad171735ef.png)

![error2](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/436d188a-c9d7-0bae-44ea-aa305db0d7ed.png)

![error3](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/0a672d03-b432-60a1-1200-91226e3fe761.png)

![error4](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/5c663fc8-3853-ef29-ba28-97bd529cd008.png)

設定は```.streamlit/config.toml```に記載しましょう。  

```toml:.streamlit/config.toml
[client]
showErrorDetails = "stacktrace"
```

## ```st.line_chart```にツールチップを表示

```st.line_chart```にマウスカーソルの位置にツールチップが表示されるようになったぞ！  
ちょっとリッチなグラフモジュールだと元々対応していたのですが、一番お手軽な```st.line_chart```でも対応してくれたのは嬉しいですね。  

```py:new_line_chart_hover.py
import streamlit as st
import pandas as pd
import numpy as np

chart_data = pd.DataFrame(np.random.randn(20, 3), columns=["a", "b", "c"])

st.line_chart(chart_data)
```

![mouse hover](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/0bbfe330-8381-a25a-0647-3238da3cf255.png)

## ```st.html```が```_repr_html_()```を呼び出せるか試行するように変更

```_repr_html_()```はそのオブジェクトをHTML形式でレンダリングする関数で、特にJupyter Notebookの世界でよく使われています。  
この関数を持っているオブジェクトを```st.html```に渡すと```_repr_html_()```が返したHTMLを描画してくれるようになりました。  

```py:repr_html.py
import streamlit as st
from htbuilder import div, ul, li
import seaborn as sns

# htbuilderを使ってHTMLを生成
dom = (
  div(id='container')(
    ul(_class='greetings')(
      li('こんにちは'),
      li('やあ！'),
      li('どしたん？'),
    )
  )
)
# 生成したHTMLを表示
st.html(dom)

st.divider()

# Seabornで試してみる
tips = sns.load_dataset('tips')
st.html(tips)
```

![repr_html](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/175eb5c6-26e8-d89f-b8c6-9b8428267646.png)

Jupyter Notebookでは以下のような動作をします。  

![Juyter Notebook with Seaborn](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/50def0ba-413c-b5e5-bc6a-5ea1e84c518d.png)

なお、手元で試した範囲では、なんでもいい感じに表示してくれるという訳ではないようです。  
```_repr_html_()```の出力に含まれているJavaScriptでグラフを描画する、みたいなものは上手く表示できませんでした。  

## Python 3.13のサポート

正式にPython 3.13をサポートするようになりました。  
一方、Python 3.8がサポート対象外になっています。  

サポート対象外というだけでしばらく前からPython 3.13でも動いていたのですが…試してみましょう。  

```py:new_python.py
import streamlit as st
import base64

# バイト列を文字列に変換する関数
def bytes2str(b):
    return b.decode("utf-8")

# 適当なバイト列を生成する
source_bytes = base64.b64encode("b64encodeでバイト列を生成する".encode("utf-8"))

# このコードはPython3.12以前でも動作する
result = base64.b32encode(source_bytes)
result_str = bytes2str(result)
st.write(result_str)

# このコードはPython3.12以前だとエラーになる。関数が実装されたのがPython3.13だから
result = base64.z85encode(source_bytes)
result_str = bytes2str(result)
st.write(result_str)
```

![Python 3.13](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/a5ff5498-6965-2e9e-1ec5-feb64478cf70.png)

## Streamlitを試すためには？

StreamlitはPythonライブラリなので、```pip```で簡単に導入できます。  

```sh
pip install streamlit
streamlit run (ソースコード名).py
```

過去にインストールしたバージョンからアップデートする場合は```pip install -U streamlit```を実行してください。  

また、Python環境の構築にハードルを感じる場合は、[Streamlit Playground](https://streamlit.io/playground?example=hello)で試してみるのがオススメです。こちらではブラウザ上でソースコードを入力して試すことができます。  

https://streamlit.io/playground?example=hello

2024/12/24時点ではStreamlit Playgroundの内部で使っているプログラム [stlite](https://github.com/whitphx/stlite) が1.41.0に対応していないため、今回紹介した機能は使えません。対応を心待ちにしましょう！