わくわく計算ライフ

ゲーマーによる節約生活。はじまります。

【Python】ModuleNotFoundErrorとお付き合いする

Pythonでそこそこの規模のスクリプトを書いているとブチ当たる憎いやつ。
それがModuleNotFoundError
ウチでの環境の話にはなりますが、そこそこ良い対処法が出来たので紹介しておこうと思います。
環境が似てる人には参考になると思います。

サンプルはここに置いておきます。

1. そもそもModuleNotFoundErrorって

Pythonでモジュールをimportした時に発生するエラーで、読み込もうとしているPythonスクリプトのPathが見つからないことで発生します。

2. 良くあるエラー発生パターン

良くハマったパターンが以下。 前提としてはエディタはVSCodeを使用し、workspaceがVSCodeで開いているワークスペースである時を考えてください。
(ほかのワークスペースを使うIDEなどでも同様に考えられると思います)

ハマりパターン

ここでmodAはmodBの機能を利用しているケースを考えます。
ものすごい絞った具体例を以下に挙げます。

2.1. モジュールの動作確認

modA.py

from modB import *

def modA_print():
    print("これはmodule A")
    modB_print()

if __name__ == '__main__':
    modA_print()

modB.py

def modB_print():
    print("これはmodule B")

if __name__ == '__main__':
    modB_print()

普通に開発すると、まずmodB.pyの動作を確認⇒modBをimportしているmodA.pyと作業をすると思います。
で、上記は問題なく動作すると思います。

2.2. アプリからの利用

VSCodeの環境では以下のコードは、エディタ上でも警告が表示されません。

from lib.modA import modA_print

modA_print()

が、実行するとmodA.pyの先頭でmodBをimportするところでModuleNotFoundErrorが出ます。

3. エラー原因

先ほどのfrom modB import *ですが2.1ではエラーが出ていないのに2.2ではエラーが出ていない問題ですが。
「明示的にlibにpathが通っていないのでmodBがimport出来ない」というのが本質的なところです。
逆に言うと2.1でエラーが出ない点に注目する必要があります。
Pythonでスクリプトを指定して実行した場合(python xxxx.pyなど)は、実行したスクリプトが置いてある場所がPythonモジュールのサーチパスに含まれます。
よって、
2.1のケースではmodA.pyを実行しているので、modA.pyが置かれているlibがサーチパスに加えられるので、lib以下のmodB.pyが見える。 一方で、
2.2のケースではuserApp.pyを実行しているので、userApp.pyが置かれているworkspaceがサーチパスに加えられるので、lib以下のmodB.pyはpathが通っていない。

これが問題です。
解決の方法はlibにpathを通してやることです。

補足ですが、userApp.py実行時は、workspaceにはpathが通っているので、modA.pyのimportを

from lib.modB import *

とすれば通るのですが、こうするとmodA.pyを実行した際にはworkspaceにパスが通ってない故にlib.modBが見つからないと怒られるのでオススメ出来ないです。
というか、実行するPythonスクリプトの配置によって書き方が変わってしまうので良くないです。

4. pathの通し方あれこれ

使いたいモジュールのディレクトリにpathを通してやれば良いということで既存の方法をいくつか紹介します。

4.1. 環境変数PYTHONPATHにパスを追加する

ストロングスタイル。
PythonはPYTHONPATHに設定したpathもサーチパスに加える。

[長所]
* とにかく簡単

[短所]
* 開発をするたびに環境変数PYTHONPATHが長くなる * PYTHONPATHとは言えユーザの環境変数を汚染する

PYTHONPATHには基本的には絶対pathを記述するが、相対pathも記述可能。
その場合実行したPythonスクリプトからの相対pathがサーチパスに加わる点に注意する。
なので実行毎に変わるんじゃよ...。

pathさえ通ってしまえば、userApp.py

from modA import modA_print

modA_print()

と書き換えられ、userApp.pyの配置変更にも耐えられるようになるので、pathを通すこと自体は正義。
いかに環境に負荷を掛けずにpathを通すかという話になる。

4.2. .envファイルにPYTHONPATHを記述する

VSCode等のIDEではプロジェクト直下の.envファイルを読み込んで環境変数の設定を反映してくれるものも多い。
pipenvを使っている場合は仮想環境実行時に.envを読み込んで環境変数の設定を反映してくれるのでこれとの相性も良い。

[長所]
* IDEや仮想環境と相性が良い
* PYTHONPATHの汚染が限定的
IDEのプロジェクト内や、Python仮想環境内に汚染が限定される。
直接環境のPYTHONPATHを書くより大分まし。

[短所]
* ユーザのプロジェクト配置場所に応じて.envを書き換える手間は残る

4.3. いっそインストールしてしまう

からあげさんのところで紹介されていた手法。
元記事のでもメリットの1つとして挙げられているが結構スマートな方法と思う。

zenn.dev

[長所]
* インストールしてしまえば他のパッケージの様に使用可能
* 汚染範囲がPythonに限定される
仮想環境で使用すればほぼ問題が無くなる。

[短所]
* 個別にsetup.pyを書く必要がある * 不要になったらアンインストールする必要がある
仮想環境ごと削除する分には忘れていても良い。

4.4. sys.path を使う

