Streamlit 1.38.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/8/29時点で最新バージョンは1.38.0です。  

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

以下に記載した変更点は、1.38.0でHighlights、Notable ChangesおよびOther Changesとしてアナウンスされたものの抜粋です。  

- Pandas以外のDataFrame形式に対応
- ```st.json```の展開状態の設定
- ```st.code```における行折り返し表示
- Kubernetesで用いられる形式のシークレットに対応
- pydantic 1.x.x系への対応の一部打ち切り
- ```experimental_allow_widgets```の削除
- WebSocketの再接続でRerunされなくなった

今回の目玉アップデートはやはり最初のPandas以外のDataFrame形式に対応したことですね。

## Pandas以外のDataFrame形式に対応

StreamlitはこれまではPandas DataFrameを主に扱ってきたのですが、これがなんと色々なDataFrameに対応しました。  
Dask、Modin、Numpy、Polars、PyArrow、Snowpark、Xarray…などなどに対応したそうです。  
PythonデータベースAPI(PEP 249)もしくはPython dataframe interchange protocolに対応していればOKだそうです。  
例えばPolarsはPandasよりも高速に動作するシーンが多いので嬉しいですね！  
DataFrameをテーブル形式で表示する```st.dataframe```だけではなく、グラフで表示する```st.bar_chart```や```st.line_chart```など、データを編集する```st.data_editor```も対応しました。  

![Dataframes](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/74095e19-5f83-3f73-eac8-908056ffe80f.png)

```py:dataframes.py
import streamlit as st
import pandas as pd
import polars as pl
import ibis
import duckdb

# PandasでCSVを読み込む関数
def read_csv_pandas(file_path):
    return pd.read_csv(file_path)

# PolarsでCSVを読み込む関数
def read_csv_polars(file_path):
    return pl.read_csv(file_path)

# IbisでSQLiteに接続してDataFrameとして返す関数
def read_csv_ibis(db_path, table_name):
    conn = ibis.sqlite.connect(db_path)
    return conn.table(table_name)

# DuckDBに接続してConnectionを返す関数
def connect_duckdb(db_path, table_name):
    conn = duckdb.connect(db_path)
    query = f"SELECT * FROM {table_name}"
    conn.execute(query)
    return conn

# PandasのDataFrameを表示
st.subheader('Pandas')
df_pandas = read_csv_pandas('dummy.csv')
st.dataframe(df_pandas)

# PolarsのDataFrameを表示
st.subheader('Polars')
df_polars = read_csv_polars('dummy.csv')
st.dataframe(df_polars)

# IbisのDataFrameを表示
st.subheader('Ibis')
df_ibis = read_csv_ibis('db.sqlite', 'test')
st.dataframe(df_ibis)

# DuckDBのConnectionを表示
st.subheader('DuckDB by PEP 249')
conn_duckdb = connect_duckdb('db.duckdb', 'test')
st.dataframe(conn_duckdb)

# それぞれのオブジェクトを使ってチャートを表示
st.subheader('Chart from Pandas')
st.bar_chart(df_pandas, x='ID', y=['Val1', 'Val2'])
st.subheader('Chart from Polars')
st.bar_chart(df_polars, x='ID', y=['Val1', 'Val2'])
st.subheader('Chart from Ibis')
st.bar_chart(df_ibis, x='ID', y=['Val1', 'Val2'])
st.subheader('Chart from DuckDB by PEP 249')
# これは上手く表示されない：上記のst.dataframe(conn_duckdb)でcursorが動いてしまっているため
# cursorが動いていないときであれば動作する
st.bar_chart(conn_duckdb, x='ID', y=['Val1', 'Val2'])

# それぞれのオブジェクトを使ってdata_editorを表示
st.subheader('Data Editor from Pandas')
st.data_editor(df_pandas)
st.subheader('Data Editor from Polars')
st.data_editor(df_polars)
# これはエラーになる
# StreamlitAPIException: The data type (Table) or format is not supported by the data editor. Please convert your data into a Pandas Dataframe or another supported data format.
# st.subheader('Data Editor from Ibis')
# st.data_editor(df_ibis)
# これは上手く表示されない：上記のst.dataframe(conn_duckdb)でcursorが動いてしまっているため
# cursorが動いていないときであれば動作する
st.subheader('Data Editor from DuckDB by PEP 249')
st.data_editor(conn_duckdb, key='duckdb1')
```

ただし、1.38.0時点ではいくつか明記されていない制限があるようです。  

### PythonデータベースAPI(PEP 249)を表示するときの制限

1回表示すると内部のcursorが動いてしまうようで、2回目の表示はできませんでした。  
なお、1回目であれば```st.data_editor```も使えますが、編集したデータのデータベースへの書き戻しはされないのでご注意ください。  

