🦔

【python】sys.pathとsys.modulesをごにょごにょして絶対インポートを解決する

2024/08/23に公開

はじめに

業務でpythonを利用しています。
今回、特殊な要件があり相対インポートが利用できない中で開発をしないといけなくなりました。
もちろんテストも必要ですのでpytestを利用してテストを書いたところ絶対インポートが解決できず、解決はしたもののしばらく時間を溶かしてしまいました。

そこで、自分の備忘録も兼ねて解決方法をまとめました。みなさんの参考になれば幸いです。

注意点

  • 相対インポートを利用すれば今回の問題はすべて解決します。(その解法は最後に説明します。)
  • 諸事情により相対インポートを使えない場合の対応策、sys.path, sys.modulesを理解してインポートを魔解決したい方向けです

今回の設定

今回のフォルダ構成は以下の通りです。
複雑な見た目をしていますが、今回の説明のためには必要でしたので、申し訳ありませんが我慢してください。それぞれのコードは共通部分が多く、本質的な箇所のみを抽出しているので、見た目よりは理解しやすいはずです。

root/
│
├── app/
│   └── functions/
│       │── supermodule_x/
│       │   ├── module_a/
│       │   │   ├── main_a.py      # main_x_a
│       │   │   └── submodule/
│       │   │       └── sub_a.py   # sub_x_a
│       │   └── module_b/
│       │       ├── main_b.py      # main_x_b
│       │       └── submodule/
│       │           └── sub_b.py   # sub_x_b
│       └── supermodule_y/
│           ├── module_a/
│           │   ├── main_a.py      # main_y_a
│           │   └── submodule/
│           │       └── sub_a.py   # sub_y_a
│           └── module_b/
│               ├── main_b.py      # main_y_b
│               └── submodule/
│                   └── sub_b.py   # sub_y_b
└── tests/
    ├── __init__.py              
    └── app/
        ├── __init__.py
        └── functions/
            ├── __init__.py
            ├── supermodule_x/
            │   ├── __init__.py
            │   ├── module_a/
            │   │   ├── __init__.py
            │   │   └── test_x_main_a.py # main_x_a のテスト
            │   └── module_b/
            │       ├── __init__.py
            │       └── test_x_main_b.py # main_x_b のテスト
            └── supermodule_y/
                ├── __init__.py
                ├── module_a/
                │   ├── __init__.py
                │   └── test_y_main_a.py # main_y_a のテスト
                └── module_b/
                    ├── __init__.py
                    └── test_y_main_b.py # main_y_b のテスト

ファイルの中身は以下の通りです。分かりやすさのために、main,sub,testではそれぞれ差分以外はすべて共通です。

まずmainですが、main_aとmain_bの差異はインポートするサブモジュールです。main_a.pySubAを、main_b.pySubBをインポートします。

supermodule_x/main_a.py, supermodule_y/main_a.py
from submodule.sub_a import SubA


def main():
    return SubA().run()


if __name__ == "__main__":
    main()
supermodule_x/main_b.py, supermodule_y/main_b.py
from submodule.sub_b import SubB


def main():
    return SubB().run()


if __name__ == "__main__":
    main()

次にsubですが、supermoduleとmoduleの組み合わせでrunがreturnする文字列が変化します。
具体的には i am supermodule [supermodule] module [module] の文字列を返します。

supermodule_x/.../sub_a.py
class SubA:
    def run(self):
        return "i am supermodule x module a"
supermodule_x/.../sub_b.py
class SubB:
    def run(self):
        return "i am supermodule x module b"
supermodule_y/.../sub_a.py
class SubA:
    def run(self):
        return "i am supermodule y module a"
supermodule_y/.../sub_b.py
class SubB:
    def run(self):
        return "i am supermodule y module b"

最後にtestです。mainの出力が期待通りのものかをassertで比較します。そのため、テストの対象のmain関数をそれぞれインポートしています。テストにはpytestを利用します。

test_x_main_a.py
from app.function.supermodule_x.module_a.main_a import main


class TestXMainA:
    def test_main(self):
        assert main() == "i am supermodule x module a"
