これはKLab Advent Calendar 2016 21日目の記事です。
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 syntax cheat sheet (Python 3)
チートシート。型アノテーションの書き方が分かる -
Static types in Python, oh my(py)!
zulipで対応した際のナレッジがつまってる。この記事なんかより読んだほうがいい。
Qiitaに翻訳版があります。[翻訳] Python の静的型、すごい mypy! -
PEP484
チートシートみても分からなくなったら読むといいかも