Pythonのスクリプト側にimportするパスの設定を書いてしまおうというもの。
sys.pathはリストになっているためappend()で任意のパスを追加するサンプルがWeb上でも見受けられる。

[利点]
* Scriptの配置を固定すれば相対パスでも書ける
配布しやすくなる。 * 環境を全く汚さない
Python実行中にのみ有効

[欠点]
* 書く場所が多い
前述の例だと、userApp.pyの様なファイル毎にすべて記入が必要。
* プロジェクト内の構成変更に弱い
ディレクトリ構成を変更した場合。sys.path.append()等を書いている箇所を全体的に直す必要がある。

という感じで一長一短。
さらにこの手法をとっているスクリプトをimportした場合に、延々とsys.pathが増えそうなのも個人的にはちょっと嫌。

5. .envファイルを生成する

現在うちの環境では VSCode + pipenv + .envで運用しています。
配布を考慮して.envを生成することにします。

import sys
import os 

# pythonpath.confのあるディレクトリをhomeとする
path_conf = sys.argv[1]
env_home = os.path.abspath(os.path.dirname(path_conf))

# 書き込みたい相対pathの一覧を取得
path_list = []
with open(path_conf) as f:
    for line in f.readlines():
        path_list.append(os.path.join(env_home, line.rstrip()))

# .envファイルを作成
env_path = os.path.join(env_home, '.env')
with open(env_path, 'w') as f:
    paths = ';'.join(path_list)
    f.write(f'PYTHONPATH={paths}\n')

PYTHONPATHに加えたいpathをプロジェクトルートからの相対パスで1行ごとに書いたファイルを用意し、上記スクリプトに読み込ませます。
するとプロジェクトに合わせた絶対pathに変換した.envファイルが作成されます。

6. おまけ(activate.ps1の改修)

VSCodeではpowershellをターミナルで開いた際に.venv/Scripts/activate.ps1を実行します。
pipenv shellの実行ではなくこれによっても仮想環境のPythonが使えるようになりますが、.envファイルの読み込みは行ってくれません。
5の作業に加えてactivate.ps1.envも反映するように書き加えると捗ります。

$script:THIS_PATH = $myinvocation.mycommand.path
$script:BASE_DIR = Split-Path (Resolve-Path "$THIS_PATH/..") -Parent

function global:deactivate([switch] $NonDestructive) {
    if (Test-Path variable:_OLD_VIRTUAL_PATH) {
        $env:PATH = $variable:_OLD_VIRTUAL_PATH
        Remove-Variable "_OLD_VIRTUAL_PATH" -Scope global
    }

    # Restore Python Path
    if (Test-Path variable:_OLD_PYTHONPATH) {
        $env:PATH = $variable:_OLD_PYTHONPATH
        Remove-Variable "_OLD_PYTHONPATH" -Scope global
    }

    if (Test-Path function:_old_virtual_prompt) {
        $function:prompt = $function:_old_virtual_prompt
        Remove-Item function:\_old_virtual_prompt
    }

    if ($env:VIRTUAL_ENV) {
        Remove-Item env:VIRTUAL_ENV -ErrorAction SilentlyContinue
    }

    if (!$NonDestructive) {
        # Self destruct!
        Remove-Item function:deactivate
        Remove-Item function:pydoc
    }
}

function global:pydoc {
    python -m pydoc $args
}

# unset irrelevant variables
deactivate -nondestructive

$VIRTUAL_ENV = $BASE_DIR
$env:VIRTUAL_ENV = $VIRTUAL_ENV

New-Variable -Scope global -Name _OLD_VIRTUAL_PATH -Value $env:PATH

$env:PATH = "$env:VIRTUAL_ENV/Scripts;" + $env:PATH
if (!$env:VIRTUAL_ENV_DISABLE_PROMPT) {
    function global:_old_virtual_prompt {
        ""
    }
    $function:_old_virtual_prompt = $function:prompt

    if ("pytest_practice" -ne "") {
        function global:prompt {
            # Add the custom prefix to the existing prompt
            $previous_prompt_value = & $function:_old_virtual_prompt
            ("(pytest_practice) " + $previous_prompt_value)
        }
    }
    else {
        function global:prompt {
            # Add a prefix to the current prompt, but don't discard it.
            $previous_prompt_value = & $function:_old_virtual_prompt
            $new_prompt_value = "($( Split-Path $env:VIRTUAL_ENV -Leaf )) "
            ($new_prompt_value + $previous_prompt_value)
        }
    }
}

# import PYTHONPATH from .env file
New-Variable -Scope global -Name _OLD_PYTHON -Value $env:PYTHONPATH
$script:PROJ_DIR = Split-Path (Resolve-Path "$BASE_DIR") -Parent
$script:ENV_FILE = Get-Content ${script:PROJ_DIR}/.env

foreach ($line in $script:ENV_FILE) {
    $tokens = $line.Split("=")
    if($tokens[0] -eq "PYTHONPATH") {
        if ($env:PYTHONPATH.Length -gt 0) {
            $newPath = $env:PYTHONPATH, $tokens[1]
            $env:PYTHONPATH=$newPath -Join ";"
        }
        else {
            $env:PYTHONPATH=$tokens[1]
        }
    }
}