test_x_main_b.py
from app.function.supermodule_x.module_b.main_b import main


class TestXMainB:
    def test_main(self):
        assert main() == "i am supermodule x module b"
test_y_main_a.py
from app.function.supermodule_y.module_a.main_a import main


class TestYMainA:
    def test_main(self):
        assert main() == "i am supermodule y module a"
test_y_main_b.py
from app.function.supermodule_y.module_b.main_b import main


class TestYMainB:
    def test_main(self):
        assert main() == "i am supermodule y module b"

対象のファイルが多くて大変ですが、それぞれのファイルがやっていること自体はシンプルだと思います。

ではテストの前に、想定される動作について簡単に示しておきます。
例えばapp/functions/supermodule_x/module_a/main_a.pyを実行してみましょう。当然"i am supermodule x module a"が出力されます。これは他のmainでも期待する値が出力されます。

$ cd ./app/functions/supermodule_x/module_a/
$ python main_a.py
i am supermodule x module a

では、pytestを利用して ./tests/ 以下のすべてのテストファイルに対してテストを実行します。

$ cd root/
$ pytest ./tests/

結果は4つのテストですべて失敗します。全文は長いのでtests/unit/app/function/supermodule_x/module_a/test_x_main_a.pyの結果を一部抜粋します。

============================================================================= test session starts ==============================================================================
platform win32 -- Python 3.12.1, pytest-8.3.2, pluggy-1.5.0
rootdir: ()
plugins: mock-3.14.0
collected 0 items / 4 errors

==================================================================================== ERRORS ==================================================================================== 
________________________________________________ ERROR collecting tests/unit/app/function/supermodule_x/module_a/test_x_main_a.py ________________________________________________ 
ImportError while importing test module '(略)\tests\unit\app\function\supermodule_x\module_a\test_x_main_a.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
..\..\..\AppData\Local\Programs\Python\Python312\Lib\importlib\__init__.py:90: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
tests\unit\app\function\supermodule_x\module_a\test_x_main_a.py:1: in <module>
    from app.function.module_a.main_a import main
app\function\module_a\main_a.py:1: in <module>
    from submodule.sub_a import SubA
E   ModuleNotFoundError: No module named 'submodule'

さらに抜粋すると、最後の2行で「main_a.pyの内部でインポートしているfrom submodule.sub_a import SubAについて、'submodule'が見つからないぞ」と叱られています。

    from submodule.sub_a import SubA
E   ModuleNotFoundError: No module named 'submodule'

そんなことはないはずです、もし本当にsubmoduleが存在しないのであれば、先ほどのpython main_a.pyに失敗しているはずです。これが第一の問題です。

[第一の問題] 消えたsubmoduleを探せ

結論から述べると、pytestを実行した際にsubmoduleが見えていないことが原因です。
そこで、test_x_main_a.pyを用いて、pythonがどのようにモジュールをインポートしているかについて簡単に説明します。

mainをインポートする行より前に以下の3行を追加してみます。このsys.pathは、インポートするモジュールを探す際の開始位置のリストです。

test_x_main_a.py
+import sys
+for i in sys.path:
+    print(i)

from app.function.supermodule_x.module_a.main_a import main


class TestXMainA:
    def test_main(self):
        assert main() == "i am supermodule x module a"

-sオプションをつけてprintが表示されるようにして、pytestを実行します。そうすると、プロジェクトルートやpythonをインポートしている箇所などの複数のパスが表示されていることが分かります。

$ pytest .\tests\unit\app\function\supermodule_x\module_a\test_x_main_a.py -s
============================================================================= test session starts ==============================================================================
platform win32 -- Python 3.12.1, pytest-8.3.2, pluggy-1.5.0
rootdir: C:\...\root
plugins: mock-3.14.0
collecting ...
C:\...\root
C:\...\root\venv\Scripts\pytest.exe
C:\...\Python312\python312.zip
C:\...\Python312\DLLs
C:\...\Python312\Lib
C:\...\Python312
C:\...\root\venv
C:\...\root\venv\Lib\site-packages
collected 0 items / 1 error

