Streamlit アプリを pyinstaller で .exe パッケージ化のメモ(2023/01 時点)

## 背景

streamlit のアプリを .exe にしてぺろっと配布できるようにしたい...

pyinstaller を使い, .py から `steramlit run` を動かすという形で対応します.

https://discuss.streamlit.io/t/using-pyinstaller-or-similar-to-create-an-executable/902



https://qiita.com/syoyo/items/b0ce06b00c1b3abc0245

## 手順

https://note.com/jack952/n/n9c38385a6ccf

ありがとうございます. 基本はこれを周到すればよいです. 以下は差分的な感じです.

2022 年 11 月時点で, `streamlit.web.cli` がすでに用意されているので,  site-packages のほうに cli.py を作る必要がなくなりました.

https://discuss.streamlit.io/t/using-pyinstaller-or-similar-to-create-an-executable/902/65

にあるようにエントリとなる .py はこんな感じになります.

```py
import streamlit.web.cli as stcli
import sys

def streamlit_run():
     sys.argv=["streamlit", "run", "main.py", "--global.developmentMode=false"]
     sys.exit(stcli.main())

if __name__ == "__main__":
     streamlit_run()
```

## `.streamlit/config.toml` は不要かもしれません.

とりあえずなくても cli コマンド引数で対応できるためです.

## spec ファイル

一度コマンドラインで pyinstaller 実行し, spec ファイルが作られるのでこれを編集します.

### asset file

実行時に streamlit のサイトテンプレートのファイルなどが必要になります(`streamlit/static/index.html` など)

datas にパス(元ディレクトリと, コピー先)を指定します.
`site.getsitepackages()` で現在(pyinstaller 動かす環境)の python site-packages からコピるようにするとよいでしょう.
 
```
import site
import os

block_cipher = None

assert len(site.getsitepackages()) > 0

# Choose path containing "site-packages"
package_path = site.getsitepackages()[0]
for p in site.getsitepackages():
    if "site-package" in p:
        package_path = p
        break

a = Analysis(
    ['cli_main.py'],
    pathex=[],
    binaries=[],
    # copy streamlit files
    datas=[(os.path.join(package_path, "altair/vegalite/v4/schema/vega-lite-schema.json"),
        "./altair/vegalite/v4/schema/"),
       (os.path.join(package_path, "streamlit/static"),"./streamlit/static")
       ],
    hiddenimports=[],
    hookspath=['./hooks'],
    hooksconfig={},
    runtime_hooks=[],
    excludes=[],
    win_no_prefer_redirects=False,
    win_private_assemblies=False,
    cipher=block_cipher,
    noarchive=False,
)
```

streamlit で利用する .py 関連(multipage 利用時は `pages` フォルダの `.py` なども)は `datas` に記載しましょう.

### hooks

hooks スクリプトが必要になります. これを行わないと, 実行時 `import streamlit` で `streamlit` フォルダを import するようになり, しかしパッケージには static html のアセットファイルしかないため import error

```
importlib_metadata.PackageNotFoundError: No package metadata was found for streamlit
```

が生じてしまいます.

```py
# hook-streamlit.py

from PyInstaller.utils.hooks import copy_metadata
datas = copy_metadata('streamlit')
```

な hoo-streamlit.py ファイルを作り,

 ↑での `hookspath` に hooks スクリプトのフォルダを記載します.

hook 用のファイル名は `hook-IMPORTNAME.py` である必要があります.

https://pyinstaller.org/en/stable/hooks.html

streamlit 1.14 あたりから, 実行時 `'No module named streamlit.runtime.scriptrunner.magic_funcs' エラーがでるるようになります.

`streamlit.runtime` は実行時に評価されるモジュールのため, .spec での datas に `streamlit.runtime` のフォルダもコピーするように追加するか, エントリポイントの .py で import して pyinstaller が見つけられるようにしておくとよいでしょう.
 
### other python modules

cv2 など, streamlit に直接関係しない module は pyinstaller が自動では見つけてくれないので, 
(.exe にしたあと .py を評価しないとわからないため)
適当に `requirements.txt` などの python module をエントリポイントの .py で import しておくか,
.spec に記述しておきましょう

## パッケージング

あとは pyinstaller で .spec ファイルを指定してパッケージングするだけです!

お好みで `--onefile` などして.

## 問題点

streamlit が web サーバプロセスとして動くので, port 解放とか必要になります.
Electron で Standalone という手もありますが, 完全に処理部分を以下の stlite(python module が全部 WASM 化されている場合)に置き換えられる場合に限られます.
(ローカルファイル扱っていたり, FastAPI や pytorch つかっていたりしたら結局はサーバプロセス + Electron 等の GUI の組み合わせとなってしまう. FastAPI については OpenSSL 部分含め WASM モジュール化できればワンチャンあるかもしれませんが...)

### .py がいじれる

起動させる .py はユーザーがいじれてしまいます.
ソースコード公開したくないやソースいじられたくない場合, なにか方法を考える必要があります(cli 起動側で main.py 部分を import させて .exe に取り込むなどできるかも?)

### パッケージが大きい, 起動が遅い

pytorch とか使っているとパッケージが 4 GB とかになります. 大きめの python プロジェクトだと起動も遅くなります...

このあたりはどうしようもないですね...

python-build-standalone で Python 環境を self-contained で作り, PyOxidizer でバイナリ化(Python 環境 embed)がいいかもしれません.

https://python-build-standalone.readthedocs.io/en/latest/

https://github.com/indygreg/PyOxidizer


## stlite を使う(制約あり)

https://discuss.streamlit.io/t/new-library-stlite-a-port-of-streamlit-to-wasm-powered-by-pyodide/25556

pyodide で WASM 化された Python で pure HTML(WASM)で動かす
C++ native module などを含んだものは対応できません(のはず)
(C++ -> wasm コンパイルすればいけるだろうがめんどい) 

pytorch とかつかっておらず, numpy あたりだけ使っているのであれば, これと nativefier を組み合わせすれば行けそうなきもします!(未検証) ただ, 大きめのプロジェクトだと numpy とかの js のロードで起動遅くなるやも?


## TODO

`main.py` など .spec に記載し自動でパッケージされるようにする.

* [ ] electron-builder を試す https://qiita.com/tetutaro/items/ac04b4dca12fbbaf55dc
* [ ] PyOxidizer を試す