pyinstallerで余計なモジュールを省いて.exeファイル変換する

## イントロダクション
RPAのためにPythonファイルを作っても横展開ができない。  
　→環境関係なく実行するためにExeファイル化したい。  
　　→PyInstallerなるものが一番シンプルらしい。  
　　　→でも、PyInstallerは重い。

要らないモジュールはすべて弾いてしまおう。

## 目次
- [イントロダクション](#イントロダクション)
- [背景](#背景)
- [環境情報](#環境情報)
- [コード](#コード)
- [コードの流れ](#コードの流れ)
- [どう使えばいいのか](#どう使えばいいのか)
- [注意点](#注意点)
- [道のり](#道のり)
- [なぜ作ったのか](#なぜ作ったのか)
- [結論](#結論)

## 背景
PyInstallerは性質上インストールされているモジュールを全部取り込んでexeファイル化するらしい。  そのため、PyInstallerで作成したexeファイルは重くなる。

これを防ぐために下記のように不要なモジュールを指定することができる。

```
pyinstaller （略）--exclude <モジュール名>
```

じゃあ、自分の作ったpythonファイルで使っているモジュール以外をすべて弾けるようにすればいい。こう考えてまずはpythonファイルにimprot, fromで指定したモジュールを抽出しexcludeできるファイルを作った。

しかし、この方法はうまくいかなかった。

使用しているモジュールの中でさらに別のモジュールが読み込まれているからだ。
これをexcludeで指定してしまうとNo module named <モジュール名>のエラーが起きる。
適切にモジュールをexcludeできる仕組みを考えないといけない。

## 環境情報

- **OS**: Microsoft Windows 10 Pro 10.0.19045 N/A ビルド 19045
- **Pythonバージョン**: Python 3.8.7
- **依存ライブラリ**:
    -  <details><summary>もっと見る</summary>
        aenum==3.1.15
        aiohttp==3.8.5
        aiosignal==1.3.1
        altgraph==0.17.2
        annotated-types==0.5.0
        apiclient==1.0.4
        async-generator==1.10
        async-timeout==4.0.2
        attrs==22.1.0
        beautifulsoup4==4.12.2
        cachetools==5.2.0
        certifi==2022.9.14
        cffi==1.15.1
        chardet==5.0.0
        charset-normalizer==2.1.1
        chromedriver-autoinstaller==0.6.2
        chromedriver-binary==110.0.5481.30.0
        click==8.1.3
        cloudevents==1.9.0
        colorama==0.4.6
        contourpy==1.0.7
        cycler==0.11.0
        decorator==4.4.2
        Deprecated==1.2.14
        deprecation==2.1.0
        et-xmlfile==1.1.0
        exceptiongroup==1.0.4
        ffmpeg-python==0.2.0
        Flask==2.2.2
        fonttools==4.39.3
        frozenlist==1.4.0
        functions-framework==3.3.0
        future==0.18.2
        google-api-core==2.11.0
        google-api-python-client==2.72.0
        google-auth==2.15.0
        google-auth-httplib2==0.1.0
        google-auth-oauthlib==0.8.0
        google-cloud==0.34.0
        google-cloud-core==2.3.3
        google-cloud-datastore==2.17.0
        google-cloud-firestore==2.11.1
        googleapis-common-protos==1.57.0
        grpcio==1.57.0
        grpcio-status==1.57.0
        h11==0.14.0
        httplib2==0.21.0
        huepy==1.2.1
        idna==3.4
        imageio==2.22.4
        imageio-ffmpeg==0.4.7
        importlib-metadata==6.0.0
        importlib-resources==5.12.0
        iniconfig==1.1.1
        instabot==0.117.0
        isodate==0.6.1
        itsdangerous==2.1.2
        Janome==0.4.2
        Jinja2==3.1.2
        joblib==1.2.0
        kiwisolver==1.4.4
        line-bot-sdk==3.2.0
        MarkupSafe==2.1.1
        matplotlib==3.7.1
        mecab-python3==1.0.6
        mock==4.0.3
        moviepy==1.0.3
        multidict==6.0.4
        mysql-connector-python==8.0.31
        numpy==1.23.3
        oauthlib==3.2.1
        openai==0.27.8
        opencv-python==4.7.0.72
        openpyxl==3.0.10
        outcome==1.2.0
        packaging==21.3
        pandas==1.5.2
        pefile==2022.5.30
        Pillow==9.3.0
        playsound==1.3.0
        pluggy==1.0.0
        proglog==0.1.10
        proto-plus==1.22.3
        protobuf==4.21.12
        pyasn1==0.4.8
        pyasn1-modules==0.2.8
        PyAudio==0.2.12
        pycparser==2.21
        pydantic==2.2.0
        pydantic-core==2.6.0
        pydub==0.25.1
        pyinstaller==5.4.1
        pyinstaller-hooks-contrib==2022.10
        pynput==1.7.6
        pyparsing==3.0.9
        pypiwin32==223
        PySocks==1.7.1
        pytest==7.2.0
        python-dateutil==2.8.2
        python-dotenv==0.21.0
        python-twitter==3.5
        pytz==2022.6
        pywin32==305
        pywin32-ctypes==0.2.0
        requests==2.31.0
        requests-oauthlib==1.3.1
        requests-toolbelt==0.10.1
        responses==0.22.0
        rsa==4.9
        schedule==1.1.0
        scikit-learn==1.1.2
        scipy==1.9.1
        selenium==4.7.2
        six==1.16.0
        sniffio==1.3.0
        sortedcontainers==2.4.0
        SoundFile==0.10.3.post1
        soupsieve==2.5
        SpeechRecognition==3.8.1
        threadpoolctl==3.1.0
        tk==0.1.0
        toml==0.10.2
        tomli==2.0.1
        tqdm==4.64.1
        trio==0.22.0
        trio-websocket==0.9.2
        tweepy==4.10.1
        types-toml==0.10.8.1
        typing-extensions==4.7.1
        uritemplate==4.1.1
        urllib3==1.26.12
        watchdog==2.2.1
        webdriver-manager==4.0.1
        Werkzeug==2.2.2
        wordcloud==1.8.2.2
        wrapt==1.15.0
        wsproto==1.2.0
        yarl==1.9.2
        zipp==3.11.0
</details>

## コード
```python
import subprocess
import os
import re
import time
import pkg_resources
import sys
import importlib
import pkgutil

def find_submodules(module_name):
    """
    指定されたモジュール名に対して、そのモジュールが依存するサブモジュールを探します。
    ただし、トップレベルのモジュール名のみを返します。
    """
    submodules = set()
    try:
        package = importlib.import_module(module_name)
        if hasattr(package, '__path__'):  # パッケージの場合、サブモジュールを探す
            for importer, modname, ispkg in pkgutil.walk_packages(package.__path__, package.__name__ + '.'):
                # トップレベルのモジュール名のみを追加
                top_level_module = modname.split('.')[0]
                submodules.add(top_level_module)
    except Exception as e:  # モジュールが見つからない場合など
        print(f"Error finding submodules for {module_name}: {e}")
    return submodules

def get_external_dependencies(module_name):
    """
    指定されたモジュール名に対して、そのモジュールが依存する外部依存関係を探します。
    ただし、トップレベルのモジュール名のみを返します。
    """
    dependencies = set()
    try:
        # pkg_resourcesを使用してパッケージの依存関係を取得
        distribution = pkg_resources.get_distribution(module_name)
        for requirement in distribution.requires():
            # 依存関係のパッケージ名のみを取得
            dependencies.add(requirement.project_name)
    except Exception as e:
        print(f"Error finding dependencies for {module_name}: {e}")
    return dependencies

def get_all_modules(module_name, all_modules=None):
    """
    再帰的にモジュールの依存関係を取得します。
    """
    # 初回のみ実行
    if all_modules is None:
        all_modules = set()

    # すでに追加されているモジュールはスキップ
    if module_name in all_modules:
        return all_modules

    # サブモジュールと外部依存関係を取得
    submodules = find_submodules(module_name)
    external_deps = get_external_dependencies(module_name)
    all_modules.add(module_name)
    all_modules.update(submodules)
    all_modules.update(external_deps)

    # 再帰的に依存関係を探索
    for submodule in submodules:
        get_all_modules(submodule, all_modules)

    return all_modules


def get_installed_modules():
    """
    インストールされているPythonパッケージの名前を小文字でセットとして取得します。
    """
    return {dist.project_name.lower() for dist in pkg_resources.working_set}

def extract_modules_from_file(filepath):
    """
    指定されたPythonファイルから使用されているモジュールの名前を抽出します。
    'from' または 'import' を含む行を見つけ、必要な情報をセットとして取得します。
    """
    modules = set()
    with open(filepath, 'r', encoding='UTF-8') as file:
        for line in file:
            if 'from' in line or 'import' in line:
                parts = line.split()
                if 'from' == parts[0]:
                    modules.add(parts[1].split('.')[0])
                elif 'import' == parts[0]:
                    imported = parts[1].split(',')
                    modules.update(m.split('.')[0] for m in imported)
    return modules


def run_pyinstaller(bat_file_path):
    """
    指定されたbatファイルを実行して、PyInstallerの出力を得ます。

    :param bat_file_path: 実行するbatファイルのパス
    :return: PyInstallerの出力
    """
    try:
        # BATファイルを実行
        # capture_output = Falseにすることで画面上にプロセス出力が表示されるようにする
        result = subprocess.run([bat_file_path], capture_output=False, text=True, check=True)
        return result.stdout
    except subprocess.CalledProcessError as e:
        print(f"Error running pyinstaller: {e.output}")
        return ""

def get_missing_module_and_display_output(exe_path):
    """
    指定されたexeファイルを実行して、コンソールに出力を表示しながら
    'No module named ...' エラーをキャプチャします。

    :param exe_path: 実行するexeファイルのパス
    :return: 見つかった場合はモジュール名、見つからない場合はNone
    """
    try:
        # コンソールに出力を表示しつつ実行
        result = subprocess.run([exe_path], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
        output = result.stdout
        print(output)  # コンソールに出力を表示
    except subprocess.CalledProcessError as e:
        # エラーが発生した場合、その出力をコンソールに表示
        print(e.output)
        output = e.output

    # 'No module named ...' エラーを検索
    match = re.search(r"No module named '(.+?)'", output)
    if match:
        # マッチしたモジュール名を返す
        return match.group(1)

    # エラーが見つからない場合はNoneを返す
    return None

def create_bat_file(py_file_path, exclude_list):
    """
    .pyファイルのパスと除外するモジュールのリストを受け取り、
    pyinstallerを使用して実行可能ファイルを生成するバッチファイルを作成します。
    """
    directory = os.path.dirname(py_file_path)
    filename_without_ext = os.path.splitext(os.path.basename(py_file_path))[0]
    bat_file_path = os.path.join(directory, filename_without_ext + '_build.bat')
    
    with open(bat_file_path, 'w') as bat_file:
        bat_file.write('@echo off\n')
        bat_file.write('rem Change to the script directory\n')
        bat_file.write('cd /d "%~dp0"\n')
        bat_file.write(f'pyinstaller "{filename_without_ext}.py" --onefile ')
        bat_file.write(' '.join(f'--exclude {module}' for module in exclude_list))
        bat_file.write(' --noconfirm\n')  # Don't ask for confirmation on overwrite
        bat_file.write('echo Build completed\n')
        # bat_file.write('pause\n')  # 自動化プロセスに含むため、pauseをコメントアウト

    return bat_file_path

def create_exclude_list(installed, used):
    """
    インストールされているモジュールのセットと使用されているモジュールのセットを比較し、
    使用されていないモジュールのリストを作成します。

    :param installed: インストールされているモジュールのセット
    :param used: 実際に使用されているモジュールのセット
    :return: PyInstaller の --exclude オプションに使用するためのモジュール名のリスト
    """
    # インストールされているモジュールから、実際に使用されているモジュールを除外したリストを作成
    return [module for module in installed if module not in used]

# .pyファイルのパスから予想される実行ファイル(.exe)のパスを生成
def get_expected_exe_path(py_file_path):
    directory = os.path.dirname(py_file_path)
    filename_without_ext = os.path.splitext(os.path.basename(py_file_path))[0]
    expected_exe_path = os.path.join(directory, 'dist', filename_without_ext + '.exe')
    return expected_exe_path

def main():
    dropped_py_file = sys.argv[1] if len(sys.argv) > 1 else None
    # ====================== テスト用のコード ===========================
    # ドラッグアンドドロップしない場合に指定する仮想ファイル
    if dropped_py_file == None:
        dropped_py_file = os.path.join(os.path.dirname(sys.argv[0]), 'test_to_convert.py')
    # ====================== テスト用のコード ===========================
    directory = os.path.dirname(dropped_py_file)
    base_name = os.path.splitext(os.path.basename(dropped_py_file))[0]
    # =============== 初回除外モジュールリストの作成 =====================
    # Extract used modules from the dropped .py file
    used_modules = extract_modules_from_file(dropped_py_file)
    # ドロップされたファイルに含まれるモジュールをリストとして抽出
    print(used_modules)
    # 対象のファイルで利用されているモジュールを一つ一つループ
    all_used_modules = set()
    for module in used_modules:
        # 利用されているモジュールを一つ一つget_all_modulesに渡す
        all_used_modules.update(get_all_modules(module))
    # インストールされているモジュールから利用されているモジュールを省く
    exclude_list = create_exclude_list(get_installed_modules(), all_used_modules)
    # =============== 初回除外モジュールリストの作成 =====================
    
    # =============== ループを実行 =====================
    if dropped_py_file:
        py_file = dropped_py_file

        while True:
            # pyinstaller用のbatファイルを作成
            bat_file_path = create_bat_file(py_file, exclude_list)
            # 対象のbatファイルを起動
            # コンソールを直接画面に出力し、返り値として受け取らない
            run_pyinstaller(bat_file_path)
            # 実際に.exeファイルが存在するかどうかを確認
            expected_exe_path = get_expected_exe_path(dropped_py_file)
            expected_exe_path_exists = os.path.isfile(expected_exe_path)
            if expected_exe_path_exists == True:
                # exeファイル完成後に実行し、実行結果からメッセージ内容を受け取る
                missing_module = get_missing_module_and_display_output(expected_exe_path)
                # エラー表示されたモジュール名を除外して再びpyisnstaller実行>exe実行を繰り返す
                if missing_module:
                    print(f"Missing module found: {missing_module}")
                    exclude_list.remove(missing_module)  # excludeリストからモジュールを削除
                else:
                    print("No missing modules found. Compilation may be successful.")
                    break

                # セーフティブレーク（無限ループを避ける）
                time.sleep(1)  # システムに負荷をかけないように少し待つ
            else:
                print('Exeファイルが見つかりません。batファイル実行結果を確認してください')
                input('終了するには何かのボタンを押してください。')

    print('不要なファイルを削除します')
    # =============== 実行中に生成した一時ファイルの削除 =====================    
    spec_file = os.path.join(directory, base_name + '.spec')
    bat_file = os.path.join(directory, base_name + '_build.bat')
    # .specファイルの削除
    try:
        os.remove(spec_file)
        print(f"Removed file: {spec_file}")
    except OSError as e:
        print(f"Error removing {spec_file}: {e.strerror}")

    # _build.batファイルの削除
    try:
        os.remove(bat_file)
        print(f"Removed file: {bat_file}")
    except OSError as e:
        print(f"Error removing {bat_file}: {e.strerror}")
    # =============== 実行中に生成した一時ファイルの削除 =====================    
    print("PyInstaller finished.")

if __name__ == '__main__':
    main()
```

## コードの流れ
1. 指定したモジュールが使っているモジュールをリストアップ
1. 除外リストを作成（インストールされているモジュール - 1で取得したモジュール）
1. PyInstaller用のBATファイルを作成
1. BATファイルを実行
1. 完成したEXEファイルを実行
1. エラーメッセージ: No module named<モジュール名>が出たらモジュール名を取得
1. 除外リストを更新
1. 3~7の繰り返し

## どう使えばいいのか
先ほどのコードの.pyファイルに、exeファイル化したい.pyファイルをドラッグアンドドロップする。

## 注意点
この方法はエラーを出しながら必要なモジュールを拾うという流れになっている。
よって注意点は下記。
1. 実行ファイルを作りながら進むため、時間がかかる
1. 部分的にコードが実行されるため、DB更新や他環境へのアクセスが発生する環境での実行は非推奨

## 道のり
これより特に意味のない道のり情報。

なぜ2つの注意点が生まれるようなやり方になってしまったのか。
最初に考えたのは再帰的に利用されているモジュールを取得して、除外リストを作る方法であった。
そのためにいくつかのアプローチを仕掛けて最終的にmodulefinderというモジュールに行き当たった。
だが、結局のところ上手くいかなかった。同じく上手くいかなかった人もいたようだ。
https://teratail.com/questions/169586

その他の調査系モジュールも調べてみたが一部取得できないモジュールが存在するなど網羅性に欠ける印象があった。
ゆえに除外リストを足し算していくのではなく引き算するアプローチに切り替えた。

## なぜ作ったのか
3年前くらいにexeファイル化の情報を調べたがそのころからほとんど情報が更新していなかった。
exeファイル化って需要がないんだろうか？
pythonって最近はAIとかで聞くからあんまりこういう使い方しないのかも知れない。
IT素人には結構いいんだけど…。

もっとスマートな方法があるような気もするが見つけられなかった…。