nikkie-ftnextの日記

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

Ryeのworkspaceで複数のパッケージを同時に開発している時に、workspaceのルートでmypyを流す(error: Duplicate module named "..."を--explicit-package-basesで解消)

はじめに

ごみけついきたい、nikkieです。

Ryeを使った開発をきっかけに、mypyのドキュメントにあたりました。
mypyにディレクトリのパスを渡した時にどう動いているか、少し理解が深まった感覚です1

目次

Ryeのworkspace

https://rye-up.com/guide/workspaces/

Ryeにはworkspaceという概念があります。
複数のパッケージを開発しているときに選択肢に入ると認識しています。

Afterwards all projects within that workspace share a singular virtualenv.

workspaceとして設定すると、Ryeは1つの仮想環境で開発環境を管理してくれます2

workspaceの例

百聞は一見に如かず!

.  # 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

モノレポでawesomeパッケージとfabulousパッケージを同時に開発しています。
それぞれディレクトリを作ってrye initしました。

リポジトリルートのpyproject.tomlの設定

[tool.rye.workspace]
members = ["awesome", "fabulous"]

rye syncを叩くと、リポジトリルートの.venvにawesomeもfabulousも全部インストールされます(editable installです)

requirements.lock

-e file:awesome
-e file:fabulous
-e file:.

インストールされたことの確認
(rye initでできたpyproject.tomlのscriptのところを少しいじっています)

% rye run hello
Hello from rye-workspace-example!
% rye run ahello
Hello from awesome!
% rye run fhello
Hello from fabulous!

リポジトリルートからmypyを流したい

このworkspaceで、リポジトリルートからmypy .と実行したいです3

単に実行するとエラーが送出されます。

% rye run mypy .
fabulous/tests/__init__.py: error: Duplicate module named "tests" (also at "./awesome/tests/__init__.py")
fabulous/tests/__init__.py: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#mapping-file-paths-to-modules for more info
fabulous/tests/__init__.py: note: Common resolutions include: a) using `--exclude` to avoid checking one of them, b) adding `__init__.py` somewhere, c) using `--explicit-package-bases` or adjusting MYPYPATH
Found 1 error in 1 file (errors prevented further checking)

それぞれ個別に指定すると何も問題なく流れるんですよ!

% rye run mypy awesome
Success: no issues found in 3 source files
% rye run mypy fabulous
Success: no issues found in 3 source files

mypy .と実行したときの「Duplicate module named "tests"」の解決法を調べました。
そもそもなんでうまくいかないのかがワカンナイヨー

結論:Duplicate module namedを解決するには

mypy --explicit-package-bases .とフラグを指定します。
ちょうどnoteとして案内されていますね(c)

% rye run mypy --explicit-package-bases .
Success: no issues found in 7 source files

エラーメッセージ中のリンク https://mypy.readthedocs.io/en/stable/running_mypy.html#mapping-file-paths-to-modules を見た末に「--explicit-package-bases」を指定する必要があると理解しました。

「Mapping file paths to modules」(mypyがファイルとモジュールの対応を付けるやり方)

mypy [files ...]とファイルパスを渡した時に、どのようにファイルをモジュールに対応付けるのかが述べられている箇所です。

mypyに渡されたファイルの扱い

  • コマンドラインで与えられた、ファイルに対応するパスをmypyはすべてチェックする4
  • --exclude引数を考慮した後で、与えられたディレクトリパスの中の.pyまたは.pyiで終わる全てのファイルを再帰的に見つけ、チェックする5
  • チェックする各ファイルについて、mypyはファイルとモジュールの完全修飾名を関連させようとする6

3点目について、完全修飾名とは、用語集より
https://docs.python.org/ja/3/glossary.html#term-qualified-name

モジュールへの参照で使われると、完全修飾名 (fully qualified name) はすべての親パッケージを含む全体のドット名表記

ここでの例は、ファイルがproject/foo/bar/baz.pyのとき

  • 完全修飾名はfoo.bar.baz
  • ディレクトリprojectがmypyのモジュール検索パスに追加されます

モジュールの完全修飾名の決め方

mypyのオプション--no-namespace-packages--explicit-package-basesによって決まるとあります。

ケース1:--no-namespace-packagesが指定された場合

__init__.pyまたは__init__.pyiの有無によります。
__init__.pyまたは__init__.pyiを見つける限り、mypyはディレクトリツリーを上にたどり続けます7

ここで挙げられている例は以下です。

.
└── pkg/
    ├── __init__.py
    └── subpkg/
        ├── __init__.py
        └── mod.py

mod.pyに対応する完全修飾名ですが、__init__.pyがあるので subpkg -> pkg とたどっていきます。
結果、完全修飾名はpkg.subpkg.modとなります。

ケース2:デフォルトの場合(--namespace-packagesが有効で、--explicit-package-basesは無効)

