はじめに
START THE DREAM😭😭😭 nikkieです。
先日のRyeのworkspaceとmypy(pytest)の記事のアップデートをお届けします。
mypyをstrictモードで流すために対処せねばならぬ点がありました。
目次
- はじめに
- 目次
- 前回までの、Ryeのworkspaceのルートで流すシリーズ
- workspaceのルートでmypyをstrictモードで流したい
- 結論:このようにして解決しました
- なぜmypyは「Class cannot subclass "ShapeInterface" (has type "Any")」というエラーを出したのか
- どう解決したか
- 終わりに
前回までの、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
を指定するのをやめました- testsディレクトリをrenameして区別しています
- pytestはtestsという名前のディレクトリを見ているわけではないので影響はありません
- pytestが見ているのは
test_*.py
または*_test.py
という名前のファイルだけです - testsディレクトリを区別したので
--import-mode importlib
を外したprependモードでも動きます
- pytestが見ているのは
なぜ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が動くようになったので私の中では解決ですが、今後対処が必要なエラーが発生したらこのシリーズを更新していきます。