### ```st.data_editor```の制限

対応しているDataFrame形式と対応していないDataFrame形式がありそうです。 
具体的にはIbisはダメでした。  

また、PythonデータベースAPI(PEP 249)をソースとして```st.data_editor```を使うときだけ、オプション引数の```key```を指定することを要求されました。  

## ```st.json```の展開状態の設定

```st.json```の展開状態を設定することができるようになりました。  
オプション引数の```expanded```が```True```だとすべて展開、```False```だとすべて非展開、数値だとそのレベルまで展開します。  

https://x.com/AkSakatoku/status/1829156538973597771

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

# 選択肢を表示
val = st.selectbox("expandedに指定する値", ["True", "False", "1", "2", "3"])
if val == "True":
    expanded = True
elif val == "False":
    expanded = False
else:
    expanded = int(val)

st.json(
    {
        "foo": "bar",
        "stuff": [
            "stuff 1",
            "stuff 2",
            "stuff 3",
        ],
        "level1": {"level2": {"level3": {"a": "b"}}},
    },
    expanded=expanded,
)
```

## ```st.code```における行折り返し表示

```st.code```が折り返し表示できるようになりました。  
オプション引数の```wrap_lines```で設定できます。  

https://x.com/AkSakatoku/status/1829159034689310891

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

wrap_lines = st.checkbox("wrap_lines", value=False)

long_text = "This is very very long text. " * 10
code = f'''
def hello():
    print("{long_text}")
'''
st.code(code, language="python", wrap_lines=wrap_lines)
```

## Kubernetesで用いられる形式のシークレットに対応

Snowpark Container Servicesとの連携を想定して、Kubernetesで用いられる形式のシークレット、具体的にはディレクトリにひとつひとつのシークレットを配置する方法に対応しました。  
例えば以下のようにファイルを配置することで各ファイルの中身をシークレットとして読み出すことができます。  

> .streamlit/
>　└ .secrets/
>　　 ├ user1/
>　　 │　├ user
>　　 │　└ password
>　　 ├ user2/
>　　 │　├ user
>　　 │　└ password
>　　 └ somevalue
>　　  　└ somesecret

デフォルトでは```.streamlit/secrets.toml```のみを読み込もうとするため、```.streamlit/config.toml```の構成オプションにシークレットを配置するディレクトリを指定してあげる必要があります。  
なお、構成オプションの反映は```Rerun```ではなくStreamlitの再起動が必要なので注意してください。  
また、Streamlitの実装の都合上、1.38.0時点では指定するディレクトリ名の末尾が「.toml」だと```Permission denied```で怒られます。うーむ、あるある。

```toml:.streamlit/config.toml
[secrets]
files   = [ ".streamlit/secrets.toml", ".streamlit/.secrets" ]
```

Python側で実際にシークレットを読み出すときは以下のようになります。  
指定したディレクトリからの相対パスがキーに変換されます。  
また、ディレクトリ内にファイルが一つだけの場合はファイル名に相当するキーが省略されます。  

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

# 従来の.streamlit/secrets.tomlに記載した場合
st.write("OpenAI_key: " + st.secrets["OpenAI_key"])
st.write("[database] user: " + st.secrets["database"]["user"])
st.write("[database] password: " + st.secrets["database"]["password"])

# 以降はKubernetesで用いられる形式のシークレットで記載した場合

# .streamlit/.secrets/user1にuserファイルとpasswordファイルを配置
st.write("[user1] user: " + st.secrets["user1"]["user"])
st.write("[user1] password: " + st.secrets["user1"]["password"])

# .streamlit/.secrets/user2にuserファイルとpasswordファイルを配置
st.write("[user2] user: " + st.secrets["user2"]["user"])
st.write("[user2] password: " + st.secrets["user2"]["password"])

# .streamlit/.secrets/somevalueにファイルを一つだけ配置
st.write("somevalue: " + st.secrets["somevalue"])

# 全体像を表示
st.write(st.secrets)
```

## pydantic 1.x.x系への対応の一部打ち切り

pydanticはPythonのバリデーションライブラリです。  
2023/6/30に2.0.0がリリースされて、それ以降もある程度は1.x.x系と2.x.x系が並行で開発されているようです。  
Streamlitと1.x.x系を組み合わせるとカスタムバリデーションという機能が使えなくなるので、これを修正するためにStreamlitが動的にパッチを適用していたのようなのですが、2.x.x系がリリースされてだいぶ経つからそろそろいいだろう…ということでそのパッチが削除されました。  

ちなみに、pydanticはJSONで入出力するときのスキーマ定義に使われることがしばしばあるようで、例えば```openai-python```の依存関係にも含まれています。

なお、どうしても1.x.x系でカスタムバリデーションを使いたいときはユーザ側で対応できるとのことです。  

https://github.com/streamlit/streamlit/pull/9257

Pydanticの1.x.x系を試してみたコード、2.x.x系を試してみたコードは以下の通りです。  

```py:st_pydantic_v1.py
# pydantic 1.x.x系の実装
import streamlit as st
from pydantic import BaseModel, Field, validator

