mypyで静的型チェックを導入する
仕事で既存のコードへmypyの導入を試みる機会がありましたので、使い方とtipsの備忘録としてまとめます。
mypyとは
mypyはPythonで静的型チェックを行うライブラリです。型はtypingで定義します。
- ドキュメント: https://mypy.readthedocs.io/en/stable/index.html
- GitHub: https://github.com/python/mypy
- PyPI: https://pypi.org/project/mypy/
インストール
typingと異なり、標準ライブラリではないので、インストールが必要です。
$ python --version Python 3.7.4 $ pip install mypy $ mypy --version mypy 0.782
型チェックの実行
mypyで型チェックできます。以下ではsrcディレクトリ以下の型チェックが走ります。既存のプロジェクトへのmypy導入時には大量のエラー・警告メッセージが表示されるかと思います。
$ mypy src
Tips: 型チェック対象を限定する
コマンドライン引数でファイルやディレクトリ単位の指定が可能です。
$ mypy src/common src/models src/services
これらの型チェック対象をローカル環境やCI環境で共有する場合、以下のように列挙したテキストファイルを作成しておきます。mypy @{作成したファイルパス}
とすることでテキストファイルで指定したファイル・ディレクトリのみがチェックされます。
$ cat mypy_files.txt src/common src/models src/services $ mypy @mypy_files.txt
pipenvの場合、Pipfileに以下のように定義しておくと楽になるかと思います。
[scripts] mypy = "mypy @mypy_files.txt"
設定
mypyはカレントディレクトリのmypy.ini (あるいはsetup.cfg) で設定します。詳細なパラメータはドキュメントを参照してください。
- warn_retun_any = Trueとすることで、返す型を明示していないインタフェースに警告させてます (デフォルトはFalse)
[mypy] python_version = 3.7 warn_return_any = True warn_unused_configs = True
Tips: スタブが無いパッケージのエラーは無視させる
よく見るエラーの1つがerror: Skipping analyzing '{パッケージ名, 例えばnumpy}': found module but no type hints or library stubs
です。これは型情報を持つstubファイル (.pyi) を含まないパッケージをインポートすると起きます。
サードパーティのライブラリだといちいち追加するのも大変なので、無視するようにパッケージ単位でignore_missing_imports = True
を追加します。
[mypy-numpy] ignore_missing_imports = True [mypy-scipy.*] ignore_missing_imports = True
- stub付きパッケージを作る方法はこちらの記事 PEP 561 に準拠した型ヒントを含むパッケージの作り方 – ymyzk’s blog が参考になるかと思います
- ちなみにnumpyの場合、numpy-stubsやnptypingなどを使ってmypyで型検査できるそうです
コードの修正
mypyで指摘された事項を修正していきます。基本的には、正しく型アノテーションを付けることで解消していきます。
返り値がintでアノテーションされてますが、Noneを返すケースもあります。
import re def atoi(a: str) -> int: if re.match(r"[+-]?[0-9]+", a): return int(a) else: return None
mypyでそれが指摘されます。
% mypy src src/main.py:7: error: Incompatible return value type (got "None", expected "int") Found 1 error in 1 file (checked 1 source file)
なのでtypingを使って書き換えていきます。
from typing import def atoi(a: str) -> Optional[int]: # ...
基本はこの繰り返しになりますが、いくつか対処に困るケースもあります。
Tips: Unionでの型エラー
Union型の変数を、いずれかの型であることを前提とした処理を書くことがしばしばあります。例えば以下のコードでは、辞書のキーが "_list" の場合はlist、そうでなければintとして分けています。
from typing import Dict, Union, List def sum_dict(d: Dict[str, Union[int, List[int]]]) -> int: total = 0 for k, v in d.items(): if k.endswith("_list"): total += sum(v) else: total += v return total total = sum_dict({ "ShopA": 100, "ShopB_list": [200, 300] }) print(total) # 600
問題なく実行もできるのですが、mypyではエラーとなってしまいます。
% mypy src src/main.py:7: error: Argument 1 to "sum" has incompatible type "Union[int, List[int]]"; expected "Iterable[int]" src/main.py:9: error: Unsupported operand types for + ("int" and "List[int]") src/main.py:9: note: Right operand is of type "Union[int, List[int]]"
解決策が2つあります。
1つ目はtyping.castを使って、チェック時に型を教えてあげる方法です。値の型は変わりません。
from typing import Dict, Union, List, cast def sum_dict(d: Dict[str, Union[int, List[int]]]) -> int: total = 0 for k, v in d.items(): if k.endswith("_list"): total += sum(cast(List[int], v)) else: total += cast(int, v) return total
2つ目はisinstanceで明示的に処理分岐させる方法です。こうするとmypyは分岐を解析し、ifの方はlist、elseの方はintで解釈するようになります。
def sum_dict(d: Dict[str, Union[int, List[int]]]) -> int: total = 0 for k, v in d.items(): if isinstance(v, list): total += sum(v) else: total += v return total
個人的には、可能であればisinstanceで実行時の型チェック、それも難しい場合にtyping.castを使うのが安全かなと思います。
Tips: 特定箇所の型チェックエラーを無視したい
どうしても型チェックエラーを無視したいケースもあります。インタフェースを簡単に変えられないケースなどです。
エラーとなっている箇所に# type: ignore
とコメントすることで除外できます。
import re def atoi(a: str) -> int: if re.match(r"[+-]?[0-9]+", a): return int(a) else: return None # type: ignore
まとめ
今回はmypyの使い方とTipsを紹介しました。