==================================================================================== ERRORS ==================================================================================== 
_______________________________________________ ERROR collecting tests/unit/app/function/supermodule_x/module_a/test_x_main_a.py _______________________________________________ 
ImportError while importing test module 'C:\...\root\tests\unit\app\function\supermodule_x\module_a\test_x_main_a.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
C:\...\Python312\Lib\importlib\__init__.py:90: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
tests\unit\app\function\supermodule_x\module_a\test_x_main_a.py:10: in <module>
    from app.function.supermodule_x.module_a.main_a import main
app\function\supermodule_x\module_a\main_a.py:1: in <module>
    from submodule.sub_a import SubA
E   ModuleNotFoundError: No module named 'submodule'
=========================================================================== short test summary info ============================================================================ 
ERROR tests/unit/app/function/supermodule_x/module_a/test_x_main_a.py
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 
=============================================================================== 1 error in 0.09s =============================================================================== 

モジュールをインポートするときには、このリストの先頭から順番にインポート先のパスを探します。例えば、先頭は(略)\rootのため、(略)\root\submoduleを探します。しかし (略)\root以下にsubmoduleというフォルダ(ファイル)は存在しません。そこで次に(略)\root\venv\Scripts\pytest.exe\submoduleを探します。このように先頭から順にリスト内のパス\submoduleを探しますが、最後の(略)\root\venv\Lib\site-packages\submoduleまで行っても見つからないため、「submoduleが見つかりませんでした」という結果を返します。

では、submoduleが見つかるためにはどうしたらよいのでしょうか。答えは、sys.pathの中に(略)\root\app\function\supermodule_x\module_aが存在すればよいのです。そうすれば、(略)\root\app\function\supermodule_x\module_a\submoduleのようにsubmoduleのパスを取得できます。

具体的にはfrom app.function.supermodule_x.module_a.main_a import mainを実行する前にsys.pathに(略)\root\app\function\supermodule_x\module_aを追加します。

パスの追加位置は必ずしもsys.pathの先頭である必要はありませんが、先頭にしない理由もないのでとりあえず先頭に追加しています。またこのパスはインポート後は不要なので削除しておきます。

test_x_main_a.py
+import os
import sys

+module_path = os.path.abspath(f"{os.getcwd()}/app/function/supermodule_x/module_a")
+sys.path.insert(0, module_path)

for i in sys.path:
    print(i)

from app.function.supermodule_x.module_a.main_a import main

+sys.path.remove(module_path)


class TestXMainA:
    def test_main(self):
        assert main() == "i am supermodule x module a"

こうすることで、無事test_x_main_a.pyのテストを単体で実行した場合には成功しました。
また、sys.pathの中身を確認してみると、先頭にC:\...\root\app\function\supermodule_x\module_aが追加されていることが確認できます。

$ pytest .\tests\unit\app\function\supermodule_x\module_a\test_x_main_a.py -s
============================================================================= test session starts ==============================================================================
platform win32 -- Python 3.12.1, pytest-8.3.2, pluggy-1.5.0
rootdir: C:\...\root
plugins: mock-3.14.0
collecting ...
C:\...\root\app\function\supermodule_x\module_a
C:\...\root
C:\...\root\venv\Scripts\pytest.exe
C:\...\Python312\python312.zip
C:\...\Python312\DLLs
C:\...\Python312\Lib
C:\...\Python312
C:\...\root\venv
C:\...\root\venv\Lib\site-packages
collected 1 item                                                                                                                                                                 

tests\unit\app\function\supermodule_x\module_a\test_x_main_a.py .

============================================================================== 1 passed in 0.02s =============================================================================== 

ということで、そのほかのテストファイルについても同様にsys.pathを追加してpytest ./tests/ -sを実行してみます。

test_x_main_a.py
import os
import sys

module_path = os.path.abspath(f"{os.getcwd()}/app/function/supermodule_x/module_a")
sys.path.insert(0, module_path)

-for i in sys.path:
-   print(i)

from app.function.supermodule_x.module_a.main_a import main

sys.path.remove(module_path)


class TestXMainA:
    def test_main(self):
        assert main() == "i am supermodule x module a"

test_x_main_b.py
+import os
+import sys

