1. はじめに
前回の記事では「PyInstallerの内部構造」について触れましたが、今回は実践編として「ビルドの仕組みとカスタマイズ」を詳しく解説します。
PyInstallerを使うと、コマンド一つでとても簡単に実行ファイルを作ることができますが、できたファイルがそのまま動くというのは稀で、多くの場合、起動してみるとエラーが発生します。エラーの内容から、何が足りないかを考え、設定を変えて再度ビルドを行うトライ&エラーを繰り返すことになりますが、この作業には結構な労力と時間がかかります。
本記事では、PyInstallerのビルドプロセスを深掘りしながら、なぜエラーが起きるのか、どうしたらエラーを回避できるかなどを、説明していきます。
2. ビルドの手順
手順は非常に簡単で、pyinstaller コマンドを実行するだけです。
pyinstaller [options] script [script …] | specfile
exe化したいプログラムのメインスクリプトが myscript.pyだとすると、そのスクリプトがあるディレクトリで
pyinstaller myscript.py
と実行すると、しばらくの間、大量のメッセージがひとしきり表示され、同じディレクトリのdistというフォルダの中に(巨大な)実行モジュールができます。
各種オプションで、ビルド時に「実行ファイルをどう組み立てるか」を設定することが可能ですが、.spec ファイルという設定ファイルを使ってビルドをすることもできます。pyinstaller myscript.py を一度実行すると、カレントディレクトリにmyscript.spcというファイルができます。あとは、このファイルを編集して以下のようにビルドを行います。
pyinstaller myscript.spec
3. PyInstallerのビルドプロセス
PyInstallerによるビルドの内部では以下の処理が行われ、実行可能なモジュールフォルダが出来上がります。
- 解析:モジュールに取り込むべきファイルを洗い出します。
- 収集:解析フェーズで洗い出したファイルを一時ディレクトリにコピーし、ソースコード(.py)をコンパイルしバイトコード化します。
- アーカイブ:バイトコード化したものを一つのアーカイブファイルにまとめます。バイトコード化できないものは、_internalフォルダなどに格納して構造化します
- 結合:「ブートローダ(Bootloader)」と呼ばれるOSからプログラムを起動するためのプログラムとアーカイブを結合します。
4. どうやって解析しているのか?
ビルドプロセスの中で最も重要かつ複雑なのが「解析」フェーズです。PyInstallerは、以下の3つのステップで「実行に必要なファイル」を特定します。
4.1 静的解析(自動追跡)
指定されたスクリプトを読み込み、import 文を芋づる式に辿って「依存関係ツリー」を構築します。標準ライブラリだけでなく、インストールされているサードパーティ製ライブラリの中まで再帰的に解析します。
しかし、この解析で、全てが特定できる訳ではなく、次のようなものは追跡できません。
- 動的インポート: __import__ や import_module() による呼び出し
- バイナリ内の依存: C言語等で書かれたモジュール(.pyd/.so)内部での呼び出し
- 非Pythonファイル: 設定ファイル(JSON/YAML)、画像、学習済みモデルなど
4.2 手動指定(解析で追跡できなかったものを取り込む)
静的解析で漏れたものは、ユーザーが明示的に指定する必要があります。
- --hidden-imports : プログラム内で動的に読み込むモジュール名
- --add-data : 画像や設定ファイルなどのリソース
- --add-binary : DLLや共有ライブラリ
あるいは、.spec ファイルに以下のように記述することで指定することもできます。
a = Analysis(
['daemon_start.py'],
pathex=[],
binaries=[],
datas=[('frontend/*' , 'frontend'),], # (取り込むファイル, 取り込み先)のリスト
hiddenimports=['rest_framework.parsers',], # 取り込むモジュール名のリスト
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
)
4.3 Hookによる補完
実は、ソースコードを読み取るだけでは、内部で使われているすべてのファイルを自動的に追跡できないモジュールは結構たくさんあります。
PyInstallerは、主要なライブラリ(numpy, requests, PyQt など)向けに、「Hook(フック)」という専用の補足プログラムが用意されています。 Hookはいわば 「個別の指示書」 のようなもので、静的解析では見えない隠れた依存関係やデータファイルをPyInstallerに自動で教える役割を担っています。これによって多くの複雑なライブラリが「何もしなくても取り込める」ようになっています。
5. 問題は実行時に起きる (NotFoundとの戦い)
解析で追跡できなかったものを明示的に指定して取り込むことはできます。しかし、そもそも何を指定すべきかはやってみないとわかりません。
必要なものが取り込めていなくてもビルドは、成功します。PyInstallerは基本的に解析したものを、収集して結合するだけですので、出来上がったものが正しく実行できるかについてはほぼ関知しないからです。 1
従って、ビルド時点では何が取り込めなかったのかを知ることはできません。出来上がったモジュールを実行してエラーが発生した時に、はじめて何かが取り込めていなかったことがわかります。
発生する典型的なエラーは以下の2つです。
- ModuleNotFoundError : 必要なモジュールをimportしようとしたが、そのモジュールが見つからない時に発生するエラーです。
- FileNotFoundError : ファイルをオープンしようとしたが、そのファイルが見つからない時に発生するエラーです。
これらのエラーの発生により、取り込めていないモジュールやファイルがあることがわかれば、spec ファイルの hiddenimports や datas に足りないものを記述することができます。しかし、これで再ビルドしたものを実行しても、また別の NotFound エラーが発生するかもしれません。再び spec ファイルに追記をしてビルドして、実行して、、という具合にトライ&エラーを繰り返し、実行時のエラーが出なくなるまでこれを続けます。エラーの出方によっては非常に時間と手間がかかります。
同じモジュールの中で何度もエラーが出るようであれば、モジュール内の全ての関連ファイルをまとめてリストに取り込む collect_submodules や collect_data_files という関数を使った方が良いでしょう。(本来必要ないものも取り込んでしまうのでモジュールサイズは大きくなってしまいますが)
例えば、specファイルに以下のように記載します。
# 必要な関数をインポート
from PyInstaller.utils.hooks import collect_submodules, collect_data_files
# 特定のライブラリ用に追加
numpy_hidden_imports = collect_submodules('numpy')
numpy_datas = collect_data_files('numpy')
a = Analysis(
['myscript.py'],
datas=[('frontend/*', 'frontend')] + numpy_datas,
hiddenimports=['rest_framework.parsers'] + numpy_hidden_imports,
# ... 他の設定
)
6. 失敗しないための3つの鉄則
ここからは、PyInstallerを使うときに陥りやすい間違いについてです。
鉄則①:必ず「仮想環境内」のPyInstallerを使う
venvやpoetryなどの仮想環境で開発している場合、PyInstaller自体もその仮想環境の中にインストールしたものを使ってください。
「どこでも使えるように」と、グローバル環境(システム全体)にインストールしたPyInstallerでビルドしてしまうとハマります。グローバルなPyInstallerは、ソースコードの解析こそ仮想環境内を見に行きますが、ライブラリの実体を収集する際に「自分の足元(グローバル環境)」を探してしまいます。結果として、仮想環境にしか存在しないライブラリがコピーされず、実行時に ModuleNotFoundError が多発します。
この間違いは、PyInstallerの仕組みを知らないと、なかなか気づけないのではないかと思います。もしあなたが、原因不明の NotFound で悩んでいるのなら、原因はこれかもしれません。
鉄則②:PyInstallerは常に「最新版」を維持する
「一度動いたから」と古いバージョンのまま使い続けるのは危険です。PyInstallerは定期的にバージョンアップすることをお勧めします。
Scikit-learn や Numpy などのメジャーなライブラリは、頻繁に内部構造をアップデートします。PyInstaller側もこれに合わせて「Hook(指示書)」を更新していますが、古いPyInstallerはこの最新の指示書を持っていません。
私の場合、機能追加のために機械学習ライブラリ「Scikit-learn」を新たに import して使いPyInstallerでビルドしたところ、これに関連したモジュール(Scikit-learnの他scipyとかNumpyとか)が軒並み NotFound になってしまいました。実は、使っていたPyinstallerのバージョンをVer.6.11.1から最新のVer.6.18.0にアップグレードすれば最新のHookによりこのようなエラーは出なくなるのですが、それに気が付かずトライ&エラーを繰り返し、無駄な時間を費やしました。
鉄則③:データファイルのパスに注意
定義ファイルなどを読む際に、定義ファイルが実行ファイルと同じフォルダー(あるいは実行ファイルパスからの相対パス)にあることを前提としてプログラムを書いていると、PyInstallerで実行モジュール化した時に読み込めなくなります。
PyInstallerでexe化したプログラムの場合、実行ファイルのパスを取得する’__file__’が、exeファイルのある場所を指すとは限りません。
- --onefile モードでビルドした場合、.exe を叩いた瞬間に中身が一時ディレクトリ(Windowsなら %TEMP%/_MEIxxxxxx )に解凍されます。 この時、__file__ が指すのは「exeファイルがある場所」ではなく、「一時フォルダの中にある .py(または .pyc)がある場所」になってしまいます。
- --onedir モードにした場合でも、—add-data などで定義ファイルを取り込んだファイルは実行ファイルと同じフォルダには配置されませんので、やはり失敗します。
PyInstallerは実行時に、一時フォルダのパスを sys._MEIPASS という特別な変数に格納します。これを利用して、「実行環境(通常時かビルド後か)」によってパスを切り替える関数を作るのが定石です。
import os
import sys
def get_resource_path(relative_path):
""" リソース(データファイル)の絶対パスを返す """
if hasattr(sys, '_MEIPASS'):
# PyInstallerでビルドされた実行環境の場合
return os.path.join(sys._MEIPASS, relative_path)
# 通常のPython実行環境の場合
return os.path.join(os.path.abspath("."), relative_path)
# 使い方
config_path = get_resource_path("data/config.json")
7. 終わりに
ここに書いた「失敗しないための3つの鉄則」は、すべて私が実際に経験した失敗を元にしています。これらの失敗は、PyInstallerがビルドプロセスの中で何をしているのかを知らないと、気づくのが難しく、一度ハマると大変な時間と労力を費やすことになってしまいます。私の失敗を元にしたこの記事が、皆さんの「NotFound との戦い」を終わらせる一助になれば幸いです。
さて、これでエラーが出ない実行モジュールを作ることができましたが、できたモジュールのサイズが数百MBと巨大になってしまいます。次の記事では、実行モジュールのサイズを多少なりとも削減する方法について、解説したいと思います。
-
ビルド中に取り込めなかったという警告メッセージは一応出るには出ますが、必要ないものも含め大量に出るので、あまり読む気がしません ↩