デフォルトでは、__init__.py__init__.pyiも持たないディレクトリもパッケージと見なします。
上位(原語はtop-level)のパッケージを決めるために、mypyはファイルの親のディレクトリをすべて見て、__init__.pyまたは__init__.pyiを持つ最上位のディレクトを用います8

例を見てみましょう。

pkg/
 __init__.py
 a/
  b/
   c/
    d/
     mod.py

mod.pyに対応する完全修飾名ですが、上位のパッケージは__init__.pyを持つpkgと決まります。
それにより、pkg.a.b.c.d.modとなります。

ケース3:--explicit-package-bases__init__.pyを配置できない場合)

--explicit-package-basesが指定されると、mypyは以下3つのうちから、最も近い親ディレクトを見つけ出します9

例はこちら(__init__.pyがありませんね)

.
└── src/
    └── namespace_pkg/
        └── mod.py

MYPYPATHで指定したsrcが使われます(カレントディレクトリよりもモジュールに近いと理解しました)。
srcからの相対パスで完全修飾名になるので、mod.pyに対応する完全修飾名はnamespace_pkg.modとなります。

ケース3の--explicit-package-basesがRyeのworkspaceに効く理由

そもそもなぜ「Duplicate module named "tests"」というエラーになっているかですが、デフォルトのケース2で動いたことで完全修飾名の重複が発生しています。
これは-vを指定することで確認できます

LOG:  Found source:           BuildSource(path='./awesome/tests/__init__.py', module='tests', has_text=False, base_dir='/.../rye-workspace-example/awesome', followed=False)
LOG:  Found source:           BuildSource(path='./fabulous/tests/__init__.py', module='tests', has_text=False, base_dir='/.../rye-workspace-example/fabulous', followed=False)

testsというモジュール名が重複していますね(testsが__init__.pyを持つ最上位ディレクトリに当たります)

ケース2の説明からリポジトリルートに__init__.pyを配置すれば、rye-workspace-example.awesome.testsrye-workspace-example.fabulous.tests となって区別できそうですよね。
ところがこのアイデアは、rye-workspace-exampleがハイフンを含んでいるためにモジュール名として使えず、実現できません10

ケース3をそのまま(MYPYPATHやmypy_pathを設定せずに)実行すると、カレントディレクト(=リポジトリのルート)からの相対パスになります。
これは__init__.py配置案と結果的には同様に、awesome.tests と fabulous.tests とを区別できます!

LOG:  Found source:           BuildSource(path='./awesome/tests/__init__.py', module='awesome.tests', has_text=False, base_dir='/.../rye-workspace-example', followed=False)
LOG:  Found source:           BuildSource(path='./fabulous/tests/__init__.py', module='fabulous.tests', has_text=False, base_dir='/.../rye-workspace-example', followed=False)

こうしてエラーを解決できました🙌

なお、完全修飾名の基点が変わるので、srcの方も完全修飾名は変わります

-LOG:  Found source:           BuildSource(path='./awesome/src/awesome/__init__.py', module='awesome', has_text=False, base_dir='/.../rye-workspace-example/awesome/src', followed=False)
+LOG:  Found source:           BuildSource(path='./awesome/src/awesome/__init__.py', module='awesome.src.awesome', has_text=False, base_dir='/.../rye-workspace-example', followed=False)

P.S. 名前空間パッケージ

ケース1・2のフラグについて少しだけ

Pythonのパッケージには2種類あります
https://docs.python.org/ja/3/glossary.html#term-package

ケース1で指定した--no-namespace-packagesは、名前空間パッケージなし(=regular packageオンリー)と言っているわけですね。
なので、regular packageが持つ__init__.pyが関心事です。

ケース2は--namespace-packagesが有効ですから、regular packageもnamespace packageも両方ありな状況です。
regular packageを見つけ、その中はnamespace packageもありとして扱っているように思われます

変更履歴


  1. このエントリの元ツイートです
  2. 以前書いたモノレポでのpipの悩みは解消していますね
  3. リポジトリルートのpyproject.tomlに[tool.rye.scripts]でショートカットコマンドを用意したいという動機もあります ref: https://rye-up.com/guide/pyproject/#toolryescripts
  4. 原文 Mypy will check all paths provided that correspond to files.
  5. 原文 Mypy will recursively discover and check all files ending in .py or .pyi in directory paths provided, after accounting for --exclude.
  6. 原文 For each file to be checked, mypy will attempt to associate the file (e.g. project/foo/bar/baz.py) with a fully qualified module name (e.g. foo.bar.baz). The directory the package is in (project) is then added to mypy’s module search paths.
  7. 原文 That is, mypy will crawl up the directory tree for as long as it continues to find __init__.py (or __init__.pyi) files.
  8. 原文 Specifically, mypy will look at all parent directories of the file and use the location of the highest __init__.py[i] in the directory tree to determine the top-level package.
  9. 原文 With --explicit-package-bases, mypy will locate the nearest parent directory that is a member of the MYPYPATH environment variable, the mypy_path config or is the current working directory.
  10. renameするという道もありますが、そこまでするならケース3の方がよいと思います