Streamlit in Snowflakeの外部パッケージと和解しよう

## Streamlit in Snowflakeとは

Streamlitは簡単に機械学習/LLM向けのウェブアプリを簡単に作成できるオープンソースのPythonライブラリで、[Streamlit in Snowflake](https://docs.snowflake.com/ja/developer-guide/streamlit/about-streamlit)(通称SiS)はSnowflakeのプラットフォーム上でStreamlitアプリを構築できる機能です。  
SnowflakeのWeb UIであるSnowsight上でStreamlitアプリを開発・動作させることが可能です。  

https://docs.snowflake.com/ja/developer-guide/streamlit/about-streamlit

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/335414af-43ce-dd66-35c7-6da2326c2583.png)

## 外部パッケージ(Stage Package)の基本

Streamlit in SnowflakeではAnacondaがメンテナンスしている独自のパッケージリポジトリを参照することができますが、以下のような手順で外部パッケージを利用することもできます。  

1. ステージを作成する(既にあるステージを使ってもいい)
1. 作成したステージにPythonファイルをアップロードする
1. ```EDIT``` 画面の ```Packages``` > ```Stage Packages``` から、アップロードしたPythonファイルを所定のフォーマットで指定する
1. Streamlitアプリの画面をリロードする
1. Streamlitプログラムから ```import``` する

具体的な指定ですが、例えばステージ名が ```mymodule``` 、Pythonファイル名が ```hogehoge.py``` であれば、以下のようになります。  

- ```Stage Packages``` で指定するPythonファイル名：```@mymodule/hogehoge.py```
- ```import``` コマンド：```import hogehoge```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/d9f1542e-ac39-513e-f39a-aa3ff66d8f37.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/03caedb8-564e-fbea-22d9-daa490f31133.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/a03fb81e-5f3b-d880-5765-bf2e431efc0c.png)

上記の場合は ```hogehoge.py``` に以下のようなプログラムを書きました。  

```py:hogehoge.py
def hello(name: str) -> str:
    return f'Hello, {name}!'
```

なお、Truestar社のオザワさんの記事に記載されているように、Streamlitアプリそのものが格納されているステージにPythonファイルをアップロードする方法もあります。  

https://blog.truestar.co.jp/snowflake/20231117/56866/

## 外部パッケージ上級編

外部パッケージにはzipファイルを指定することができます。  
zipファイルをステージにアップロードして ```Stage Packages``` で指定すると、そのzipファイルに含まれているPythonファイルをimportすることができます。  

以降、以下のような構造のzipファイルをサンプルを想定して記載します。  

> mycat.zip
> 　├ mycat.py
> 　├ hogehoge.py
> 　├ sub/
> 　│　├ \_\_init\_\_.py
> 　│　└ foobar.py
> 　└ static/
>　　　└ images/
>　　　　　└ cat1.jpg

Pythonファイル単体の場合と同様に、以下のような手順でzipファイルを外部パッケージとして利用することができます。  

1. ステージを作成する(既にあるステージを使ってもいい)
1. 作成したステージにzipファイルをアップロードする
1. ```EDIT``` 画面の ```Packages``` > ```Stage Packages``` から、アップロードしたzipファイルを所定のフォーマットで指定する
1. Streamlitアプリの画面をリロードする
1. Streamlitプログラムから ```import``` する

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/79bada53-26ff-c968-ff06-7a8d34d08e78.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/a9b5ad78-52fe-1267-2563-f7b50b8e9e32.png)

zipファイルの直下にあるPythonファイルは何もしなくても ```import``` できるのですが、サブディレクトリ配下にあるPythonファイルを ```import``` するときは、中身が空でもいいので ```__init__.py``` を含めておく必要があります。  
([toru_hiyama](https://qiita.com/toru_hiyama)さんに教えていただきました。ありがとうございます！)  
Pythonプログラムからは ```from サブディレクトリ名 import``` で ```import``` できます。  


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

# 外部パッケージをimportする
import hogehoge
from sub import foobar

message = hogehoge.hello("Kumataro")
st.write(message)

message = foobar.hello("Polar")
st.write(message)
```

### zipファイルに含まれる他のファイルを使いたい

Pythonファイルは上記のように ```import``` できるのですが、それ以外のファイルはそのままだとアクセスできません。  
例えば上記のサンプルではstatic/images/cat1.jpgというファイルが含まれていますが、外部パッケージがこのファイルを使う処理をしているとエラーが発生します。  
リソースファイルや機械学習モデルがセットになっているモジュールを扱いたいときに困りますね。  
これはzipファイルをすべて解凍しているのではなく、Snowflake Python UDFsの機能でzipファイルに含まれるPythonファイルだけを一時的に解凍していることに起因するようです。  

以下のINSIGHT LAB様の記事では使っているのはSnowflake Python UDFsですが、これと同様の挙動になります。  

https://knowledge.insight-lab.co.jp/snowflake/snowflake-python-udf-tried#:~:text=%E3%81%A7%E3%81%8D%E3%81%BE%E3%81%97%E3%81%9F%E3%80%82-,%E3%83%91%E3%83%83%E3%82%B1%E3%83%BC%E3%82%B8,-%E8%87%AA%E4%BD%9C%E3%83%91%E3%83%83%E3%82%B1%E3%83%BC%E3%82%B8%E3%82%92

解決策は、Stage Packagesで指定したzipファイルを/tmp配下に解凍することです。  
Snowsight上で動かすPythonプログラムあるいは外部パッケージのいずれかで、例えば以下のような処理を書けばOKです。  

```py:mycat.py
import streamlit as st
import os
import zipfile
import glob
import random

DEFAULT_PATH: str = "/tmp/mycat"
is_deployed = False

def deploy(path=DEFAULT_PATH) -> str:
    # "/tmp/appRoot"の直下にあるzipファイルを展開する
    # os.getcwd()は呼び出すタイミングによって異なるディレクトリ("/home/udf")を返すことに注意
    # zipファイル名は__file__から取得する
    dirname = "/tmp/appRoot"
    basename = os.path.basename(os.path.dirname(__file__))
    archive_path = os.path.join(dirname, basename)
    if archive_path.endswith(".zip"):
        # 本当はロックした方がいい
        with zipfile.ZipFile(archive_path) as z:
            z.extractall(path)

    # 本当はデプロイ済みかどうかはファイルの存在で判断する
    global is_deployed
    is_deployed = True

    return path

# モジュールを読み込んだときにデプロイする
if not is_deployed:
    deploy()

# ランダムな画像をStreamlitで表示する
def show_cat(path=DEFAULT_PATH):
    lst = glob.glob(path + "/static/images/*.jpg", recursive=True)
    # ランダムで選ぶ
    img_path = random.choice(lst)
    # 画像を表示
    st.image(img_path)
```

上記ではモジュールを読み込んだとき( ```import``` したとき)にzipファイルを解凍する処理を書いたので、リソースファイルを参照する関数をサッと呼び出せました。  
解凍する先が/tmp配下に限定されているため、パスを解決できるものに限られますが、外部パッケージの幅が広がりそうですね！  

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2917580/f085af9a-bbef-84fe-7db1-4f42516f45b5.png)