はじめに
郁原ゆうさん1、お誕生日おめでとうございます。nikkieです。
Ryeを使った開発をきっかけに、pytestのドキュメントに当たりました。
なぜpytestがModuleNotFoundErrorを送出するのか、少し理解が深まったように感じています(sys.pathが絡んでるんだ!)
目次
- はじめに
- 目次
- Ryeのworkspaceの例
- workspaceのルートでpytestを流したい
- 結論:ModuleNotFoundErrorを解決するには
- pytestのcollectの様子を見てみる
- Import modes(ドキュメント「pytest import mechanisms and sys.path/PYTHONPATH」)
- --import-mode importlibがRyeのworkspaceに効く理由
- P.S. PyCon APAC 2023よりModuleNotFoundErrorのトーク
- P.S. 姉妹編 workspaceのルートでmypyを流したい
- 変更履歴
Ryeのworkspaceの例
Ryeの機能のworkspaceとは、開発中の複数のパッケージを1つの仮想環境で管理できる仕組みです(詳しくは姉妹編の記事に譲ります)。
例として、モノレポでawesomeパッケージとfabulousパッケージを同時に開発している状況を用意しました。
. # rye-workspace-example ディレクトリ ├── .venv ├── pyproject.toml ├── src/ │ └── rye_workspace_example/ │ └── __init__.py ├── awesome/ │ ├── pyproject.toml │ ├── src/ │ │ └── awesome/ │ │ └── __init__.py │ └── tests/ │ ├── __init__.py │ └── test_awesome.py └── fabulous/ ├── pyproject.toml ├── src/ │ └── fabulous/ │ └── __init__.py └── tests/ ├── __init__.py └── test_fabulous.py
workspaceのルートでpytestを流したい
ここでは workspaceのルート=リポジトリルート です。
pytest
コマンド2を流したいのですが、単に実行するとエラーが送出されます。
% rye run pytest ================================== test session starts ================================== platform darwin -- Python 3.12.0, pytest-8.0.1, pluggy-1.4.0 rootdir: /.../rye-workspace-example collected 1 item / 1 error ======================================== ERRORS ========================================= ___________________ ERROR collecting fabulous/tests/test_fabulous.py ____________________ ImportError while importing test module '/.../rye-workspace-example/fabulous/tests/test_fabulous.py'. Hint: make sure your test modules/packages have valid Python names. Traceback: .../.rye/py/cpython@3.12.0/install/lib/python3.12/importlib/__init__.py:90: in import_module return _bootstrap._gcd_import(name[level:], package, level) E ModuleNotFoundError: No module named 'tests.test_fabulous' ================================ short test summary info ================================ ERROR fabulous/tests/test_fabulous.py !!!!!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!!!!!! =================================== 1 error in 0.04s ====================================
それぞれ個別に指定すると何も問題なく流れます。
% rye run pytest awesome collected 1 item awesome/tests/test_awesome.py . [100%] =================================== 1 passed in 0.00s =================================== % rye run pytest fabulous collected 1 item fabulous/tests/test_fabulous.py . [100%] =================================== 1 passed in 0.00s ===================================
pytest
と実行したときのModuleNotFoundError
の解決法を調べていきます。
モジュールが見つからないってどういうこと?
結論:ModuleNotFoundErrorを解決するには
--import-mode importlib
の指定を追加します。
% rye run pytest --import-mode importlib ================================== test session starts ================================== platform darwin -- Python 3.12.0, pytest-8.0.1, pluggy-1.4.0 rootdir: /.../rye-workspace-example collected 2 items awesome/tests/test_awesome.py . [ 50%] fabulous/tests/test_fabulous.py . [100%] =================================== 2 passed in 0.01s ===================================
pytestのcollectの様子を見てみる
今回の事象はググってもなかなか合致する事象が見つからなかったのですが、pytestの動きを見て分かってきたことがありました(姉妹編のmypyで同様の対応をしていたというのも大きいです)。
pytest
コマンドがテストのファイルを収集(collect)する様子を眺めます。
「Finding out what is collected」
https://docs.pytest.org/en/8.0.x/example/pythoncollection.html#finding-out-what-is-collected
% rye run pytest --collect-only ================================== test session starts ================================== platform darwin -- Python 3.12.0, pytest-8.0.1, pluggy-1.4.0 rootdir: /.../rye-workspace-example collected 1 item / 1 error <Dir rye-workspace-example> <Dir awesome> <Package tests> <Module test_awesome.py> <Function test_awesome>
(送出されるエラーは共通なので省略しています)
これを見て仮説として思ったのは、testsというPackageが共通だからではないかということです。
awesomeディレクトリの下のtestsと、fabulousディレクトリの下のtestsがあります。
この仮説と--import-mode
の存在が結び付き、この事象は解決できました
なお、より詳しい情報が取得できる調査方法をご存じの方はぜひ教えてください!
Import modes(ドキュメント「pytest import mechanisms and sys.path/PYTHONPATH」)
https://docs.pytest.org/en/8.0.x/explanation/pythonpath.html#import-modes を見ていきましょう。
--import-mode
に指定できる値は3つあります
- prepend(デフォルト)
- append
- importlib
1. prepend(デフォルト)
- 各モジュール(test_foo.pyなどのファイル)を含むディレクトリのパスを、まだなければsys.pathの先頭に挿入する3
- (sys.pathを変更してから)
importlib.import_module
関数でimportする
sys.pathとは
https://docs.python.org/ja/3/library/sys.html#sys.path
モジュールを検索するパスを示す文字列のリスト。
ディレクトリのパスをsys.pathに追加するから、pytestはモジュールをimportできているんですね!
prepend(=sys.pathの先頭に追加)モードは、テストディレクトリツリーがパッケージによって整えられていないとき、テストモジュールの名前が一意であることを要求する4とあります。
2. append
こちらもsys.pathを変更するモードです。
prependと違って(先頭ではなく)末尾に追加します。
このモードのメリットとして、テスト対象のパッケージ(pkg_under_test
)はソースコードでなく、インストールされたバージョンで実行できる6と説かれていました。
sys.pathの末尾に追加なので、それよりも先にあるsite-packagesへのパスを見てインストールされたバージョンをテストできるという理解です(詳しくはドキュメントの例をどうぞ)。
3. importlib
sys.pathを変更しません7!
pytest 6.0から導入されたそうです。
sys.pathを変更しないので、テストモジュールの名前も一意でなくてよくなります8。
--import-mode importlib
がRyeのworkspaceに効く理由
collectの動きはprependモードで見ていました。
私の理解ですが、sys.pathの先頭にawesomeのtestsディレクトリをimportするためのパスが追加されたと思われます。
このprependによりtestsモジュールはawesome/testsからimportという挙動となり、fabulous側のtestsディレクトリでもこれが参照されます。
しかし、awesome/tests以下にtest_fabulous.pyはないため、ModuleNotFoundErrorが送出されたと考えています。
注意事項ですが、ここで述べたのは仮説にすぎず、printデバッグなどで確認できていないため、反証される可能性があります
--import-mode importlib
で今回の事象は解決できました。
また、pytestのドキュメントにもimportlibの指定をオススメする箇所9があります。
「じゃあimportlibモードの指定が完全上位互換じゃん!」と思っちゃいそうですが、「Import modes」の箇所によればそうではなく欠点もあるそうです。
このあたりはまたの機会に。
P.S. PyCon APAC 2023よりModuleNotFoundErrorのトーク
このトークをアーカイブで確認していたおかげで解決できたフシもあります。
P.S. 姉妹編 workspaceのルートでmypyを流したい
変更履歴
- 2024/02/20 ディレクトリツリーのsrcの下にawesomeとfabulousを追加(リポジトリルートからは
awesome/src/awesome/__init__.py
) - 2024/02/21 https://github.com/ftnext/rye-workspace-example へのリンクをコミットハッシュを指定したものに差し替え
- エミリーちゃんのキャストさんです ↩
-
単に
pytest
とだけ叩くと、カレントディレクトリから下のtest_*.py
や*_test.py
のテストが実行されます 「This will execute all tests in all files whose names follow the form test*.py or *test.py in the current directory and its subdirectories.」https://docs.pytest.org/en/8.0.x/how-to/usage.html↩ - 原文 the directory path containing each module will be inserted into the beginning of sys.path if not already there, and then imported with the importlib.import_module function.↩
- 原文 This requires test module names to be unique when the test directory tree is not arranged in packages, because the modules will put in sys.modules after importing.↩
- the directory containing each module is appended to the end of sys.path if not already there, and imported with importlib.import_module.↩
- ref: 「the tests will run against the installed version of pkg_under_test when --import-mode=append is used」(なお、src layoutを使うことにも言及されています。これはまたの機会に)↩
- ref: 「doesn’t require changing sys.path」↩
- 原文 For this reason this doesn’t require test module names to be unique.↩
- 「For historical reasons, pytest defaults to the prepend import mode instead of the importlib import mode we recommend for new projects.」 https://docs.pytest.org/en/8.0.x/explanation/goodpractices.html#choosing-an-import-mode↩