MicroAd Developers Blog

マイクロアドのエンジニアブログです。インフラ、開発、分析について発信していきます。

最強のPython型チェッカーmypy

はじめまして!今年マイクロアドに入社してサーバーサイドエンジニアとしてバッチ開発を担当している根本( id:realyutanemoto )といいます! 今回は、マイクロアドのバッチ開発で利用している静的コード解析ツールのmypyについてのご紹介です。

はじめに

マイクロアドではバッチを作成する際にメインの処理をPythonで記述し、ワークフローをDigdagという管理ツールで制御しています。

developers.microad.co.jp

マイクロアドでは、主に

  • シンプルな文法で可読性の良いコードが書きやすい
  • 他に社内の機械学習チームでもPythonを使用していることから開発チーム間での連携がとりやすい

の2点の理由からPythonを用いてバッチ処理の開発を行っています。

Pythonを含めスクリプト言語の多くはコンパイラ言語(JavaやScalaなど)のように強力な型制約で守られているわけではなく、注意して実装を行わないとコードも安定感に欠けるものになってしまいます。

そのためバッチ開発では、網羅的なユニットテストの作成やフォーマッターやリンターの活用により、長期的に安定した開発ができるよう工夫しています。

この記事ではバッチ開発で活用されているそれらのツールと、特に頼りになるPythonの型チェックツールである mypy の最近の進化についてご紹介します。

バッチ開発で使っているリンター・フォーマッターなど

マイクロアドのバッチ開発では、開発しているPythonプログラムの品質を保持するために以下の3つのツールを活用しています。

yapf

yapf はPythonのフォーマッターのひとつで、読み込んだコードをスタイルガイドに沿った適切な形に変更してくれるツールです。

github.com

mypy

mypy はPython3と2.7向けの静的型チェッカーです。

mypy.readthedocs.io

型アノテーションをもとにコードの型チェックを行いバグを検知することができます。

flake8

flake8

  • PyFlakes - Pythonソースコードのエラーチェックを行う
  • pycodestyle - PEP8をもとにしたPythonのコードのスタイルチェックを行う
  • Ned Batchelder’s McCabe script - Pythonコードの複雑度チェックを行う

の3つのコードチェッカーのラッパーです。

flake8.pycqa.org

最強の型チェッカーmypy

プログラムが大きく複雑なものになるほど、コードの管理は困難になります。

上記でご紹介したツールの中でも型周りの安全を担保するのに特に有用なのが mypy です。 mypy による静的型チェックは、Pythonの弱点である型周りの曖昧さを補完するのに使えます。

mypyで型チェックしよう

Python3.5以降ではタイプヒントがサポートされていますが、これをプログラム中で変数やメソッドの返り値に付与するメリットは可読性が上がることだけではありません。mypy で型チェックを行うことが可能になり、このタイプヒントをもとに型誤りを指摘してくれます。

例えば以下のコードは関数の戻り値と変数にタイプヒントが付与されています。

def do_something() -> str:
    return "returned text"

def main() -> None:
    some_number: int = do_something()
    calculated_number: int = some_number + 1
    print("sum: " + calculated_number)

if __name__ == '__main__':
    main()

これをmypyを用いて型チェックすると、以下のようにプログラムの実行前に型誤りを検出できます。

$ mypy ./test.py
test.py:5: error: Incompatible types in assignment (expression has type "str", variable has type "int")
test.py:7: error: Unsupported operand types for + ("str" and "int")
Found 2 errors in 1 file (checked 1 source file)

タイプヒントはint/str/boolといったプリミティブ型以外に自作クラスのインスタンスにも適用でき、mypyを用いて汎用的にモジュールの入出力の型の管理を行うことができます。

さらに詳細な使い方について知りたい方は、下のドキュメントを漁ってみてください。 mypy.readthedocs.io

最近のmypy

mypyは10年以上前からリリースされているツールですが、アップデートは現在も行われています。

マイクロアドのバッチ開発ではバージョン0.770の mypy を使っているのですが、最新版のバージョンは0.910です。

最近の mypy でどんな進化があるのかを調べてみると、利便性に関わる目立った変更にはこんなものがありました。

  • オプションの追加
  • Pythonのサポート対象バージョンの変更
  • stdlibのモジュール以外に対してのスタブをmypyが提供しなくなる
  • type(x)が使えるようになった

他にも新機能の追加やパフォーマンス改善など変更点はたくさんありますが、ここではその一部をご紹介します。

オプションの追加

エラーコードの有効化/非有効化

バージョン0.790のリリースで

  • --enable-error-code
  • --disable-error-code

の2つのオプションが登場しました。 このオプションによってグローバルに特定のエラーコードについてのエラーメッセージを有効化/非有効化することが可能になりました。

特定のファイル/ディレクトリをビルドから除外

バージョン0.812のリリースで --exclude オプションが登場しました。 このオプションを用いることで、mypyビルド時に正規表現で指定したパスのファイルを検査対象から除外することができます。

例えば--exclude '/setup\.py$'を指定すると配下にある全てのsetup.pyを、--exclude /build/' を指定すると/build/配下にある全てのファイルをそれぞれmypyの型チェックから除外することができます。

スタブパッケージの自動インストール

バージョン0.900のリリースで --install-types オプションが登場しました。 サードパーティーのスタブライブラリが使えるようになる変更と同時につくられたオプションで、これによってpipを用いてチェック対象のソースコードの中で不足してるスタブライブラリを自動でインストールしてくれます。

とはいえこのオプションでインストールしてくれるパッケージは、セキュリティの観点から、Typeshedのチームに認証されたごく一部のパッケージに限られています

確認不要のスタブパッケージの自動インストール

