💨

dash + pyinstallerでUI付き自動化アプリケーション作成

2024/08/09に公開

目的

  • 数理最適化を用いて工場の生産計画を自動化するアプリケーションを作成した際にdash + pyinstallerでバイナリ化した時の知見をまとめる

目標

  • この記事見ればdashをバイナリ化できる

dashおさらい

拝借画像
引用: https://zenn.dev/yasudakn/articles/08f5ac0202967c

dashの技術構成はreact(redux) + flask

  • react + reduxでフロント側の処理を行い
  • callbackなどはflaskで実装されているらしい

https://zenn.dev/yasudakn/articles/08f5ac0202967c

https://qiita.com/Yusuke_Pipipi/items/b74f269d112f180d2131

超簡単にいえば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への対応

https://github.com/plotly/dash/issues/1885

  • 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