nikkie-ftnextの日記

イベントレポートや読書メモを発信

Ryeのworkspaceで複数のパッケージを同時に開発している時に、workspaceのルートでpytestを流す(ModuleNotFoundErrorを--import-mode importlibで解消)

はじめに

郁原ゆうさん1、お誕生日おめでとうございます。nikkieです。

Ryeを使った開発をきっかけに、pytestのドキュメントに当たりました。
なぜpytestがModuleNotFoundErrorを送出するのか、少し理解が深まったように感じています(sys.pathが絡んでるんだ!)

目次

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つあります

  1. prepend(デフォルト)
  2. append
  3. 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と違って(先頭ではなく)末尾に追加します。

  • 各モジュールを含むディレクトリのパスを、まだなければsys.pathの末尾に挿入する5
  • (sys.pathを変更してから)importlib.import_module関数でimportする

このモードのメリットとして、テスト対象のパッケージ(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を流したい

変更履歴


  1. エミリーちゃんのキャストさんです
  2. 単に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
  3. 原文 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.
  4. 原文 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.
  5. the directory containing each module is appended to the end of sys.path if not already there, and imported with importlib.import_module.
  6. ref: 「the tests will run against the installed version of pkg_under_test when --import-mode=append is used」(なお、src layoutを使うことにも言及されています。これはまたの機会に)
  7. ref: 「doesn’t require changing sys.path」
  8. 原文 For this reason this doesn’t require test module names to be unique.
  9. 「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