Help us understand the problem. What is going on with this article?

mypyやっていくぞ

More than 1 year has passed since last update.

これはKLab Advent Calendar 2016 21日目の記事です。

mypyやっていく気持ちになったので、社内のコードで実践してみた話を書きます。

続編

mypyやっていったぞ

mypyとは

python3.5から型アノテーションを付ける構文(PEP 484)が追加されました。このアノテーションをもとにコード上の型を静的解析してくれるのがmypyです。

def func() -> int:
    return "a"

このようなコードにmypyを走らせてみると

$ mypy test.py
test.py: note: In function "func":
test.py:2: error: Incompatible return value type (got "str", expected "int")

アノテーションを元に型をチェックして、戻り値の型がintなのにstrを返しているのでエラーになります。

やっていく気持ち

"mypyやっていく"というのは、PEP 484に対応した型アノテーションを付け、mypyを実行し静的チェックをパスすることを指します。なぜやっていく気持ちになったかというと、Static types in Python, oh my(py)! この記事を読んだことが大きいです。やっていけそうな気がしました。担当している案件が規模が大きく既存機能の変更頻度も高いものだったので、信頼できる型アノテーションが有ると、修正する際の助けになりそうだなと思いました。
その他にはPyCharmがPEP 484に対応し警告を出してくれるようになったことで間違ったType Hintingが付けられてる箇所がエラーになり、目につくようになったからというのもあります。

スタートライン

コードベースはpython3.5で書かれており、"アノテーションは付けたい人が付ける"ぐらいの緩いルールで開発されていました。アノテーションはバッチやらテストやらを除くと7割ぐらいのメソッドには付いているかなといったところです。とりあえず全体に
mypy -s --fast-parser --strict-optional --disallow-untyped-defs --disallow-untyped-calls <dir>
を実行してみると3000件近いエラーが出ました。このまま上から順番に潰していく作業は心が折れる可能性があるので、一旦ビジネスロジックの詰まった部分だけに限定し、最小限のオプションのみで実行し修正することにしました。

やっていく

mypyのversionは0.4.6, pythonは3.5.2です。
mypy -s --fast-parser <dir>
を実行しつつ修正しました。
-sオプションはimportしたモジュールのチェックを行わなくするオプションで、これがないとimportしたサードパーティー製のライブラリまでチェックしに行ってしまいます。<dir>で指定したディレクトリ中のモジュールimportはよしなにしてくれます。外部モジュールの関数を呼び出した場合の引数,戻り値の型はAnyになります。
--fast-parserオプションはそのうちデフォルトになるオプションで、現行のデフォルトパーサーだと引数のアンパック周りなどでパースエラーになるパターンがあります。python3.6のシンタックスに対応しているのも--fast-parserのみです。

やってみて躓いたところなど

__init__にアノテーション付け忘れてる

クラス中の__init__メソッドの型アノテーションを付けていない箇所がとても多くありました。そもそも__init__にアノテーション付ける意識がなかったです。

Note that the return type of init ought to be annotated with -> None .

PEP484#the-meaning-of-annotations
とのことなので戻り値の型はNoneにしましょう。幸いこのパターンは機械的に対応可能なので、適当なスクリプトを書いて対応しました。

imported but unused

pythonの静的解析ツールにflake8というものがあり、これを使いCIサーバーでチェックをかけています。flake8のテストケースには未使用のimport文を確認するものがあり、インポートしたものの一度も使っていない場合エラーになります。ここで問題になるのが、関数ではなく変数にアノテーションを付ける場合です。python3.5では変数にアノテーション付ける場合コメントで対応することになっています。(python3.6では新たに変数アノテーションを付ける構文が追加されます。PEP526)

from typing import Optional
a = None  # type: Optional[int]

このように変数アノテーションを書くのですが、コメントになっているのでflake8を実行すると

$ flake8 ./test.py
./test.py:1:1: F401 'Optional' imported but unused

エラーになってしまいます。なので、このエラーがでたモジュールのimport部分には、# noqaとコメントを追加してエラー抑制することで対応しました。

from typing import Optional  # noqa
a = None  # type: Optional[int]

python3.6からは

a: Optional[int] = None

と書けるので、変数アノテーションによる未使用インポートの問題は出にくくなるかもしれません。

循環import

