【PyInstaller】Pythonで作成したアプリをexe化して配布する

#0. はじめに

前回に引き続き連載ネタ第3弾です。
作成したGUIもPythonの環境構築している人しか使えません。技術者が何かしらのアプリを作成してもいざユーザーに使ってもらう際に、そのユーザーに難解な環境構築をさせるのもひと手間ですね。
そこで今回はPyrhon環境がないユーザーにexe化してアプリ提供する部分を書いていきます。
exe化もいくつかあります(Py2exeとかcx_freeze)が、今回はメジャーなPyInstallerを紹介します。

[【第1回】Pythonで簡単に日本語OCR](https://qiita.com/ku_a_i/items/93fdbd75edacb34ec610)
[【第2回】PythonでオリジナルGUIアプリを作成](https://qiita.com/ku_a_i/items/20004800adc78fa94a0b) 　※前回記事
**【第3回】Pythonで作成したアプリをexe化して配布する**　**←今回はこの記事**


* 動作環境
 * OS : Windows10 pro
 * Python : 3.8.3 // Miniconda 4.9.1
 * PyInstaller : 4.6　※今最新は4.7になってるらしい
 * PySimpleGUI ：4.55.1 (GUI部分)
 * Tesseract : 5.0.0　(OCR部分)
 * pyocr : 0.8　(OCR部分)
 * Pillow : 8.4.0

**※今回はjupyter notebookは使用しまぜん**

#1. PyInstallerでexe化を行う前に知っておくべきこと

まずPyInstallerは`pip install pyinstaller`で導入可能です。
そしてexe化したい.pyファイルがある階層でコマンドプロンプトを開き、`pyinstaller [exe化したいファイル名] --onefile`というコマンドを実行すればいいだけです。

**ただそれだけなのでこれで記事を終わってもいいんですが、せっかくなのでこの記事ではPyInstallerに関してかなり色々掘り下げていきます**

###1-1. コマンドオプション

上のコマンドでも「–onefile」というものがついているのがお分かりになると思いますが、PyInstallerにはいくつかオプションが存在します。全部は紹介できないものの、その中からいくつかを紹介します。


|オプション  | 効果 |
|---|---|
|--onefile  |プログラムを一つにまとめるためのコマンド  |
|--noconsole  |コマンドプロンプトを表示させないコマンド|
|--icon  |exe化したときのアイコンを指定するコマンド  |
|--debug all  |exe実行時にデバッグするコマンド(--noconsoleは指定しないこと)  |
|--clean  |PyInstallerキャッシュを消去するコマンド  |
|--exclude  |不要なライブラリを指定するコマンド  |
|--key |暗号化に使用されるキーを指定する(難読化)コマンド  |

###1-2. アイコンの作成
コマンドオプションで`--icon`というものがあるが、これは`jpgやpngを指定できず.icoファイルを指定する必要がある`ので、ここではすでにある画像を使用して簡単に.icoをpythonから作成する。**皆さんはもっとかっこいいアイコンを設定してみてください。**

※今回のアイコンは以下(パワポで適当に作成しただけ・・・)

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373060/84b00780-5ae9-50e6-21ad-a9e9e88be68f.png" width=10%>

```Python:(コマンドプロンプトでそのままpythonを起動)
>>> from PIL import Image
>>> filename = r'OCR.png' #適当な画像を読み込む
>>> img = Image.open(filename)
>>> img.save('OCR.ico') #.icoで保存する
```

★あとでこのアイコンをexeの画像として指定する


###1-3. 仮想環境の構築

↑のオプションはさておき、何も考えずにexe化コマンドを実行するとどうなるか・・・
無茶苦茶重いexeファイルが出来上がる可能性があります。そりゃそうですよね、`今使ってる開発環境のpythonのライブラリを全部使えるように盛り込んだexe`なので、不要なライブラリとかも入ってる為です。

そこで新規でexe用の仮想環境を立ち上げて、必要なライブラリだけ導入してexeを作成することにする。※今回はcondaの仮想環境ケースを書くが、別にvenvでもなんでもOKである。

```:コマンドプロンプト
#--pyinstallerという名前の仮想環境作成--
conda create -n pyinstaller python=3.8.3

#--作成した仮想環境に切り替える--
conda activate pyinstaller

#--以下自作アプリで使用している必要ライブラリをpipで入れる--
pip install PySimpleGUI
pip install pillow
pip install pyocr
pip install pyinstaller
```

#2. 実際に前回までのGUIアプリをexe化する

※参考(前回記事)

https://qiita.com/ku_a_i/items/20004800adc78fa94a0b


###2-1.プログラムのPath指定部分を変更する

**プログラムの中にpathを指定するような箇所がある場合は修正が必要になる。**

```Python:sample.py
import PySimpleGUI as sg
from PIL import Image, ImageTk, ImageEnhance
import io
import os
import pyocr

#テーマカラーを設定
sg.theme('Purple')

#TesseractのPath情報登録 ※ここをFULLpathから変更する
"""
exeが置かれるpathにTesseractを入れてもらえばOK
※配布側がそのままTesseractと同梱で配布するのも可能(同じような環境のユーザーの場合のみ)
"""
path = os.getcwd() #exe実行ディレクトリpathを取得するのが重要
TESSERACT_PATH = path + '/Tesseract-OCR' #今回はexeと同じディレクトリに配置させる前提とする
TESSDATA_PATH = path + '/Tesseract-OCR/tessdata' #tessdataのpath
os.environ["PATH"] += os.pathsep + TESSERACT_PATH
os.environ["TESSDATA_PREFIX"] = TESSDATA_PATH

"""以下全く同じでいいので略。"""
```


###2-2.PyInstallerでexe化

sample.pyの配置されているディレクトリへcdコマンドで移動し、exe化コマンドを打つ。
今回使用するオプションは「onefile、noconsole、clean、icon 」の4つとする。

```:コマンドプロンプト
#--作成した仮想環境に切り替える--
conda activate pyinstaller

#--sample.pyのディレクトリ移動--
cd C:\Users\・・・\sample.pyのディレクトリ

#--exe化実行-- ※さっき作成した.icoを同じディレクトリに配置する
pyinstaller sample.py --onefile --noconsole --clean --icon OCR.ico

#--アイコンキャッシュを削除(★これしないとアイコンが反映されないので注意★)--
ie4uinit.exe -show
```

exeは「dist」フォルダの中に出来るため、**プログラム内でPath指定しているものを「dist」内に集結させる。**※exeのアイコンが変わっていることも確認できる。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373060/aadc9ccf-75fe-a20f-d730-a3b32a95cd46.png" width=50%>

このdistフォルダをユーザーに配ればユーザーはpython環境が無くてもアプリを実行できるようになる。**※ただし今回のアプリではpyocrを使用する為、以下2-3の対応が別途必要です**

参考までに完成イメージGIFは以下のようになります(**クドイようですが今回のアプリは2-3の対応しないとOCR実行時にエラーになります**)

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373060/f7d7fd2d-f5b0-62b7-332e-74af39099c9e.gif" width=60%>


###2-3.onefile＋noconsoleの組み合わせ時のエラー対応(ハンドルが無効)

**現時点(2021/11/23)で上で述べたexeを作成して実行するとtesseractで以下のようなエラーが発生するはずである。<font color="Red">調べた限りだと「onefile」「noconsole」のコマンドオプションを同時に使用し、かつ「subprocessライブラリ」を併用した場合にエラーが起きる模様</font>(以下公式のWiki)**

https://github.com/pyinstaller/pyinstaller/wiki/Recipe-subprocess

`noconsoleなのでこのエラー内容を確認するには、try-except文にlogを残すような改造が必要です(本記事では省略)`
`※ハンドルというのは「何らかのリソースを識別・操作するための識別子」のことです`

```:エラー内容
File "pyocr\tesseract.py", line 364, in image_to_string
File "pyocr\tesseract.py", line 293, in run_tesseract
File "subprocess.py", line 804, in __init__
File "subprocess.py", line 1142, in _get_handles
OSError: [WinError 6] ハンドルが無効です。
```

まずはエラーの原因になっている`tesseract.py`を探す必要がある。
**<font color="Red">仮想環境に入ったままpythonを起動して</font>**以下のように確認すれば簡単。

```Python:(コマンドプロンプトでそのままpythonを起動)
>>> import pyocr
>>> pyocr.__file__
'C:\\Users\\****\\envs\\pyinstaller\\lib\\site-packages\\pyocr\\__init__.py'
```
指定された場所に行けば以下のように問題となっている「tesseract.py」が見つかるはずである。

![pyocr.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/373060/d579a4ae-2fff-0e38-0c3b-d20ff43ab29c.png)

以下は修正前と後を記載する。

```Python:tesseract.py(修正前)
293    proc = subprocess.Popen(command, cwd=cwd,
294                            startupinfo=g_subprocess_startup_info,
295                            creationflags=g_creation_flags,
296                            stdout=subprocess.PIPE,
297                            stderr=subprocess.STDOUT)
```

これを以下のように修正して保存する。

```Python:tesseract.py(修正後)
293    proc = subprocess.Popen(command, cwd=cwd,
294                            startupinfo=g_subprocess_startup_info,
295                            creationflags=g_creation_flags,
296                            stdout=subprocess.PIPE,
297                            stderr=subprocess.STDOUT,
298                            stdin=subprocess.DEVNULL) #これを追加
```
この修正後に`pyinstaller sample.py --onefile --noconsole --clean --icon OCR.ico`とやり直せばエラーを出さずに実行可能である。

<details><summary>subprocess.pyのエラー箇所解説(読み飛ばしOK)</summary><div>

そもそも以下部分で「if stdin is None:」だとエラーになるようになっており、`subprocess.pyにおけるデフォルト設定(init関数)がstdin=Noneなのでエラーになる`ようである。そこで上述のように`stdin=subprocess.DEVNULL`を追加することにより回避させている。

```Python:subprocess.py
        def _get_handles(self, stdin, stdout, stderr):
            """Construct and return tuple with IO objects:
            p2cread, p2cwrite, c2pread, c2pwrite, errread, errwrite
            """
            if stdin is None and stdout is None and stderr is None:
                return (-1, -1, -1, -1, -1, -1)

            p2cread, p2cwrite = -1, -1
            c2pread, c2pwrite = -1, -1
            errread, errwrite = -1, -1

            if stdin is None: #★★ここでNoneだとエラーになるようになってる★★
                p2cread = _winapi.GetStdHandle(_winapi.STD_INPUT_HANDLE)
                if p2cread is None:
                    p2cread, _ = _winapi.CreatePipe(None, 0)
                    p2cread = Handle(p2cread)
                    _winapi.CloseHandle(_)
            elif stdin == PIPE:
                p2cread, p2cwrite = _winapi.CreatePipe(None, 0)
                p2cread, p2cwrite = Handle(p2cread), Handle(p2cwrite)
            elif stdin == DEVNULL: #★★ここに分岐できるように修正する★★
                p2cread = msvcrt.get_osfhandle(self._get_devnull())
```
</div></details>

#3. さいごに
Pythonが触れる人間が社内で作ったアプリを紹介したら「それ使いたい！」と言われることも多いと思う。さらに使い勝手がいい場合、勝手に口コミで他部署にも伝番することも少なくない。
そんな時に手順書作成していちいち環境構築をやらせるわけにもいかないので、exe化を覚えておいて損はないと思う。
**今回3回に渡って記事連載した流れなんて例えば「これからDX進めるぞ！！」なんて部署では割とありがちな流れを書いたつもり**なのでぜひ参考にしていただければと思います。

それでは今回はここまで！

##参考リンク集
・https://github.com/pyinstaller/pyinstaller
・https://pyinstaller.readthedocs.io/en/stable/usage.html
・https://news.mynavi.jp/article/20180131-windows_icon/
・https://stackoverflow.com/questions/69425010/pyinstaller-error-oserror-winerror-6-the-handle-is-invalid
・https://github.com/pyinstaller/pyinstaller/wiki/Recipe-subprocess
・https://github.com/pyinstaller/pyinstaller/issues/5601