mypyで静的型チェックを導入する

仕事で既存のコードへmypyの導入を試みる機会がありましたので、使い方とtipsの備忘録としてまとめます。

mypyとは

mypyはPythonで静的型チェックを行うライブラリです。型はtypingで定義します。

インストール

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

コードの修正

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を紹介しました。