バージョン0.900で追加された --install-types オプションですが、使用時に自動インストールを行おうとすると逐次コマンドライン上での確認を求められていました。 0.910で追加された --non-interactive オプションを使うとその確認を省略することができます。

Pythonのサポート対象バージョンの変更

バージョン0.900の大きな変更のひとつがサポートされるPythonのバージョンについてのものです。 Python2の型チェックはmypy[python2]という別のパッケージに切り離されました。

このバージョン以降、Python2の型チェックを行うためにはmypy[python2]のパッケージを別途pipでインストールする必要があります。

stdlibのモジュール以外に対してのスタブをmypyが提供しなくなる

mypyとスタブの更新を切り離すため、バージョン0.900からはstdlib以外の外部ライブラリのモジュールについてmypyはスタブを提供しなくなりました。 バージョン0.900以降、サードパーティーのライブラリに含まれるモジュールのスタブは明示的にpipを用いてインストールするか、あるいは自作する必要があります。

mypyのスタブについて詳しくは以下のドキュメントをご覧ください。 mypy.readthedocs.io

メソッドや変数の型範囲を狭めやすくなった

type(x) を用いた型範囲の決定

これは例を使って説明した方が早いです。

from typing import Union

def do_something() -> Union[int, str]:
    return "returned text"

def main() -> None:
    # do_something()からstrまたはintを受け取る
    something: Union[int, str] = do_something()
    
    if(type(something) is str):
        # ここで変数somethingの型は確実にstr
        print("text: " + something)
    else:
        # ここでの変数somethingの型はUnion[int, str]
        print("type: " + str(type(something)))

if __name__ == '__main__':
    main()

上のコードをmypy0.900未満のバージョンでチェックすると、以下のようになります。

$ mypy test.py
test.py:11: error: Unsupported operand types for + ("str" and "int")
test.py:11: note: Right operand is of type "Union[str, int]"
Found 1 error in 1 file (checked 1 source file)

が、0.900以上で実行すると、以下のようになります。

$ mypy test.py
Success: no issues found in 1 source file

つまり0.900の変更により、型情報を決定する際にtype(x)で絞った型情報も利用されるようになったということです。

TypeGuard を用いた型範囲の決定

type(x)による型決定と同じく、mypyはバージョン0.900からTypeGuardによる型決定もサポートをはじめました。

TypeGuardとは、Python3.10からPythonに含まれる予定の、ユーザー定義の関数で型の判定をするための仕様です。これまで一般的にmypyのように型チェックを行うツールでは、isinstance()のようなメソッドを使って型判定をしたものについてのみ、型決定を行う際にその型情報が用いられていました。上で紹介したtype(x)による判定も、バージョン0.900で新たにそこに加わった判定方法です。

しかし、これまで以下プログラムのis_str_list()ようなユーザー定義の関数による型決定もmypyにおける型判定には用いられていませんでした。

from typing import List

def is_str_list(val: List[object]) -> bool:
    """全部の要素が文字列だったらTrue"""
    return all(isinstance(x, str) for x in val)

def func(val: List[object]) -> None:
    if is_str_list(val):
        # ここでの変数valは確実にstrのみを含むリスト
        print(" ".join(val))
    else:
        # ここでの変数valはstr以外のものを含むリスト
        print("val is a list including mixed types")

def main() -> None:
    something = ["abc", "def", 123, True, "geh"]
    func(something)
        
if __name__ == '__main__':
    main()

試しに上記のコードについて型チェックを行ってみると、

$ mypy test.py
test.py:10: error: Argument 1 to "join" of "str" has incompatible type "List[object]"; expected "Iterable[str]"
Found 1 error in 1 file (checked 1 source file)

func関数のif文中で変数valがstrしか含まないリストであることが確実なブロックでも、変数valはstrではなくobjectのリストだと判定されてしまいます。

TypeGuardは以下のように用いることで、ユーザー定義の関数でも型チェックの際に型の判定に利用することができます。

from typing_extensions import TypeGuard
from typing import List

def is_str_list(val: List[object]) -> TypeGuard[List[str]]:
    """全部の要素が文字列だったらTrue"""
    return all(isinstance(x, str) for x in val)

def func(val: List[object]) -> None:
    if is_str_list(val):
        # ここでの変数valは確実にstrのみを含むリスト
        print(" ".join(val))
    else:
        # ここでの変数valはstr以外のものを含むリスト
        print("val is a list including mixed types")

def main() -> None:
    something = ["abc", "def", 123, True, "geh"]
    func(something)
        
if __name__ == '__main__':
    main()

*1

上記のコードについて型チェックを行ってみると、チェックを通過します。

$ mypy test.py
Success: no issues found in 1 source file

TypeGuardについて、詳しくは以下のドキュメントをご覧ください。

www.python.org

以上、最近のmypyの変更の一部をご紹介しました。 利用するバージョンの検討などに役立てていただけると幸いです。

詳細が気になる方はぜひアップデートから変更を追ってみてください。

おわりに

今回の記事では、主にPythonの型チェッカーであるmypyについて、その概要と最近のアップデートをご紹介しました。 mypyはPythonで型安全を確保するのに非常に有用なツールです。 これまで外部ライブラリの型など、手の届かないちょっとした不便があったものの、アップデートを経て使いやすさが改善されています。 文量の関係もあって全部を解説することはできませんが、今回の記事がPythonの開発支援ツールを探している方の参考になれば嬉しいです。

*1:mypy0.900のリリースドキュメントのプログラムを一部書き換えたもの。typingのTypeGuardはPython3.10の仕様のため、Python3.9でデモを行うためにtyping_extensionsのTypeGuardを用いた。