+module_path = os.path.abspath(f"{os.getcwd()}/app/function/supermodule_x/module_b")
+sys.path.insert(0, module_path)

from app.function.supermodule_x.module_b.main_b import main

+sys.path.remove(module_path)


class TestXMainB:
    def test_main(self):
        assert main() == "i am supermodule x module b"
test_y_main_a.py
+import os
+import sys

+module_path = os.path.abspath(f"{os.getcwd()}/app/function/supermodule_y/module_a")
+sys.path.insert(0, module_path)

from app.function.supermodule_y.module_a.main_a import main

+sys.path.remove(module_path)


class TestYMainA:
    def test_main(self):
        assert main() == "i am supermodule y module a"
test_y_main_b.py
+import os
+import sys

+module_path = os.path.abspath(f"{os.getcwd()}/app/function/supermodule_y/module_b")
+sys.path.insert(0, module_path)

from app.function.supermodule_y.module_b.main_b import main

+sys.path.remove(module_path)


class TestYMainB:
    def test_main(self):
        assert main() == "i am supermodule y module b"

いざ実行してみると、test_x_main_a.pytest_x_main_b.pyには成功しますが、
test_y_main_a.pytest_y_main_b.pyには失敗します。

$ pytest .\tests\                                                            
============================================================================= test session starts ==============================================================================
platform win32 -- Python 3.12.1, pytest-8.3.2, pluggy-1.5.0
rootdir: C:\...\root
plugins: mock-3.14.0
collected 4 items

tests\unit\app\function\supermodule_x\module_a\test_x_main_a.py .                                                                                                         [ 25%] 
tests\unit\app\function\supermodule_x\module_b\test_x_main_b.py .                                                                                                         [ 50%] 
tests\unit\app\function\supermodule_y\module_a\test_y_main_a.py F                                                                                                         [ 75%]
tests\unit\app\function\supermodule_y\module_b\test_y_main_b.py F                                                                                                         [100%]

=================================================================================== FAILURES =================================================================================== 
_____________________________________________________________________________ TestYMainA.test_main ______________________________________________________________________________ 

self = <tests.unit.app.function.supermodule_y.module_a.test_y_main_a.TestYMainA object at 0x0000022F09ACC560>

    def test_main(self):
>       assert main() == "i am supermodule y module a"
E       AssertionError: assert 'i am supermodule x module a' == 'i am supermodule y module a'
E
E         - i am supermodule y module a
E         ?                  ^
E         + i am supermodule x module a
E         ?                  ^

tests\unit\app\function\supermodule_y\module_a\test_y_main_a.py:15: AssertionError
----------------------------------------------------------------------------- Captured stdout call ----------------------------------------------------------------------------- 
_____________________________________________________________________________ TestYMainB.test_main ______________________________________________________________________________ 

self = <tests.unit.app.function.supermodule_y.module_b.test_y_main_b.TestYMainB object at 0x0000022F09B98650>

    def test_main(self):
>       assert main() == "i am supermodule y module b"
E       AssertionError: assert 'i am supermodule x module b' == 'i am supermodule y module b'
E
E         - i am supermodule y module b
E         ?                  ^
E         + i am supermodule x module b
E         ?                  ^

tests\unit\app\function\supermodule_y\module_b\test_y_main_b.py:14: AssertionError
----------------------------------------------------------------------------- Captured stdout call ----------------------------------------------------------------------------- 
=========================================================================== short test summary info ============================================================================ 
FAILED tests/unit/app/function/supermodule_y/module_a/test_y_main_a.py::TestYMainA::test_main - AssertionError: assert 'i am supermodule x module a' == 'i am supermodule y module a'
FAILED tests/unit/app/function/supermodule_y/module_b/test_y_main_b.py::TestYMainB::test_main - AssertionError: assert 'i am supermodule x module b' == 'i am supermodule y module b'
========================================================================= 2 failed, 2 passed in 0.09s ========================================================================== 

具体的にはtest_y_main_a.pyでは"i am supermodule y module a"が返ってくることが期待されているのに"i am supermodule x module a"が、test_y_main_b.pyでは"i am supermodule y module b"が返ってくることが期待されているのに"i am supermodule x module b"が返ってきてしまっています。

