ローカルファイルを使ったライブラリインストールでハマったお話

# きっかけ
ローカルにダウンロードしたライブラリのインストールのお話です。
訳あってインターネット接続の無いパソコンへのライブラリのインストールをしなきゃいけない。ま、いろいろありますわな。セキュリティーポリシーとかね。
それも複数台のパソコンに一気にインストールしたい。ああ、インターネットが欲しい。w

そんな時に、ライブラリのインストールを自動化しようとしてハマったお話。

# おさらい：ローカルファイルからのインストール方法
欲しいライブラリはPypi.orgから探してきます。バージョンはよく気を付けて。
ほんで、ターミナルで以下を実行するとインストールできます。
```python
pip install --no-index --find-links=ライブラリを入れたフォルダ名 ライブラリ名
```

# 環境
今回は訳あってWindows11（久々に使った）
Python3.10.8


# ライブラリの依存関係
ライブラリには依存関係があります。（リンク参照）  
Aと言うライブラリをインストールするためにはBとCのライブラリがインストールされてないとだめよ～とか、そんな関係があります。詳しい事は下記を見て頂ければいいんですが、わかんなかったら依存関係があるんだなというコトだけ知っておいてください。というか、僕も説明できるほど知らない。


https://dev.classmethod.jp/articles/python-pipdeptree_licenses/


# 実施したこと
ライブラリの依存関係を考慮した順番に並べたリストを作っておいて、for分で回してインストールしてたんですね。
subprocessはコマンドラインで実行するコマンドをpythonを使って実行する方法。
```python
import subprocess
command = 'pip install --no-index --find-links=ライブラリを入れたフォルダ名 ライブラリ名'
result = subprocess.run(command, shell=True, capture_output=True, text=True)
```

でもね、**どうしてもインストールできないライブラリ**がありやがる！怒

ちなみにコメントは以下でもOK
`command = f'python -m pip install {lib_folder}{element}'`
`{lib_folder}{element}`のところはダウンロードしたライブラリのインストールファイルへのパスを入れてちょ。

# 念の為、cmdから実行してみる
インストールできなかったライブラリをコマンドラインから実行。
**できるじゃん！**
```
tar -xzvf ライブラリ名
cd tar.gzファイルを解凍したのフォルダ
python setup.py install
```
解説すると
1行目：tar.gzファイルを解凍
2行目：解凍したフォルダに移動
3行目：このフォルダの`setup.py`に引数`install`を渡して実行


# 参考：pythonでもtar.gzファイルを解凍する方法
これ、tarっていうアーカイブファイルをgzという圧縮ファイルに変換したもの。
Pythonの標準ライブラリ`tarfile`で解凍できます。

```python
with tarfile.open(tar.gzファイルのパス, 'r:gz')as tr:
    tr.extractall(path=解凍するフォルダパス)
```



# ここまで来て・・・
なんか、もやもやしません？
ちょっと僕はしたんですよ。モヤモヤ
そこで、インストールできたtar.gzファイルを解凍した後のsetup.pyを見比べてみました。

- インストールできた`setup.py`
```python
import os
import re
from setuptools import setup

scriptFolder = os.path.dirname(os.path.realpath(__file__))
os.chdir(scriptFolder)
```

- インストールできなかった`setup.py`
```python
import re
from setuptools import setup, find_packages
```

なるほど

`os.chdir(scriptFolder)`って、ディレクトリの移動してるよね？

ということで、以下の通りにしよう
- whlファイル
先ほど試したコメントでインストール

