【python】sys.pathとsys.modulesをごにょごにょして絶対インポートを解決する
はじめに
業務で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.py
はSubA
を、main_b.py
はSubB
をインポートします。
from submodule.sub_a import SubA
def main():
return SubA().run()
if __name__ == "__main__":
main()
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]
の文字列を返します。
class SubA:
def run(self):
return "i am supermodule x module a"
class SubB:
def run(self):
return "i am supermodule x module b"
class SubA:
def run(self):
return "i am supermodule y module a"
class SubB:
def run(self):
return "i am supermodule y module b"
最後にtestです。main
の出力が期待通りのものかをassertで比較します。そのため、テストの対象のmain関数をそれぞれインポートしています。テストにはpytestを利用します。
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"
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"
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"
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
は、インポートするモジュールを探す際の開始位置のリストです。
+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の先頭である必要はありませんが、先頭にしない理由もないのでとりあえず先頭に追加しています。またこのパスはインポート後は不要なので削除しておきます。
+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
を実行してみます。
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"
+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"
+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"
+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.py
とtest_x_main_b.py
には成功しますが、
test_y_main_a.py
とtest_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_a
、submodule.sub_b
のインポートがキャッシュされていることが原因です。
ためしに、test_y_main_a.py
を以下のように書き換えてpytest ./tests/ -s
を実行してみます。
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_x
のsub_a.py
が読み込まれていることが分かります。本来であればsupermodule_y
のsub_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が存在すれば、モジュールをインポートする前にその要素を削除します。
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
も変更します。
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"
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.py
、test_x_main_b.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"
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.py
とtest_x_main_a.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()
-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