テスト名 期待されるrun()の結果 実際のrun()の結果 結果
test_x_main_a.py "i am supermodule x module a" "i am supermodule x module a"
test_x_main_b.py "i am supermodule x module b" "i am supermodule x module b"
test_y_main_a.py "i am supermodule y module a" "i am supermodule x module a"
test_y_main_b.py "i am supermodule y module b" "i am supermodule x module b"

なぜこのようなことが起こってしまうのでしょうか?これが第二の問題です。

[第二の問題] submoduleはどこで入れ替わったのか

結論から述べると、submodule.sub_asubmodule.sub_bのインポートがキャッシュされていることが原因です。

ためしに、test_y_main_a.pyを以下のように書き換えてpytest ./tests/ -sを実行してみます。

test_y_main_a.py
import os
import sys

module_path = os.path.abspath(f"{os.getcwd()}/app/function/supermodule_y/module_a")
sys.path.insert(0, module_path)

from app.function.supermodule_y.module_a.main_a import main

sys.path.remove(module_path)

+for k, v in sys.modules.items():
+   if "submodule" in k:
+       print(k, v)


class TestYMainA:
    def test_main(self):
        assert main() == "i am supermodule y module a"

sys.modulesはインポートしたモジュール名をkey、モジュールのパスをvalueとした辞書です。submoduleという値をキーに持つ箇所を抽出すると以下のようになります。

collecting ...
submodule <module 'submodule' (namespace) from ['C:...\\root\\app\\function\\supermodule_x\\module_b\\submodule']>
submodule.sub_a <module 'submodule.sub_a' from 'C:...\\root\\app\\function\\supermodule_x\\module_a\\submodule\\sub_a.py'>
submodule.sub_b <module 'submodule.sub_b' from 'C:...\\root\\app\\function\\supermodule_x\\module_b\\submodule\\sub_b.py'>

注目すべきは submodule.sub_aです。パスを見てみるとC:...\\root\\app\\function\\supermodule_x\\module_a\\submodule\\sub_a.pyとなっており、supermodule_xsub_a.pyが読み込まれていることが分かります。本来であればsupermodule_ysub_a.pyが読み込まれることが期待されます。

この原因はテストの実行順にあります。初めにtest_x_main_a.pyが実行されるとそのタイミングでsubmodule.sub_aというモジュールとしてC:...\\root\\app\\function\\supermodule_x\\module_a\\submodule\\sub_a.pyが読み込まれます。そしてtest_y_main_a.pyを実行するタイミングで from submodule.sub_a import SubAを実行すると、submodule.sub_aの値として前回読み込んだ値をキャッシュしてそのまま流用します。そのため、SubAとしてC:...\\root\\app\\function\\supermodule_x\\module_a\\submodule\\sub_a.pyが利用されてしまいます。これは test_y_main_b.pyでも同様です。

それでは、test_y_main_a.pyを実行するタイミングで、SubAとしてC:...\\root\\app\\function\\supermodule_y\\module_a\\submodule\\sub_a.pyを利用するにはどうすればよいのでしょうか?正解は、test_y_main_a.pyの実行時にsubmodule.sub_aのキャッシュを削除すればよいです。そうすると、from submodule.sub_a import SubAをインポートする際に新しくC:...\\root\\app\\function\\supermodule_y\\module_a\\submodule\\sub_a.pyがインポートされるようになります。

具体的には以下のようにして、sys.modulesの中にsubmodule.sub_aのkeyが存在すれば、モジュールをインポートする前にその要素を削除します。

test_y_main_a.py
import os
import sys

+if "submodule.sub_a" in sys.modules:
+   sys.modules.pop("submodule.sub_a")

module_path = os.path.abspath(f"{os.getcwd()}/app/function/supermodule_y/module_a")
sys.path.insert(0, module_path)

from app.function.supermodule_y.module_a.main_a import main

sys.path.remove(module_path)

for k, v in sys.modules.items():
     if "submodule" in k:
         print(k, v)


class TestYMainA:
    def test_main(self):
        assert main() == "i am supermodule y module a"

