Python+Nimのハイブリッド環境(Nimporter)で、PyInstallerによる実行ファイル(EXE)を作成する


## ずっと気になっていたハイブリッド環境

こちらの[記事](/uesseu/items/477bbabe8ea354cb9d49)にて、nimporterなるクールなライブラリがあると知り、ほんわかな[コメント](/uesseu/items/477bbabe8ea354cb9d49#comment-4601239e890a54de0ccf)をしたものの、ずっと気になっていたので検証してみました。

サンプルソースは[こちら](https://github.com/6in/nimporter-sample)。

PyInstallerの記事もQiita内にたくさんありますので、そちらも参照いただければと。

## 環境

PyInstallerとpyenvやらvirtualenv系とは相性が悪いらしいので、Pythonは仮想環境なしで動作させています。

* Windows 10
* Nim 1.4.2
* Python 3.9
* PyInstaller 4.1
* Nimporter 1.0.2
* Visual Studio Community 2019

### 開発時のトラブルなど

* NimporterがWindows環境だとVCCを使う設定になっているようなので、VisualStudioのCommunity Editionを入れました。
* PyInstallerが起動しないため、PythonインストールフォルダのScriptsフォルダにもパスを通したりしていました。

## まずはNimporterで遊ぶ

今回のサンプルのディレクトリ構成はこちら。

| ファイル・フォルダ | 説明 |
| ---- | ---- |
| └─ nimporter-sample | プロジェクトディレクトリ |
| 　　├─nimutils | nimソース用パッケージ |
| 　　│　├─`__init__.py` | お約束ファイル |
| 　　│　├─ calc.nim | 計算サンプル |
| 　　│　├─ thread_test.nim | スレッドサンプル |
| 　　│　└─ uuid.nim | 別ライブラリ呼び出しサンプル |
| 　　├─ nimporter_sample.py | メインのPythonスクリプト |
| 　　└─ nimporter_sample.spec.sample | PyInstaller用Specファイルのサンプル |

### Pythonからパッケージ内のNimモジュールをインポートする

Pythonファイルと同じディレクトリになくても、パッケージ(フォルダ)内にあるNimのメソッドへのアクセスも、通常のPythonと同じようにできます。

```nim:nimutils/calc.nim
import nimpy
import strformat

proc add(a: int, b: int): int {.exportpy.} =
    echo fmt("{ a + b = }")
    return a + b
```

```python:nimporter_sample.py
import nimporter
from nimutils import calc # nimutils/calc.nimをインポート

# call nim method
print(calc.add(2, 4))  # 6
```


### Nimbleでインストールしたモジュールを利用してみる

あらかじめnimbleコマンドでインストールした`nuuid`というモジュールを、nimソースでインポートし、実行結果をPythonに返すということもできます。


```shell
# モジュールインストール
$ nimble install nuuid
```

```nim:nimutils/uuid.nim
import nimpy
import strformat
import nuuid      # import uuid library 

proc generate(): string {.exportpy.} =
    return generateUUID()
```

```python:nimporter_sample.py
import nimporter
from nimutils import uuid

print(uuid.generate())
```

### PythonからNimのマルチスレッドを実行してみる

こちらも問題なく動作しました。

```nim:nimutils/calc.nim
import nimpy
import strformat
import os

proc threadFunc(param: tuple[a, b: int]) {.thread.} = 
    echo fmt("This is Thread-{param.a}")

proc threadTest(): int {.exportpy.} =
    var thr: array[0..1, Thread[tuple[a, b: int]]]
    echo "start threads"
    defer:
        echo "wait threads"
    thr[0].createThread(threadFunc, (1, 1000))
    thr[1].createThread(threadFunc, (2, 1000))
    sleep(1000)
    joinThreads(thr)
```

```python:nimporter_sample.py
from nimutils import thread_test

# スレッド生成＆実行しているメソッドを呼び出す
thread_test.threadTest()
```

ただし、**Pythonのスレッド**からNimのメソッドは呼べないようです。(Issueは[こちら](https://github.com/Pebaz/nimporter/issues/19))
そのため、現在のところNimporterを利用する場面においては、Pythonのメインスレッドからしか呼べないようです。
Webフレームワークでリクエストハンドラの中からNimのモジュールを呼ぶっていうことはできないみたいですね。

## PyInstallerによるシングルExeファイルの作成

上記３パターンの呼び出しを行ったPythonファイルを、PyinstallerにてExe化し、別のWindows10環境でも動作することを確認します。

１発でExeが出来上がらないので、以下の手順で作成していきます。

1. Pyinstallerを起動し、Specファイルを作成
2. Pydファイル情報をSpecファイルに追加する
3. PyinstallerをSpecファイルで起動し、Exeファイルを作成
4. エラーが出たら足りないモジュールをSpecファイルに追加

以下、Exe起動時にエラーが出なくなるまで、3,4を繰り返します。

### Specファイルの作成

シンプルな構成のPythonスクリプトであれば、Pyinstallerで１発でEXEファイルができるかもしれませんが、Pyinstallerを起動して生成されるSpecファイルを適切に修正して、Exeを作る環境を整えていきます。

まずは、エントリポイントとなるPythonスクリプトを指定して、PyInstallerを実行すると、スクリプトと同じフォルダにspecファイルが生成され、distフォルダにもExeファイルが出来上がります。
ただし、出来上がったExeファイルを起動してもエラーが出て終了してしまうため、生成されたSpecファイルに足りないモジュールなどを記述していきます。

```shell
$ pyinstaller nimporter_sample.py --onefile
・・・

$ dir dist
2021/01/04  19:36    <DIR>          .
2021/01/04  19:36    <DIR>          ..
2021/01/04  19:36         7,725,805 nimporter_sample.exe
               1 File(s)      7,725,805 bytes
               2 Dir(s)  232,853,856,256 bytes free

$ dist\nimporter_sample.exe

# 起動するとエラーが発生してしまう
Traceback (most recent call last):
  File "nimporter_sample.py", line 1, in <module>
    import nimporter
  File "c:\apps\python\python39\lib\site-packages\PyInstaller\loader\pyimod03_importers.py", line 493, in exec_module
    exec(bytecode, module.__dict__)
  File "nimporter.py", line 13, in <module>
  File "c:\apps\python\python39\lib\site-packages\PyInstaller\loader\pyimod03_importers.py", line 493, in exec_module
    exec(bytecode, module.__dict__)
  File "setuptools\__init__.py", line 24, in <module>
  File "c:\apps\python\python39\lib\site-packages\PyInstaller\loader\pyimod03_importers.py", line 493, in exec_module
    exec(bytecode, module.__dict__)
  File "setuptools\depends.py", line 6, in <module>
ModuleNotFoundError: No module named 'setuptools.py33compat'
[431764] Failed to execute script nimporter_sample
```

### PydファイルをSpecのbinariesに追加する

Nimporterが生成するのは、Python拡張モジュール(拡張子がpyd)なので、これをPyinstallerがExeモジュールに含めるように修正します。
Specファイルは、実際はPythonスクリプトであるため、Pythonのコードを記述して設定ファイルを修正することができます。

今回のサンプルでは、`nimutils` フォルダ内にNimファイルを配置したので、`nimutils\__pycache__` フォルダに`pyd`が出力されたので、Specファイルを以下のように修正します。

```python:nimporter_sample.spec
# -*- mode: python ; coding: utf-8 -*-
import os

curDir = os.getcwd()
cacheDir = os.path.join(curDir, 'nimutils', '__pycache__')
pydDir = os.path.join('.', 'nimutils')
block_cipher = None

a = Analysis(['nimporter_sample.py'],
             pathex=[curDir],
             # nimporter が生成したpydファイルをバイナリとして追加する
             binaries=[
               (os.path.join(cacheDir, 'calc.pyd'), pydDir),
               (os.path.join(cacheDir, 'thread_test.pyd'), pydDir),
               (os.path.join(cacheDir, 'uuid.pyd'), pydDir),
             ],
```

バイナリファイルの設定は複数指定でき、ファイル毎にタプルで指定`(Pydファイルの場所, EXE起動時にどこに配置するか)`します。
今回の例だと、`nimutils/__pycache__/`に入っているpydファイルを、Exe起動時にはnimutilsフォルダに展開せよという指定です。
展開後も`__pycache__`フォルダなのでは？と思ったのですが、Exe実行時は`__pycache__`フォルダに`pyd`があったとしてもそちらからは読み込まないようです。

### PyinstallerをSpecファイルで起動し、Exeファイルを作成

PyInstaller の引数に、Specファイルを指定して実行します。

```
# Specファイルを指定して実行
$ pyinstaller nimporter_sample.spec

# distにできたExeを起動する
$ dist\nimporter_sample.exe
```

### エラーが出たら足りないモジュールをSpecファイルに追加

PyInstallerで作成したExeを起動した際に、以下のようなエラーが出た場合、PyInstallerのSpecファイルの`hiddenimports`に、モジュール名を追加することで、エラーが解消されます。

```shell

ModuleNotFoundError: No module named 'setuptools.py33compat'
```

何度か繰り返し、２つのモジュールをhiddenimportsに追加することでエラーが出なくなりました。

```python:nimporter_sample.spec
  hiddenimports=['setuptools.py33compat','setuptools.py27compat'],
```

### 実行の様子

雑なキャプチャですが、ご参考まで。

![pyinstaller.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58872/f3524d73-e18b-d1a1-303a-dc6d9c2893ee.gif)

## まとめ

Nimporterを利用したPythonスクリプトをPyInstallerでExe化する手順を紹介しました。
ポイントとしては、Nimporterが生成したPydファイルをバイナリとして含めてあげることでしたね。

PythonのマルチスレッドからはNimporterで作成したモジュールは呼べない制限があるものの、Pythonの魅力的なライブラリを利用し、高速処理させたい部分をNimで作成するなど、ちょっとしたユーティリティをExeとして配布できるのは魅力的な環境ではないでしょうか。

とはいえ、本番で利用できる技術ではなく、ホビーユース(趣味的)な利用に限られるとは思いますが。


## 参考にしたサイト

https://qiita.com/rebellious-wimp/items/61f16389f957b2ace163