モジュール変数やメソッドにアノテーションを付けているとモジュール間で相互importしてしまうパターンがあります。こうなると実行時にループしてエラーが起きてしまうので対策が必要です。python3.5.2からtyping.TYPE_CHECKINGという変数が使えるようになっており、これはサードパティー製のツールが読むときのみTrueにする変数で、これを使って回避することができます。この変数を使いimport側で対象のモジュールを解析時のみ読み込むようにして回避します。
mypy公式のドキュメントにも解決策として載っています。
http://mypy.readthedocs.io/en/latest/common_issues.html#import-cycles

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from module import MyClass  # noqa


def func() -> "MyClass":
    return MyClass()

# type: ignoreの位置

# type: ignoreというコメントを付けるとmypyのテストを抑制することができるのですが、この位置が曲者でした。複数行にまたがる式の中にエラーが有った場合これを抑制するには式の最初の行にコメントを付ける必要があります。また、ここで付けたコメントはその後の行にも影響が出てしまいます。

class MyClass:
    def __init__(self, a: int, b: str) -> None:
        self.a = a
        self.b = b

MyClass(1,  # type: ignore
        1)  # この行のエラーを無視したい場合、↑にコメントを付ける必要がある

https://github.com/python/mypy/issues/1032
Issueには載っていて、いつか行ごとにignoreできるようになるかもしれません。

Typedshed

mypyはソース中のアノテーションだけでなく型を定義したスタブファイルを読み込むこともでき、標準のライブラリなどはそちらの方法が使われています。それれらのスタブはtypeshedで管理されていて、mypyに含まれる形でインストールされます。なので、mypyテスト時はビルトイン関数や標準ライブラリの不正な呼び出しを検知してくれるのですが、スタブ自体はpythonのコードから生成されていて細かい部分で食い違っている場合がまだあります。特定バージョンのソースから生成してしていて、アップデートに追従できてないパターンもあるので見つけ次第PRをぶん投げていくのがいいと思います。

解決できていない課題

SqlAlchemy

動的に属性付けていたりバリバリメタクラスを使っているものだったりすると、アノテーションを付けるのが難しいです。SqlAlchemyの場合テーブル定義するベースクラス中の__init__で keyword argumentを受け取ってsetattrしているのでソースに直接アノテーションを付けるのが難しいです。派生クラスで__init__定義してやればいいのですが、型アノテーション付けるためにわざわざそれぞれのテーブルで__init__を書くかというと躊躇してしまいます。使っている部分はコード上でサブパッケージにまとまっていたので、まるっと対象から外してしまいました。

やってみて

一通り対応してmypy -s --fast-parser <dir>を実行してもエラーが出なくなるところまでたどり着きました。どうしようもないパターンというのはほとんどなくて、大体は素直に直していくことができました。Typeshedのバグなどもありましたがignore付けておけばいいのでエラーが消えなくて進めなくなることはありませんでした。
また、幾つかのバグを発見することもできました。テストは書いていたのですが、異状系などテストでカバーできていない場所で見つかりました。修正作業はコードをちゃんと読まないと返している型が判断できないのでそこそこ大変で、毎日少しづつ対応していきました。地道にやっていくしかないでしょう。mypy自体もまだまだ開発中ですが現状でもやってやれないことはないと思います。
コードを読む上で役立っていることは明らかで特にa = []といった何が入るかわからない変数にアノテーションがついているのは助けになっています。また、明らかに不正な呼び出しをしている箇所をテストする前に発見できるのは嬉しいことです。リファクタに役立つかはまだわかりませんが、複雑すぎる型を返す関数などが分かりやすくなるので書く際に気をつけるようになりそうです。

今後やること

  • CIに突っ込む
    まだCIサーバーでチェックしているわけでないので、メンバーに周知しつつ入れる
  • --strict-optionalをいれる
    Optionalな型のチェックを厳密にする。experimental-strict-optional-type-and-none-checking
  • --disallow-untyped-defsをいれる
    タイプアノテーションのついていない関数をエラーにする
  • --disallow-untyped-callsをいれる
    タイプアノテーションのついていない関数呼び出しをエラーにする

という感じで、CIに突っ込みつつ徐々に厳しいオプションにしていけたらいいなあと思います。

やっていく際に読むといいもの

mypyやっていくぞ!!

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away