🐍

Pythonに型アノテーションを自動で付与する

commits4 min read

この記事は何か

Python Advent Calendar 2020の 2 日目の記事です(元々 13 日目でしたが、2 日目の参加者が取りやめたので横入りしました)。「Python コードに自動で型を付与し、その型をテストで静的チェックして、保守性を高める方法」をご紹介する趣旨の記事です。

書いた理由

今回紹介するメインツールの pyannotate について、日本語で紹介している記事がほとんどなく、かつ古い情報が多かった為です(python3 サポートがない時代かつ、pytest との連携ができなかった時代)。「Python において型はいるのか?」という議論はしません。

今回紹介するツールは以下の 3 つ

1/2: Pyannotate で自動で型を付与する

https://github.com/dropbox/pyannotate を使用していきます。

pyannotate とは、Python の産みの親であるグイド氏が dropbox 社にいるときに「うち 120 万行コードあるんだけど型なくて辛くなってきたわ・・・そうだ、自動で付与しよう!」と思いついた事をきっかけに開発されたものです(参照)。

(ちなみに)同様のツールに Instagram の Monkeytype というツールがありますが非常に直感的に扱いづらい上(stub が大量に生まれるともう見てられない)、pyannotate は pytest に便乗してネストした関数にも付与できるのに対し Monkeytype はできません(非公式にライブラリあるが、ほぼ更新されてない)。興味あるかたは一度触ってみてはいかがでしょうか。

基本がわかる原始的な方法。手間がかかるので最終的には非推奨

なにか適当な python コードを用意しましょう。今回は適当に pyannotate の example を使います。

まず、pip install pyannotateで pyannotate をインストールしてください。

# pyannotate.py
# これがpyannotateのエントリポイントになります。

from gcd import main
from pyannotate_runtime import collect_types

if __name__ == '__main__':
    collect_types.init_types_collection() # ここでpyannotateが開始されます。
    with collect_types.collect():
        main() # ここで型情報が収集されます。仕組みとしてはsys.profileを取得するのですが、今回は割愛。
    collect_types.dump_stats('type_info.json') # ここで解析された型情報をjsonに落とし込みます。
# gcd.py
def main():
    print(gcd(15, 10))
    print(gcd(45, 12))

def gcd(a, b):
    while b:
        a, b = b, a%b
    return a

この状態で、一度あとで差分がわかりやすくなるように git init しておいてください。そしてpython pyannotate.pyを実行してみてください。

普通に gcd.main が実行されて 5,3 と出力されましたね。さて、同時に type_info.json というファイルが出力されたのがわかりますでしょうか?

[
  {
    "path": "gcd.py",
    "line": 1,
    "func_name": "gcd",
    "type_comments": ["(int, int) -> int"],
    "samples": 2
  },
  {
    "path": "gcd.py",
    "line": 6,
    "func_name": "main",
    "type_comments": ["() -> None"],
    "samples": 1
  }
]

ここに、gcd.py の型情報がまとまって出力されるのです。これを持って、実際に gcd.py に型情報を反映させましょう。

  • (注)なぜ一々 type_info.json というファイルに保存するのか?一気に gcd.py に反映しないのか?という疑問が生まれるかもしれません。それはこの型情報を元に、ユーザーが最後に調整する機会を得る為です。例えば Python は array や dict に複数の型を与える事ができますが、このとき Any と判断する可能性があります。しかしユーザーは「この List は(String, Int, String)で間違いない」という意思があるならばここで修正できるわけです。

pyannotate -w --py3 gcd.pyとコマンドを打ってみてください。

型情報が自動付与されたことがわかりますでしょうか。このとき、--py3オプションをなくせば python2 準拠の type hint を与える事も可能です。

このように実行するだけで走査した関数全てに型情報をあたえられるので非常に楽です。

ここから mypy と組み合わせて静的解析もしたいのですが、その前にもう少し楽な方法をご紹介します。

Pytest と組み合わせた方法。こっちが一番ラクで推奨

まずはpip install pytestで pytest をインストールしてください。

そして以下を conftest.py に追記、もしくはファイルそのものを追加してください。

# Configuration for pytest to automatically collect types.
# Thanks to Guilherme Salgado.

import pytest

def pytest_collection_finish(session):
    # pytestのhook。ここにpyannotateを差し込む。
    from pyannotate_runtime import collect_types
    collect_types.init_types_collection()

@pytest.fixture(autouse=True)
def collect_types_fixture():
    from pyannotate_runtime import collect_types
    collect_types.start()
    yield
    collect_types.stop()

def pytest_sessionfinish(session, exitstatus):
    # type_info.jsonに型情報をまとめる。
    from pyannotate_runtime import collect_types
    collect_types.dump_stats("type_info.json")

pytest 用のテストも用意します。

from gcd import gcd

def test_gcd():
    assert gcd(5, 10) == 5
    assert gcd(12, 45) == 3

この状態で pytest を走らせてみましょう。

pytest

先程は、指定した関数に付随したものしか付与させる事ができませんでした。この pytest の方法ならば test を一気に網羅して走査した関数全てに対して、一発で型情報を全て集める事ができます。

また、テスト関数に対しても型情報を付与できるというメリットもあります。

2/2: mypy で静的型チェックする

そのまま mypy を動かしても弱いので、いくつか設定を付与しましょう。.mypy.ini に下記を追記してください。

[mypy]
strict-optional = True
disallow-untyped-defs = True
disallow-untyped-calls = True

この状態でmypy gcd.py、もしくは src に分離してるならmypy ./srcと実行してください。mypy が正常終了してくれるのを確認できますでしょうか。

まとめ

以上の流れで保守性高めに python コーディングしていく事が可能です。
「型のメリットに対して、今から型を導入するのがコストに伴わない」という状況にぴったしな方法だと思います。

GitHubで編集を提案

Discussion

ログインするとコメントできます