- tar.gzファイル
解凍してから、解凍したフォルダに移動
`setup.py`に引数`install’を渡して実行

# プログラムを作る前に・・・ご利用は計画的に（愛があるんやでぇ〜のCM風）
- プログラムの構造を考える
1. ライブラリ名をインストール順に並べたリストを作る
1. 拡張子が`.whl`だったらinstall_whl関数にファイルパスを入れてインストールする、拡張子が`.tar.gz`だったらinstall_targz関数に入れる
1. 正確にインストールできたかどうかを確認したいので、pip listの出力にライブラリ名が入っていたらインストールできたと判断できるget_result関数を作って判定したい
1. インストールできたファイル、できなかったファイルをリストに入れて最後に確認したい。

こんな感じで行こう

# ファイル名をどうやって関数に渡すか。
つまり、「１．ライブラリ名をインストール順に並べたリストを作る」の部分
csvに入れちゃおう。こんな感じ
これを`install_order.csv`として保存しておこう。
あ、1行目は文字コードでえらいことに会うことがあるので、何か文字を入れておいてください。
そう、BOM付きとかshift-jisとかああいう難しいやつ。w
ちゃんとutf-8で保存しても訳わからん状態になる。<- きっと勉強不足

|ライブラリファイル名|
| ---- |
| numpy-1.25.2-cp310-cp310-win_amd64.whl |
| pillow-10.2.0-cp310-cp310-win_amd64.whl |
| numpy-1.25.2-cp310-cp310-win_amd64.whl |
| ・・・ |
| PyMsgBox-1.0.9.tar.gz |

これで上から順にインストールだな！


# まず、whlファイルをインストールする関数を作ろう
「２．拡張子が.whlだったらinstall_whl関数にファイルパスを入れてインストールする、拡張子が.tar.gzだったらinstall_targz関数に入れる」のwhlファイルの部分。

**できたw**
引数の意味はにコメントに書いといた。
```python
# install whl file
def install_whl(lib_folder, element):
    """
    lib_folder: ライブラリを保存しているフォルダ
    element: インストールしたいライブラリのファイル名
    """
    # subprocessを使ってコマンドを実行（あ、pipで書いちゃった。まいっか）
    command = f'python -m pip install {lib_folder}{element}'
    print(command)
    result = subprocess.run(command, shell=True, capture_output=True, text=True)

    # 結果の取得（あ、いらんか）
    outputs, errors = result.stdout, result.stderr
    try:
        outputs, error_comment = outputs.split('\n'), errors.split('\n')
    except:
        outputs, error_comment = outputs, errors

    lib_name = element.split('-')[0]
    return get_result(lib_name)　
```
最後のgyouno`get_result()`の関数はインストールできたかどうか判別させる関数のつもり。（後で作る）

# じゃ、今度はtar.gzファイルをインストールする関数を作ろう
```python
def install_targz(lib_folder, element):
    """
    lib_folder: ライブラリを保存しているフォルダ
    element: インストールしたいライブラリのファイル名
    tar.gzファイルは解凍したフォルダに移動してからpython setup.py installを実行する。
    そのためフォルダ移動を伴う
    """
    # tar.gzの解凍
    with tarfile.open({lib_folder}{element}, 'r:gz')as tr:
    tr.extractall(path=lib_folder)

    targz = element.replace('.tar.gz', '') # tar.gzを解凍したフォルダ名
    lib_name = element.split('-')[0] # バージョン名の無いライブラリ名（_や-で繋がれている）

    # フォルダの移動
    scriptFolder = os.path.dirname(os.path.realpath(__file__))
    os.chdir(scriptFolder + lib_folder + targz + '/')

    # インストール 
    command2 = 'python setup.py install'
    # command2 = f'python {lib_folder}{targz}/setup.py install'
    print(command2)
    result2 = subprocess.run(command2, shell=True, capture_output=True, text=True)
    # 結果の取得（やっぱいらんな）
    outputs2, errors2 = result2.stdout, result2.stderr
    try:
        outputs2, error_comment2 = outputs2.split('\n'), errors2.split('\n')
    except:
        outputs2, error_comment = outputs2, errors2

    # この自動インストールファイルのフォルダに戻ってくる
    os.chdir(scriptFolder)
    
    return get_result(lib_name)