class User(BaseModel):
    name: str = Field(..., min_length=4, max_length=16)

    # ここがカスタムバリデーション
    @validator("name")
    @classmethod
    def validate_alphanumeric(cls, v: str) -> str:
        if not v.isalnum():
            raise ValueError("must be alphanumeric")
        return v

example_names = ["John", "John Doe", "John@Doe"]

for i, name in enumerate(example_names):
    try:
        st.subheader(f"example {i+1}:")
        user = User(name=name)
        st.json(user.json(indent=2))
    except ValueError as err:
        st.write("ValueError occurred:")
        st.json(err.json(indent=4))
```

```py:st_pydantic_v2.py
# pydantic 2.x.x系の実装
import streamlit as st
from pydantic import BaseModel, Field, field_validator

class User(BaseModel):
    name: str = Field(..., min_length=4, max_length=16)

    # ここがカスタムバリデーション
    @field_validator("name")
    @classmethod
    def validate_alphanumeric(cls, v: str) -> str:
        if not v.isalnum():
            raise ValueError("must be alphanumeric")
        return v

example_names = ["John", "John Doe", "John@Doe"]

for i, name in enumerate(example_names):
    try:
        st.subheader(f"example {i+1}:")
        user = User(name=name)
        st.json(user.model_dump_json(indent=2))
    except ValueError as err:
        st.write("ValueError occurred:")
        st.json(err.json(indent=4))
```

Pydanticの1.x.x系を試してみたコードを```Streamlit==1.38.0```かつ```pydantic<2```の環境で実行すると以下のようなエラーが発生します。  

> ConfigError: duplicate validator function "__main__.User.validate_alphanumeric"; if this is intended, set `allow_reuse=True`

![Pydanic error](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/9a4263eb-e928-1c61-92d0-9446619f3cdf.png)

## ```experimental_allow_widgets```の削除

これは```st.cache_data```に指定する実験的なパラメータで、キャッシュした関数内で```st.slider```のような入力要素を作成すると、その入力要素が返した値ごとにキャッシュを保持してくれる、というものでした。  

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

@st.cache_data(experimental_allow_widgets=True)
def get_data():
    # この選択結果ごとにキャッシュが保持される
    num_cols = st.slider("列数", min_value=1, max_value=10, value=1)
    data = pd.DataFrame(
        np.random.randn(20, num_cols), columns=("col %d" % i for i in range(num_cols))
    )
    return data

data = get_data()
st.line_chart(data)
```

便利なシーンはありそうなのですが、メモリ消費量がとても気軽に増えてしまうこと、他の機能を実装するときにしばしば影響があること、```st.fragment```による部分更新という違うアプローチで高速化できるようになったことなどの複合的な理由から廃止になったようです。  

1.38.0時点では以下のようなエラーが表示されて、入力要素の状態に関わらずキャッシュされます。  

![experimental_allow_widgets warning](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/b1c8ce12-e4e3-83dc-bcad-61003ed81a71.png)

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

@st.cache_data(experimental_allow_widgets=True)
def get_data():
    # この選択結果ごとにキャッシュが保持される…はずだった
    # 1.38.0からは最初に呼び出したとき、すなわちvalue=1のときのdataがキャッシュされる
    num_cols = st.slider("列数", min_value=1, max_value=10, value=1)
    data = pd.DataFrame(
        np.random.randn(20, num_cols), columns=("col %d" % i for i in range(num_cols))
    )
    return data

data = get_data()
st.line_chart(data)
```

## WebSocketの再接続でRerunされなくなった

書いてあるそのままです。  
Streamlitは裏側ではサーバプロセス(Python)とブラウザの間でWebSocket接続して通信しています。ただ、その裏側のネットワークがどうこうしたからRerunしてほしい、ということはあまりないので、いいアップデートだと思います。  
例えば、以下のようなプログラムを用意してローカルでStreamlitを動かしているときにPCをスリープにしてみると違いが分かります。  
1.37.0だとPCがスリープするたびに表示されるグラフが変わるのですが、1.38.0ではPCがスリープしても表示されるグラフは変わりません。  

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

def get_data():
    data = pd.DataFrame(
        np.random.randn(8, 3), columns=['a', 'b', 'c']
    )
    return data

data = get_data()
st.line_chart(data)
```