nikkie-ftnextの日記

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

続・Ryeのworkspaceで複数のパッケージを同時に開発している時に、workspaceのルートでmypyを流す(strictモードで流すための対応案)

はじめに

START THE DREAM😭😭😭 nikkieです。

先日のRyeのworkspaceとmypy(pytest)の記事のアップデートをお届けします。
mypyをstrictモードで流すために対処せねばならぬ点がありました。

目次

前回までの、Ryeのworkspaceのルートで流すシリーズ

mypy --explicit-package-bases .
カレントディレクトリからのモジュール名とすることで、testsというモジュール名の重複を解消します

pytest --import-mode importlib
sys.pathを変更しないimportlibモードにより、2つあるtestsモジュールそれぞれをimportしてテストを実行できます

workspaceのルートでmypyをstrictモードで流したい

実装を追加

クラスを継承する実装を追加しました(awesome.diagrams

.  # rye-workspace-example ディレクトリ
├── .venv
├── pyproject.toml
├── src/
│   └── rye_workspace_example/
│       └── __init__.py
├── awesome/
│   ├── pyproject.toml
│   ├── src/
│   │   └── awesome/
+│   │       ├── diagrams/
+│   │       │   ├── __init__.py
+│   │       │   ├── base.py
+│   │       │   └── core.py
│   │       └── __init__.py
│   └── tests/
│       ├── __init__.py
│       └── test_awesome.py
└── fabulous/
    ├── pyproject.toml
    ├── src/
    │   └── fabulous/
    │       └── __init__.py
    └── tests/
        ├── __init__.py
        └── test_fabulous.py

awesome/src/awesome/diagrams/base.py

from abc import ABCMeta, abstractmethod


class ShapeInterface(metaclass=ABCMeta):
    @abstractmethod
    def area(self) -> int:
        """Calculate rectangle's area."""

awesome/src/awesome/diagrams/core.py

from awesome.diagrams.base import ShapeInterface


class AwesomeRectangle(ShapeInterface):
    def __init__(self, width: int, height: int) -> None:
        self._width = width
        self._height = height

    def area(self) -> int:
        return self._width * self._height

mypyをstrictモードで流すと「Class cannot subclass」エラー

リポジトリルート(=カレントディレクトリ)のpyproject.tomlではmypyを以下のように設定しています

[tool.mypy]
ignore_missing_imports = true
explicit_package_bases = true
% rye run mypy . --strict
src/rye_workspace_example/__init__.py:1: error: Function is missing a return type annotation  [no-untyped-def]
fabulous/src/fabulous/__init__.py:1: error: Function is missing a return type annotation  [no-untyped-def]
awesome/src/awesome/diagrams/core.py:4: error: Class cannot subclass "ShapeInterface" (has type "Any")  [misc]
awesome/src/awesome/__init__.py:1: error: Function is missing a return type annotation  [no-untyped-def]
Found 4 errors in 4 files (checked 14 source files)

「Function is missing a return type annotation」はrye initで生成されたコードに型ヒントを書けば解消するので、ここでは取り上げません。

以下を解決したいです

awesome/src/awesome/diagrams/core.py:4: error: Class cannot subclass "ShapeInterface" (has type "Any") [misc]

結論:このようにして解決しました

.  # rye-workspace-example ディレクトリ
├── .venv
├── pyproject.toml
├── src/
│   └── rye_workspace_example/
│       └── __init__.py
├── awesome/
│   ├── pyproject.toml
│   ├── src/
│   │   └── awesome/
│   │       ├── diagrams/
│   │       │   ├── __init__.py
│   │       │   ├── base.py
│   │       │   └── core.py
│   │       └── __init__.py
-│   └── tests/
+│   └── tests_awesome/
│       ├── __init__.py
│       └── test_awesome.py
└── fabulous/
    ├── pyproject.toml
    ├── src/
    │   └── fabulous/
    │       └── __init__.py
-    └── tests/
+    └── tests_fabulous/
        ├── __init__.py
        └── test_fabulous.py
  • mypyに--explicit-package-basesを指定するのをやめました
  • pytestはtestsという名前のディレクトリを見ているわけではないので影響はありません
    • pytestが見ているのはtest_*.pyまたは*_test.pyという名前のファイルだけです
    • testsディレクトリを区別したので--import-mode importlibを外したprependモードでも動きます

なぜmypyは「Class cannot subclass "ShapeInterface" (has type "Any")」というエラーを出したのか

mypyは型チェックでimport文を見つけたとき、importされるモジュールまで追従(follow)しようとします。
https://mypy.readthedocs.io/en/stable/running_mypy.html#how-mypy-handles-imports
結果、追従できる場合(follow)と、追従できない場合(unfollow)があります。

追従できない場合、そのモジュールをAny型として扱います。
https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports

If you get any of these errors on an import, mypy will assume the type of that module is Any, the dynamic type.

上記のエラーは、from awesome.diagrams.base import ShapeInterfaceとしてimportしたShapeInterfaceがAny型であるために、Any型をサブクラスにする実装に「それはできない」と言っているわけです。

ではなぜ、ShapeInterfaceがAny型として扱われるのでしょうか?
言い換えると、なぜShapeInterfaceのimportが追従できないのでしょう?

ここに--explicit-package-basesの指定の副作用があると理解しました。
このフラグを指定すると、カレントディレクトリからのモジュール名となるので、ShapeInterfaceのあるファイルはawesome.src.awesome.diagrams.baseというモジュール名になります(カレントディレクトリはリポジトリルート=workspaceのルート)。
一方importするのはawesome.diagrams.baseですから、モジュール名が一致しません
そのため、mypyはimportを追従できず、Any型として扱っていると考えています(これも事象を説明できている仮説にすぎず、反証可能性は残っています)

試しにフラグを変えると、ShapeInterfaceのimportで追従できていないことが分かります。

% rye run mypy . --disallow-any-unimported  
awesome/src/awesome/diagrams/core.py:4: error: Base type ShapeInterface becomes "Any" due to an unfollowed import  [no-any-unimported]
Found 1 error in 1 file (checked 14 source files)

なお、ShapeInterfaceをAwesomeRectangleと同じファイルに定義した場合は、(importがなく、Any型としても扱われないので)上記エラーは発生しません

どう解決したか

--explicit-package-basesは、awesomeとfabulousで同名のtestsディレクトリ(モジュール)を区別するためのものでした。
しかしながら、これが原因でShapeInterfaceのimportを追従できていません。
そこで私は--explicit-package-basesを外すことにしました。
外すことでShapeInterfaceのあるファイルのモジュール名はawesome.diagrams.baseとなり、実装のimportと一致します(src下のawesome__init__.pyがあるためにこのようになります。詳しくは前回のmypy編記事の「ケース2:デフォルトの場合」を参照ください)

外した場合、当初のawesomeとfabulousで同名のtestsディレクトリが区別できないというエラーを解決する必要があります。
ここまでの対応での学びから、testsディレクトリの名前を変えて区別することにしました。

これを決断した理由として、pytestはtestsという名前のディレクトリを見ていないことがあります。
https://docs.pytest.org/en/8.0.x/how-to/usage.html

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.

test_で始まる、または、_testで終わる名前のPythonファイルを見ているだけです。

この設定値python_filesについて言及するドキュメントもあります。
https://docs.pytest.org/en/8.0.x/example/pythoncollection.html#changing-naming-conventions

以上、--explicit-package-basesをやめ、testsディレクトリの名前を変えて区別することで、strictなmypyも(pytestも)workspaceのルートで流せるようになりました。

testsディレクトリのrenameについては、mypyの設定を利用して__init__.pyを置く方法も考えられます。
しかし私は、パッケージとしては不要な__init__.pyを置きたくなかったので、testsディレクトリの名前を変えて対処しています。

終わりに

Ryeのworkspaceに関しては進んでは戻るという状態になりましたが、一通り対処できたと思います。
mypyやpytestがモジュールをどう扱っているか、理解が深まりました。
一方Ryeはrye sync簡単に環境構築できることが喧伝されますが、モノレポで使う際のハマりどころなど、全てにわたってeasyかと考えると私には期待外れな部分もあるという印象です(界隈でハイプになってるのかなと思います)

strictのmypyとpytestが動くようになったので私の中では解決ですが、今後対処が必要なエラーが発生したらこのシリーズを更新していきます。