コンテンツへスキップ

PyInstallerの「hidden imports」とは何か — なぜ動的インポートだけが壊れるのか

Pythonで書いたデスクトップアプリをローカルで動かすと何の問題もないのに、PyInstallerで1つの実行ファイルにビルドした途端、特定の機能を使ったときだけ ModuleNotFoundError で落ちる——という経験をしたことがある人は少なくないはずだ。しかも厄介なのは、「アプリを起動しただけ」では再現せず、「ある特定のボタンを押したとき」のような、実行パスの奥まで入って初めて発覚する点にある。この現象の正体と、対処法である「hidden imports(隠しインポート)」の考え方を整理する。

補足: PyInstaller とは、Python スクリプトと依存パッケージをまとめて OS ごとの単一の実行ファイル(Windows なら .exe、macOS なら .app に同梱するバイナリ)としてパッケージ化するツール。ユーザーの手元に Python 実行環境がインストールされていなくても動くようにするための配布用ビルド手段として広く使われている。

PyInstallerが見ているのは「実行結果」ではなく「コードの見た目」

PyInstaller は、ビルド対象のスクリプトを実際に実行して依存関係を調べているわけではない。エントリーポイントのファイルからコードを静的に読み取り、import 文や from ... import ... 文を辿って依存モジュールのグラフを組み立てる。この段階では 一切コードを実行しない。あくまで「ソースコードの文字面としてどのモジュールが参照されているか」を機械的に収集しているだけである。

この方式は、モジュールの参照が素直に import 文として書かれている限りはうまく機能する。問題が起きるのは、モジュール名が実行時にならないと確定しない参照の仕方をしているケースだ。

hidden import が発生する典型パターン

PyInstaller のドキュメント自身が「hidden import」という言葉を使って説明している通り、これは静的解析の原理的な限界に起因する現象で、大きく次のようなパターンがある。

  1. 文字列を組み立てて動的にインポートする: importlib.import_module(f"core.{plugin_name}") のように、実行時に決まる変数からモジュール名を組み立てる書き方。静的解析の時点では plugin_name に何が入るかを知りようがないため、このモジュールは依存グラフに一切現れない。
  2. プラグイン機構による動的探索: pkgutil.iter_modules() などでディレクトリ内のモジュール一覧を実行時に走査し、見つかったものを順にインポートするような設計。個々のモジュール名がコード上のどこにも直接書かれていないため、静的解析では発見しようがない。
  3. サードパーティパッケージ内部の動的ロード: パッケージ自身が内部で条件分岐やプラグイン的な仕組みによってサブモジュールを遅延ロードしている場合、そのパッケージ用の PyInstaller フックが網羅していないサブモジュールが漏れることがある。

「関数内 import だから見落とされた」という現場の経験則

このアプリの build_app.py では、これまで複数回、core/thumbnail_utils.pycore/site_paths.py のような自作モジュールが、Windows / Mac のビルド後の実行ファイルでだけ ModuleNotFoundError を起こす事故に遭遇してきた。共通していたのは、いずれも関数の内側でだけ from core.xxx import yyy の形で参照されており、ファイル冒頭のトップレベル import には出てこないモジュールだったという点である。

こうしたケースを繰り返し踏んだ経験から、このプロジェクトでは「新しい core/*.py モジュールを追加したら、それがトップレベル import で確実に収集される確信があっても、念のため hidden リストに追記しておく」という運用ルールを設けている。理由は単純で、静的解析の完全性を過信せず、疑わしきは明示的に宣言しておく方が、ビルド後にしか発覚しない事故より圧倒的にコストが低いからだ。テストで「念のためアサーションを増やす」のと同じ発想で、ビルド設定でも defense in depth(多層防御)を優先している。

気づきにくいのは「クラッシュしない」タイプの hidden import 事故

もう一つ実際に踏んだ事故で象徴的だったのが、バージョン番号を扱う version モジュールのケースだ。core/updater.py の中で from version import VERSION が関数内 import として書かれており、これも静的解析から漏れて hidden import リストへの追加が漏れていた。

このケースが厄介だったのは、アプリが即座にクラッシュしなかった点にある。version モジュールが同梱されていなくても、フォールバック処理が働いて古いキャッシュ値やデフォルト値("0.0.0")を返すコードパスが存在していたため、アプリは普通に起動してしまい、ただ「表示されるバージョン番号だけが古いまま」という症状になった。ModuleNotFoundError で盛大に落ちてくれた方がまだ気づきやすく、こうした「動くには動くが値がおかしい」タイプの hidden import 事故の方が発見が遅れやすい。

対処法:--hidden-import.spec ファイル

PyInstaller で明示的にモジュールを同梱させる方法は主に2つある。

  • コマンドラインオプション --hidden-import <モジュール名> を、必要なモジュールの数だけ列挙する
  • .spec ファイル(PyInstaller がビルド設定を書き出す Python ファイル)内の Analysishiddenimports 引数にリストとして書く

どちらも効果は同じで、「静的解析では見つからないが、実行時に確実に必要になるモジュール」を人間が明示的に補ってやるという役割を果たす。サードパーティパッケージ側の問題であれば、そのパッケージ専用の hook-<パッケージ名>.py を書いて対応する方法もあるが、自作モジュールについては単純に hiddenimports へ列挙してしまう方が手っ取り早い。

まとめ

状況 静的解析での扱い
トップレベルの import x 通常は正しく収集される
文字列を組み立てて動的にインポート 実行時までモジュール名が確定しないため収集不可能
プラグイン機構による探索的インポート コード上に個別のモジュール名が現れないため収集不可能
関数内 import(このプロジェクトでの実際の事故パターン) 収集されることもあるが、繰り返し見落とし事故が起きたため明示的に列挙する運用に

PyInstaller の静的解析は、あくまで「ソースコードに書かれた文字面」を手がかりにした最善努力の推定であって、実行時の挙動を完全に予知するものではない。「このモジュールは動的にしか参照されない可能性がある」と少しでも思ったら、ModuleNotFoundError はもちろん、値だけが静かにおかしくなるような発見の遅れやすい事故を避けるためにも、hiddenimports へ明示的に追加しておく方が安全である。