```
やっぱちょっと長くなるね。w
whl向けの関数もtar.gz向けの関数も結果の取得はいらんねぇ。（自分の備忘録ののために残しとく）

# インストールした後にインストールできたかどうか判別する関数を作ろう
原始的にpip listを実行して、インストールしたライブラリ名があるかどうかで判断。
ただし、ライブラリ名に-や_を使っていたり、それがライブラリ名とpip listで帰ってくる出力と一致しなかったりするので、全部バラバラにして、一致回数を数える方法にしました。

```python
def get_result(lib_name):
    # whlファイル、tar.gzファイル名から-_でスプリットして単語にしちゃう      A
    lib_name_list = re.split('_|-| ', lib_name) 
    
    # pip listを実行して返り値を取得して改行でスプリットする
    command = 'pip list'
    result = subprocess.run(command, shell=True, capture_output=True, text=True)
    outputs = result.stdout
    outputs = outputs.split('\n')

    # pip listの出力値を1行ごとにみていく
    for output in outputs:
        result_flag = 0 # ライブラリ名との一致回数を記録するフラグ
        
        # 毎行、スペースでスプリットして最初の部分（ここにライブラリ名が入るはず）
        output_1_line_list = output.split(' ')[0]
        # pip listの出力をファイル名と同じく、_-で分割      B
        output_1_line_list = re.split('_|-| ', output_1_line_list)
        
        # 上のA（whl、tar.gzファイルから抽出した単語）とB（pip listの出力から抽出した単語を）を比較する
        for name in lib_name_list:
            if name in output_1_line_list:
                # print('name', name)
                result_flag += 1
            else:
                pass
        # AとBの単語の一致回数が、lib_name_listの単語の数と一致したらインストールできたと判断する
        if result_flag == len(lib_name_list):
            return True
    # インストールできなかった時の処理
    if result_flag == 0:
        return False
    else:
        return None
```
案外、長くなってきたな。w
しかもごちゃごちゃしてウザい。この関数はもっといい方法がありそう。


# じゃ、上の関数を実行するためのプログラムを作っていこう
細かいことはコメント見て！（ごめん）
難しかったのは正規表現と、whl / tar.gzファイルとpip listした時の出力の一致点探し。
たとえば、**nest-asyncio**の場合。
tar.gzファイル名：nest_asyncio-1.6.0.tar.gz
pip listの出力：nest-asyncio      1.6.0
nestとasyncioの間が-だったり_だったり。
tar.gz、whlファイル共にファイル名のルールが「ライブラリ名」＋「-」＋「バージョン」＋「拡張子」
だという共通点を探したりってとこあたり。

```python
# ライブラリを保管したフォルダ
lib_folder = './libs/'

# インストール順を記載したファイルの読み取り
with open('./install_order.csv', 'r', encoding='utf-8') as f:
    libraries = f.readlines()

# 結果の保存先
installed_list = []
uninstalled_list = []
unexpected_list = []

# 目安のイテレーター
iter = 0

for j, library in enumerate(libraries):
    # 1行目は飛ばす！ｃｓｖの1行目は適当な文字をわざと入れてあるので。
    if j !=0:
        result = None

        elements = library.split(',')
        element = elements[0]
        element = element.replace('\n', '')

        ################################
        ##### 進行がわかるための表示 #######
        ################################

        print('*'*40)
        print('lib_name: ', element)

        iter += 1
        print('iter: ', iter)
        print(f'check char |{lib_folder}|, |{element}|')

        
        ################################
        #####      実行部分       #######
        ################################
        if '.whl' in element:
            result = install_whl(lib_folder, element)
        elif '.tar.gz' in element:
            result = install_targz(lib_folder, element)
        else:
            unexpected_list.append(element)
        print(element, result)

        

        ################################
        #####     結果を保存       #######
        ################################
        if result:
            installed_list.append(element)
        elif result == False:
            uninstalled_list.append(element)
        else:
            pass
        print('num installed: ', len(installed_list))


################################
#####    最終結果の表示    #######
################################
print('*'*40)
print('result')
print('installed_list: ', installed_list)
print('uninstalled_list: ', uninstalled_list)
print('unexpected_list: ', unexpected_list)
print('*'*40)

if iter == len(installed_list):
    print('install was finished')
elif (iter == len(unexpected_list) !=0) | (len(uninstalled_list) != 0):
    print(f'All libraries were not finished. Uninstalled libraries were {len(uninstalled_list)}.')
else:
    pass
```

# 終わりに
実はろくに調べることをせず、プログラムを書いてしまった。（反省）
pyinstalerを使って実行ファイルで配布しても良かったなとか、色々思うところはあります。

実行に移す前によく考えましょう！www