このようにすることで、submodule.sub_aとしてC:...\\root\\app\\function\\supermodule_y\\module_a\\submodule\\sub_a.pyが正しくインポートされるようになります。

collecting ...
submodule <module 'submodule' (namespace) from ['C:...\\root\\app\\function\\supermodule_x\\module_b\\submodule']>
submodule.sub_b <module 'submodule.sub_b' from 'C:...\\root\\app\\function\\supermodule_x\\module_b\\submodule\\sub_b.py'>
submodule.sub_a <module 'submodule.sub_a' from 'C:...\\root\\app\\function\\supermodule_y\\module_a\\submodule\\sub_a.py'>

同様にtest_y_main_b.pyも変更します。

test_y_main_a.py
import os
import sys

if "submodule.sub_a" in sys.modules:
   sys.modules.pop("submodule.sub_a")

module_path = os.path.abspath(f"{os.getcwd()}/app/function/supermodule_y/module_a")
sys.path.insert(0, module_path)

from app.function.supermodule_y.module_a.main_a import main

sys.path.remove(module_path)

-for k, v in sys.modules.items():
-    if "submodule" in k:
-        print(k, v)


class TestYMainA:
    def test_main(self):
        assert main() == "i am supermodule y module a"
test_y_main_b.py
import os
import sys

+if "submodule.sub_b" in sys.modules:
+  sys.modules.pop("submodule.sub_b")

module_path = os.path.abspath(f"{os.getcwd()}/app/function/supermodule_y/module_b")
sys.path.insert(0, module_path)

from app.function.supermodule_y.module_b.main_b import main

sys.path.remove(module_path)


class TestYMainB:
    def test_main(self):
        assert main() == "i am supermodule y module b"

また、今回は必要ありませんが、ファイルが増えた時に同様の問題が起こることが考えられるのでtest_x_main_a.pytest_x_main_b.pyについても同様の処理をしておくのが安全でしょう。

test_x_main_a.py
import os
import sys

+if "submodule.sub_a" in sys.modules:
+  sys.modules.pop("submodule.sub_a")

module_path = os.path.abspath(f"{os.getcwd()}/app/function/supermodule_x/module_a")
sys.path.insert(0, module_path)

from app.function.supermodule_x.module_a.main_a import main

sys.path.remove(module_path)


class TestXMainA:
    def test_main(self):
        assert main() == "i am supermodule x module b"
test_x_main_b.py
import os
import sys

+if "submodule.sub_b" in sys.modules:
+  sys.modules.pop("submodule.sub_b")

module_path = os.path.abspath(f"{os.getcwd()}/app/function/supermodule_x/module_b")
sys.path.insert(0, module_path)

from app.function.supermodule_x.module_b.main_b import main

sys.path.remove(module_path)


class TestXMainB:
    def test_main(self):
        assert main() == "i am supermodule x module b"

これで無事にすべてのテストが成功するようになります

[簡単]相対インポートを使うと

上記のごにょごにょは全部しなくてよいです。
代表して main_a.pytest_x_main_a.pyのみを示しますが、他も同様です。
特に事情がなければ相対インポートを使うとシンプルになる場面が多いように思います。

main.py
+from .submodule.sub_a import SubA
-from submodule.sub_a import SubA


def main():
    s = SubA()
    r = s.run()
    print(r)
    return r


if __name__ == "__main__":
    main()
test_x_main_a.py
-import os
-import sys

-if "submodule.sub_a" in sys.modules:
-   sys.modules.pop("submodule.sub_a")

-module_path = os.path.abspath(f"{os.getcwd()}/app/function/supermodule_x/module_a")
-sys.path.insert(0, module_path)

from app.function.supermodule_x.module_a.main_a import main

-sys.path.remove(module_path)


class TestXMainA:
    def test_main(self):
        assert main() == "i am supermodule x module a"

終わりに

今回は、sys.pathとsys.modulesをごにょごにょして絶対インポートを解決する方法について学びました。特段の理由がなければ相対インポートを利用する方がシンプルな場面は多いですが、いざというときに力技で解決できることも重要だと思います。

最後まで読んでいただきありがとうございました。

Discussion