PyInstallerを使ってみた

PythonのスクリプトをまとめてWindows用の実行ファイルに変換できる、PyInstallerを使ってみました。
いろいろと躓いたので忘れないうちにメモ書きです。

# PyInstaller自体のインストール方法

とりあえず以下。

```powershell:コマンド
  $ pip install pyinstaller
```

もしお使いのプロジェクトでpipenvを使っている場合は、以下です(なぜかは以下に書きました)

```powershell:コマンド
  $ pipenv install -d pyinstaller
```

# 出遭ったトラブルとその対策

## 複数のファイルにまたがったプロジェクトをコンパイルするときは、どのファイルをターゲットにすれば良いの？
エントリポイントとなるファイル(mainがあるファイルなど)を引数に指定します。のこりの依存関係はPyInstallerが勝手に解析します

## なんかログに大量のWARNINGが出る
~~Visual Cの開発用モジュール関連のようですが、とりあえず無視してOKです~~

Windows 10 Universal C Runtimeというもののようです[^5]

とりあえずなくても動くようですが、気になる場合は`c:\windows\WinSxS\x86_microsoft-windows-m..namespace-downlevel_***(環境ごとに異なる文字)`というフォルダを参照パスに加えると消えます。

ただ、うちの環境では`WARNING: lib not found: api-ms-win-core-file-l2-1-0.dll dependency of c:\...\ucrtbase.dll` というエラーだけが消えませんでした。
## 商用で使いたい

PyInstallerは商用利用OKのようです。[^4]

## \_\_file\_\_が正しい値を指してくれなくなった。
sys.argv[0]を使いましょう。ただし、一般的な構成の通りに「[プロジェクト名]\src」配下にファイルを配置して開発していた場合など、「親フォルダのパス」が想定しているパスと違ってくる場合があるので注意が必要です。
Path#suffixがexeかどうかなどで処理を分けるといいでしょう。

```python:実装例
  def mypath():
    import sys
    from pathlib import Path
    p = Path(sys.argv[0])
    return p if p.suffix == ".exe" else p.parent
```

なお、PyInstallerを実行済みかどうか(実行ファイルかどうか)というのは、`hasattr(sys, "frozen")`でも確認可能なようです(exeになっているときはTrue)[^6]

### 2018/10/19追記：unittestを使用していた場合
Pythonの標準テストフレームワーク、unittestを使っていた場合、上記の実装だとユニットテスト実行時に正しいパスが取得できない問題を確認しました。
そのため、以下のように書くのが恐らく安全です。

```py:実装例
  def mypath():
    import sys
    from pathlib import Path
    return Path(sys.argv[0]) if hasattr(sys, "frozen") else \
      Path(__file__).parent
```

## pipenvを使っていた場合
一番ハマりました。通常通りコンソールからpyinstallerを呼び出すと、通常通りexeは出力されますが、インポートしたライブラリを読み込んでくれません。

この辺はpipenvのGithubにも質問が上がっていますが[^1]、pipコマンドでシステムグローバルにインストールしたPyInstallerでなく、pipenvで作った仮想環境内に入れたPyInstallerを使う必要があります。

そこで、pipenvコマンドでpyinstallerをインストールし、かならず**pipenv shellを実行した環境から**、PyInstallerを呼び出すようにしましょう。

```powershell:コマンド
  $ pipenv install -d pyinstaller
  $ pipenv shell
  $ pyinstaller ...
```

## インポートしたライブラリがPython以外のライブラリを使っていた場合
主にDLLなどです。この辺はコンパイルしたexeファイルを実行していると、そのDLLが必要になった時点で例外が発生するのでわかります(Tracebackに、どのDLLが必要なのか、どこに格納すべきかが表示されます)。
ライブラリが使用するDLLは大抵、インストールしたモジュールのsite-packagesフォルダに入っているので、PyInstallerの引数`--add-binary`を使って実行ファイルに組み込むようにしましょう。[^2]

##標準出力に出力していた内容をそれ以外の場所で使いたい
たとえばGUIのあるアプリなど、以前CUIに表示していた文言をわけあってGUIに出力したり、ログとして使いたい場合は、その出力の前に以下のコードを書きます。

```python:追加コード
  import sys
  sys.stdout = open("file.log", "w")
```

GUIに出力する場合、writeメソッドを実装したオブジェクトをsys.stdoutに突っ込むことでとりあえずリダイレクトできるようです。わたしは面倒なのでまだやっていません(そのうちやるかも)[^3]

また、標準出力を捨てたいときは、os.devnullを使います。

```python:追加コード
  import sys
  import os
  sys.stdout = open(os.devnull, "w")
```

# 以上を踏まえて、コンパイル用のコードを書く

