dash + pyinstallerでUI付き自動化アプリケーション作成
目的
- 数理最適化を用いて工場の生産計画を自動化するアプリケーションを作成した際にdash + pyinstallerでバイナリ化した時の知見をまとめる
目標
- この記事見ればdashをバイナリ化できる
dashおさらい
引用: https://zenn.dev/yasudakn/articles/08f5ac0202967c
dashの技術構成はreact(redux) + flask
- react + reduxでフロント側の処理を行い
- callbackなどはflaskで実装されているらしい
超簡単にいえばdashのコードをもとにcall_backで書かれた部分をflaskで、viewの部分をreactで動かしている↓
pyinstallerおさらい
- pythonは本来python実行環境上で実行されるが、pyinstallerではプログラムを実行ファイル形式に変換することでスタンドアローンに実行できるようにする
バイナリ化する時にするべきこと
タスク
- ビルド用仮想環境の作成
- エントリーポイントの作成
- モジュール実行について
- 実行後ブラウザを自動で開く機能の実装
- ファイルパスの修正
- specファイルの用意
- dash系ライブラリの設定
- long_callbackへの対応
- バイナリに含めたい外部リソースの設定
- 並列処理への対応
- デバッグ&検証
ビルド用仮想環境の作成
- ビルド時にバイナリにはpythonの実行環境がそのまま含まれる
- 普段使っている環境をそのまま含めるとファイルサイズがとんでもなく大きくなるのであらかじめ仮想環境をvenvで作っておくのがおすすめ
pipenvを使っている時のrequirements.txtの作り方
pip freeze > requirements.txt
venv環境の作り方
python -m venv .venv # .venvは環境名。なんでもいい。この記事では.venvで進める。
このコマンドを実行すると、カレントディレクトリ直下に.venv(環境名)というフォルダができて、その中にpython実行環境ができている。pipenvと違って環境がソースコードに近いところにあるのでpyinstallerを使う際はこっちの方が便利
# mac
source .venv/bin/activate
# windows
.\.venv\Scripts\activate
# 先ほど作成したreq.txtからインストール
pip install -r requirements.txt
これでpyinstallerに含める実行環境ができた。
pyinstallerのインストール
pyinstallerはインストールされている環境のpythonを参照する。なのでこの環境にpyinstallerを入れる必要がある
# venvをsource済みの状態
pip install -d pyinstaller
で、おそらく今の状態ではpyinstallerのパスが反映されていないので、一旦仮想環境を閉じて入り直すと良い
deactivate
# mac
source .venv/bin/activate
# windows
.\.venv\Scripts\activate
この状態で準備完了である
エントリーポイントの整備
- エントリーポイントとはpyinstallerでビルドしたバイナリ実行時に実行するファイルのことである
- specファイルのAnalysysの先頭に記述することで指定する
- pyinstaller用の記述などを書くことがあるので、local実行用のmain.pyとは別にファイルを用意するのもおすすめ。pyinstaller用の記述が必要なければlocal実行用のファイルと同一でもいいかも。
- app.run()はdebug=Falseじゃないとバグる←重要
app.run(debug=False)
モジュール実行について
- 一応できるらしいが、検証していない
- -m で実行するファイルと同階層に__main__.pyを作成し、specのエントリーポイントにこれを指定することでできるらしい
-
__main__.py
のchatgptによる説明↓## 実行可能なパッケージのエントリポイント: Pythonのパッケージ(フォルダ内に__init__.pyが含まれているフォルダ)をコマンドライン から直接実行する際、__main__.pyがエントリポイント(起点)として機能します。 例えば、パッケージディレクトリがmypackageで、その中に__main__.pyが存在する場合、 コマンドラインからpython -m mypackageと実行することで、__main__.py内のコード が実行されます。 ## プログラムのスタートポイント: __main__.pyはプログラムの開始点としても機能し、プログラムの主要な実行ロジックを 含めることが一般的です。これにより、プログラムの構造が清潔に保たれ、実行フローの管理が 容易になります。 ## 再利用性と整理: パッケージ内の他のモジュールやクラス、関数をインポートして使用することで、コードの 再利用性を高め、整理された構造を維持することができます。これにより、大規模なプロジェクト での管理と保守が容易になります。
-
実行後ブラウザを自動で開く機能の実装
-
threading.Timerを用いるといい
def open_browser(): webbrowser.open_new('http://127.0.0.1:8050/') Timer(1, open_browser).start() # Dashアプリケーションの実行 app.run(debug=False)
絶対パスの修正
-
pyinstallerでビルドされたバイナリはmacとwindowsで
__file__
の値が変わってしまう。 -
configファイルなどをバイナリの外に置いて読み込んで実行したい場合、パスを修正する必要がある
-
例えば以下のコードを直実行とビルド後バイナリ実行で比較する
print(__file__) print(os.path.dirname(__file__))
-
出力は
# 直実行(mac) /Users/***/dev/dash-pyinstaller-template/src/main.py /Users/***/dev/dash-pyinstaller-template/src # バイナリ実行(mac) /var/folders/dm/b2322nz905g1zzm4y0vr8l940000gn/T/_MEIK6iOn9/main.py /var/folders/dm/b2322nz905g1zzm4y0vr8l940000gn/T/_MEIK6iOn9 # 直実行(Windows) C:\Users\***\dev\dash-pyinstaller-template\src\main.py C:\Users\***\dev\dash-pyinstaller-template\src # バイナリ実行(Windows) C:\Users\{user}\AppData\Local\Temp\_MEI544882\main.py C:\Users\{user}\AppData\Local\Temp\_MEI544882
-
バイナリ実行時の__file__がよくわからない値になるのはpyinstallerの仕様に起因する。
-
pyinstallerでまとめられた実行ファイルは、実行時システムのTempに展開されて実行される。そのため、__file__はTempに展開されたファイルの場所になってしまう。
-
対応としては
sys.argv[0]
を用いることが挙げられるprint(sys.argv[0]) print(os.path.dirname(sys.argv[0])) print(__file__) print(os.path.dirname(__file__))
-
結果
# 直実行(mac) src/main.py src /Users/***/dev/dash-pyinstaller-template/src/main.py /Users/***/dev/dash-pyinstaller-template/src # バイナリ実行(mac) /Users/***/dev/dash-pyinstaller-template/dist/built_binary /Users/***/dev/dash-pyinstaller-template/dist /var/folders/dm/b2322nz905g1zzm4y0vr8l940000gn/T/_MEIK6iOn9/main.py /var/folders/dm/b2322nz905g1zzm4y0vr8l940000gn/T/_MEIK6iOn9 # 直実行(Windows) src/main.py src C:\Users\***\dev\dash-pyinstaller-template\src\main.py C:\Users\***\dev\dash-pyinstaller-template\src # バイナリ実行(Windows) C:\Users\***\dev\dash-pyinstaller-template\dist\build_binary.exe C:\Users\***\dev\dash-pyinstaller-template\dist C:\Users\{user}\AppData\Local\Temp\_MEI544882\main.py C:\Users\{user}\AppData\Local\Temp\_MEI544882\
-
ポイントとしては、ローカル実行時sys.argv[0]は絶対パスではなくなることである
ちなみに実行時はソースコードがTemp上に解凍されて、解凍されたインタープリター上で解凍されたソースコードが動いてるっぽい挙動をする
- こう理解しておくと、いろいろな挙動をなんとなく理解できるはず
- カレントがおかしい→解凍されたTempのパスになってるだけ
- 一緒に別のソースコードを含められる→結局一緒にTempに解凍されてるだけ
specファイルの用意
-
ここでいうspecファイルとは、pyinstallerにビルド時に渡す設定ファイルのことである
-
.specという拡張子自体は他のシステムにも使われる
-
pyinstallerでのspecファイルは内部がpythonなので実は普通にpythonコードが書ける(ただし、エディターが赤線を引くのであまりお勧めできない)
-
基本的なspecファイル例:
block_cipher = None a = Analysis(['src/main.py'], pathex=['.'], binaries=[], datas=[ ('./image','image'), ], hiddenimports=[], hookspath=[], runtime_hooks=[], excludes=[], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=block_cipher, noarchive=False, module_collection_mode={ 'datashader': 'pyz+py', }, ) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) exe = EXE(pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [], name='output_name', debug=False, bootloader_ignore_signals=False, strip=False, upx=True, upx_exclude=[], runtime_tmpdir=None, console=True, # ウィンドウモード icon='./image/favicon.ico', # アイコンのパス ) coll = COLLECT(exe, a.binaries, a.zipfiles, a.datas, strip=False, upx=True, upx_exclude=[], name='dashapp') # OSX用 app = BUNDLE(coll, name='dashapp.app', icon='./image/favicon.ico', bundle_identifier=None)
-
Analysysには明示的に含めることを宣言するライブラリ(dash系は自動的に含めてくれないのでここに記述必要がある)や外部バイナリを記述することになる。
-
下のEXEにビルド時のアプリケーション名やアイコンなどを設定する。
- EXEのconsole=FalseにするとWindowsでの実行時にconsoleが出現しなくなる。ただしその場合、アプリケーションをUIから終了する機能を実装しておかないと終了する方法がタスクマネージャーからプロセス切るになってしまうので、コンソールを表示しておいた方がいいかもしれない。
-
datasなどの指定方法は1つ目にspecファイル視点での含めたいディレクトリ or ファイル、2個目にTemp上で解凍した時にどこにおくかになる
# hogehoge/src/dashapp.specに書かれてるのが
# datas = ['./image', 'image']だったら
hogehoge/src/image/
# がコンパイル時に含められて、実行時には
C:\Users\{user}\AppData\Local\Temp\_MEI544882\image\
# に展開されるみたいなイメージ
# datas = ['./image', 'hogehoge/foo']だったら
C:\Users\{user}\AppData\Local\Temp\_MEI544882\hogehoge\foo
# になる
- dashの場合は関連のライブラリをAnalysis内のdata(datasでも同じ?)に記述する必要がある。ちなみにdatas内に適当にライブラリを追加しても問題ないので怪しいと思ったらとりあえず追加しておけばいい
a = Analysis(['main.py'],
pathex=['.'],
data=[
(f'{site_package_path}dash', 'dash'),
(f'{site_package_path}plotly', 'plotly'),
(f'{site_package_path}dash_renderer', 'dash_renderer'),
(f'{site_package_path}dash_core_components', 'dash_core_components'),
(f'{site_package_path}dash_html_components', 'dash_html_components'),
(f'{site_package_path}dash_table', 'dash_table'),
(f'{site_package_path}dash_bootstrap_components', 'dash_bootstrap_components'),
(f'{site_package_path}dash_daq', 'dash_daq'),
(f'{site_package_path}Flask', 'Flask'),
(f'{site_package_path}flask_compress', 'flask_compress'),
(f'{site_package_path}flask_caching', 'flask_caching'),
(f'{site_package_path}flask_sqlalchemy', 'flask_sqlalchemy'),
(f'{site_package_path}flask_login', 'flask_login'),
(f'{site_package_path}flask_migrate', 'flask_migrate'),
(f'{site_package_path}flask_wtf', 'flask_wtf'),
(f'{site_package_path}flask_mail', 'flask_mail'),
(f'{site_package_path}flask_uploads', 'flask_uploads'),
(f'{site_package_path}flask_bcrypt', 'flask_bcrypt'),
(f'{site_package_path}flask_jwt_extended', 'flask_jwt_extended'),
(f'{site_package_path}flask_cors', 'flask_cors'),
(f'{site_package_path}flask_restful', 'flask_restful'),
(f'{site_package_path}flask_socketio', 'flask_socketio'),
(f'{site_package_path}flask_moment', 'flask_moment'),
(f'{site_package_path}flask_apscheduler', 'flask_apscheduler'),
(f'{site_package_path}psutil', 'psutil'),
(f'{site_package_path}multiprocess', 'multiprocess'),
],
binaries=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False
)
long_callbackへの対応
-
long_callbackは処理が特殊なのでデフォルトの設定では動かない。対応するためには、@long_callbackをappに追加する関数を作成し、そのファイルが存在するディレクトリをdatasに追加する必要がある
# src/callbacks/add_long_callback.py def add_long_callbaek(app): @app.long_callback( *** ) def ...
# spec ... datas = [ ..., ("src/callbacks", "src/callbacks"), ... ]
バイナリに含めたい外部ファイルの設定
-
例えば数理最適化ソルバーCBCを追加したいとする。
-
この場合、binariesにその旨を追記する
binaries=[ ("Cbc/bin/*", "Cbc/bin/*"), ("Cbc/lib/*", "Cbc/lib/*") ]
-
あまり理解していないが、同じフォルダをbinariesに書いてもdatasに書いても挙動に似た様な部分があり、cssが入ったassetsフォルダはどっちに入れても動いた。
-
また、追加する際は外部参照のlibやbinに注意する必要がある。特にgcc系。経験上よく出るので、正しく環境変数を設定させるか、そもそもそのlib or binをプロジェクト内にコピーしておくといい。
並列処理への対応はfreeze_support
-
デフォルトのまま並列処理を行うとアプリケーションのプロセスが不正に増えてしまい、正常に動作しないことがある。
-
freeze_support()を実行の最初に追記することで回避できる。
if __name__ == "__main__": # freeze_support freeze_support() Timer(1, open_browser).start() # ↓この関数でdash関連の定義を行っている main()
デバッグ&検証
- ビルド時のデバッグと検証については、specファイルのconsoleをTrueにしてログを確認するのが良い
- windowsとosxで挙動がかなり異なることがあるので(特に外部プログラムを追加する場合、実行に必要なgcc関連のライブラリなどの依存関係の位置が違う)別物と考えておいた方が良い
.venv\Scripts\pyinstaller dashapp.spec
これを実行するとdistフォルダの中にコンパイル結果が出力される
Resource
-
specファイルのテンプレ
# -*- mode: python ; coding: utf-8 -*- import platform import sys import multiprocessing multiprocessing.freeze_support() block_cipher = None if platform.system() == 'Windows': site_package_path = './.venv/Lib/site-packages/' elif platform.system() == 'Darwin': python_version = sys.version.split(' ')[0].split('.')[0] + '.' + sys.version.split(' ')[0].split('.')[1] site_package_path = f'./.venv/lib/python{python_version}/site-packages/' else: exit() a = Analysis(['entry_point.py'], pathex=['.'], data=[ (f'{site_package_path}dash', 'dash'), (f'{site_package_path}plotly', 'plotly'), (f'{site_package_path}dash_renderer', 'dash_renderer'), (f'{site_package_path}dash_core_components', 'dash_core_components'), (f'{site_package_path}dash_html_components', 'dash_html_components'), (f'{site_package_path}dash_table', 'dash_table'), (f'{site_package_path}dash_bootstrap_components', 'dash_bootstrap_components'), (f'{site_package_path}dash_daq', 'dash_daq'), (f'{site_package_path}Flask', 'Flask'), (f'{site_package_path}flask_compress', 'flask_compress'), (f'{site_package_path}flask_caching', 'flask_caching'), (f'{site_package_path}flask_sqlalchemy', 'flask_sqlalchemy'), (f'{site_package_path}flask_login', 'flask_login'), (f'{site_package_path}flask_migrate', 'flask_migrate'), (f'{site_package_path}flask_wtf', 'flask_wtf'), (f'{site_package_path}flask_mail', 'flask_mail'), (f'{site_package_path}flask_uploads', 'flask_uploads'), (f'{site_package_path}flask_bcrypt', 'flask_bcrypt'), (f'{site_package_path}flask_jwt_extended', 'flask_jwt_extended'), (f'{site_package_path}flask_cors', 'flask_cors'), (f'{site_package_path}flask_restful', 'flask_restful'), (f'{site_package_path}flask_socketio', 'flask_socketio'), (f'{site_package_path}flask_moment', 'flask_moment'), (f'{site_package_path}flask_apscheduler', 'flask_apscheduler'), (f'{site_package_path}psutil', 'psutil'), (f'{site_package_path}multiprocess', 'multiprocess'), (f'app/src/components', 'app/src/components'), ('cache', 'cache'), ('app/src/assets', 'app/src/assets') ], binaries=[], module_collection_mode={ 'datashader': 'pyz+py', }, win_no_prefer_redirects=False, win_private_assemblies=False, cipher=block_cipher, noarchive=False ) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) exe = EXE(pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [], name=f'plan-genie-{platform.system()}', debug=False, bootloader_ignore_signals=False, strip=False, upx=True, upx_exclude=[], runtime_tmpdir=None, console=True, # ウィンドウモード icon='icon.ico' # アイコンのパス )
-
build.sh build.batのテンプレ
-
build.sh
# Build the project python3.11 -m venv .venv .venv/bin/pip install -r requirements.txt .venv/bin/pip install pyinstaller # Run the project .venv/bin/pyinstaller dashapp.spec
-
build.bat
@echo off REM Build the project python -m venv .venv .venv\Scripts\pip install -r requirements.txt .venv\Scripts\pip install pyinstaller REM Run the project .venv\Scripts\pyinstaller dashapp.spec
-
Discussion