何度か試行錯誤することになると思いますので、バッチファイルなりなんなりを書いたほうが断然楽です。一次出力されるspecファイルをいじってもいいらしいのですが、よくわかりませんでした。

```powershell:create_exe.ps1
if (Test-Path dist) {
  Remove-Item dist -Recurse | Out-Null
}
New-Item dist -ItemType Directory | Out-Null
$windll = "c:\windows\WinSxS\x86_microsoft-windows-m..namespace-downlevel_*"
$windllpath = "."
if(Test-Path $windll){
  $item = (Get-ChildItem $windll | Sort-Object LastWriteTime)[0]
  $windllpath = '"c:\windows\WinSxS\{0}"' -f $item.Name
}
pyinstaller `
  --onefile `
  --name Application `
  --path $windllpath `
  --specpath ./dist/ `
  --distpath ./dist/dist `
  --workpath ./dist/build `
  --add-binary "../.venv/Lib/site-packages/pyzbar/libzbar-32.dll;pyzbar/" `
  --add-binary "../.venv/Lib/site-packages/pyzbar/libiconv-2.dll;pyzbar/" `
  src\gui.py 
Copy-Item config -Recurse dist/dist/config
Copy-Item template -Recurse dist/dist/template
```

なお、GUIのアプリなので`--windowed`オプションもつけたかったのですが、つけて実行するとなぜかどこかで「OSError: [WinError 6] ハンドルが無効です。」というエラーが発生するため対処できていません。

# PyInstallerの仕組み

PyInstallerは、Pythonおよび関連するファイル一式をまとめて実行ファイル形式に変換し、実行時に以下のフォルダに展開、実行しています。

> %USERPROFILE%/AppData/Local/Temp/_[ランダムな英数字]/

このフォルダには、ファイルの実行に必要なpyd形式のPythonライブラリおよび、Pythonの実行エンジンとなるDLL、必要なライブラリ群、PyInstallerの`--add-data`オプションや`--add-binary`オプションでインクルードしたファイルなど一式が格納されます。

このファイルはアプリケーションの実行が終了すると自動的に削除されます(ただし、アプリケーションが異常終了したときなど消えずに残ってしまう場合があります。その場合いつ消えるのかは不明です)。

これでPyInstallerで作ったexeの実行がやたらと遅い理由がわかったね(Tempフォルダに全てのファイルを展開しているため。とはいえ容量の割には遅すぎると思いますので、今後のPyInstallerのアップデートで少し早くなる可能性はあります)。

## それを知っていて嬉しいことって？

例えば内部でJinja2などのテンプレートエンジンを使っていて、テンプレートとなるHTMLファイルをexeに組み込みたいとか、PyInstallerで格納したアイコンファイルをアプリのGUIにアイコンとして表示したいとかいったときには有効です。

この展開されたフォルダのパスは`sys.prefix`で得ることができるので、`Path(sys.prefix) / "template" / "main.jinja"`などとやれば、テンプレートとして埋め込んだHTMLファイルを読み込むことが可能となります[^6]

先にも書いたとおり、プログラムがPyInstallerによりexeにされているかどうかは、`hasattr(sys, "frozen")`または`Path(sys.argv[0]).prefix`で知ることができるので、これを使ってアイコンファイルやテンプレートファイルの参照パスを切り替えれば、exeにしたときも、スクリプトのままでも、期待通りに動くプログラムを作成することができます。

ただし、先の通り、**exe実行時に作成されたフォルダは、アプリの終了と同時に削除されてしまいます。**そのため、ユーザーの設定・編集結果を含むファイルをここに配置しても、当然アプリの終了と同時に消されてしまうので注意が必要です。

[^1]: [ModuleNotFoundError when running installed packages · Issue \#98 · pypa/pipenv](https://github.com/pypa/pipenv/issues/98)

[^2]: [Python 3\.x \- vlcとPyQt5を用いたプログラムのPyInstallerによるexe化がうまくいきません。\(104186\)｜teratail](https://teratail.com/questions/104186)(ただしここにある方法は使っていない)

[^3]: [\[Python\] stdoutをTkinter Textウィジェットにリダイレクトする方法 Python\-2\.7 \| CODE Q&A 問題解決 \[日本語\]](https://code.i-harness.com/ja/q/11a8c5c)

[^4]: [FAQ · pyinstaller/pyinstaller Wiki](https://github.com/pyinstaller/pyinstaller/wiki/FAQ#license)

[^5]: [lib no found ：api\-ms\-win\-crt\-\*\.dll · Issue \#2068 · pyinstaller/pyinstaller](https://github.com/pyinstaller/pyinstaller/issues/2068)

[^6]: [Embed icon in python script \- Stack Overflow](https://stackoverflow.com/questions/9929479/embed-icon-in-python-script) ※ 一番下の回答で`hasattr…`および`sys.prefix`が使われている