Python
python3
mypy

Python の型アノテーションを自動生成する

型アノテーション生成ツール

MyPy Blog を読んでいて PyAnnotate の記事が気になったので調べてみました。せっかくなので調べたことのメモを簡単にまとめておきます。

2017年11月15日に Dropbox 社が PyAnnotate という Python の型アノテーションを自動生成するツールをオープンソースとして公開しました。この記事の中では Dropbox 社は120万行以上のソースコードに型アノテーションがついていると紹介しています。さらにそれは Dropbox 社のコードベースにおける総行数のうちの約20%であるそうです。

こういった大規模なコードベースに対して型アノテーションを人間の手でつけていくのは相当な労力を伴います。記事の中では型アノテーションがついていない既存コードを、あえて レガシー と呼び、そういった既存コードに対して型アノテーションをつけることを PyAnnotate は目的としています。一方で新規にコードを書くときは開発者が意図していることを伝える意味でも自分で型アノテーションを書くべきであるとも書かれています。

さらに別の型アノテーション生成ツール

PyAnnotate の詳細を調べる前にもう1つ別のツールを紹介します。

2017年12月15日に Instagram 社も MonkeyType という型ヒントのスタブファイルと型アノテーションを自動生成するツールをオープンソースとして公開しました。ここでも Python 3 のコードベースが100万行以上あり、MonkeyType を使ってすでにその3分の1のコードに型アノテーションをついていると紹介されています。

同じ時期に同じような用途のツールを Dropbox 社と Instgram 社という、主要言語として Python を採用して成功を収めている企業が公開したことで Hacker News でも議論が盛り上がっていたようです。

I must say this is the first time I've been disappointed with the quality of discussion on HN.

冒頭で「初めて Hacker News の議論の質にがっかりしたと言わざるをえない」というコメントが目に入ります。私自身、すべてのコメントに目を通しているわけではありませんが、こういったコメントに至った背景として Hacker News の議論の中心が「静的型付け vs. 動的型付け」、大規模アプリケーションに Python は向いていないといった議論になってしまい、記事の主題である MonkeyType の機能や Python の型ヒントの応用例の1つとしての議論があまり行われていないことではないかと思います。

閑話休題。せっかくなので本記事では PyAnnotate と MonkeyType 双方のツールについて調べてみたいと思います。

PyAnnotate と MonkeyType の共通点

それぞれのツールの説明で後述しますが、PyAnnotate と MonkeyType は目的こそ同じではあるものの、ツールの内部実装をみていくと多くの点が異なっていることが分かります。そのような中での大きな共通点として、プロファイラを使って実際に Python コードを実行して関数呼び出しの引数と返り値の型情報を収集するというアプローチが同じです。

このプロファイラは sys.setprofile を使って設定します。標準ライブラリのドキュメントによると、ここで設定したプロファイラ関数はカレントスタックから frameeventarg を取得できるとあります。

試しに動かしてみましょう。

import inspect
import sys

def add(x, y):
    return x + y

def print_trace(frame, event, arg):
    print('=' * 72)
    print('frame.f_code.co_name:', frame.f_code.co_name)
    print('event:', event)
    print('arg:', arg)
    print('inspect.getargvalues(arg):', inspect.getargvalues(frame))

def main():
    add(3, 2)

if __name__ == '__main__':
    sys.setprofile(print_trace)
    main()

実行すると以下のように frame オブジェクトや event/arg の情報を出力します。

$ python profiler.py 
========================================================================
frame.f_code.co_name: main
event: call
arg: None
inspect.getargvalues(arg): ArgInfo(args=[], varargs=None, keywords=None, locals={})
========================================================================
frame.f_code.co_name: add
event: call
arg: None
inspect.getargvalues(arg): ArgInfo(args=['x', 'y'], varargs=None, keywords=None, locals={'y': 2, 'x': 3})
========================================================================
frame.f_code.co_name: add
event: return
arg: 5
inspect.getargvalues(arg): ArgInfo(args=['x', 'y'], varargs=None, keywords=None, locals={'y': 2, 'x': 3})
========================================================================
frame.f_code.co_name: main
event: return
arg: None
inspect.getargvalues(arg): ArgInfo(args=[], varargs=None, keywords=None, locals={})
========================================================================

こういった情報から関数呼び出しの引数と返り値の型情報を調べられそうな雰囲気がしますね。

プロファイラのオーバーヘッド

sys.setprofile は関数呼び出しをトレースして追加処理を行うため、このプロファイル処理自体がオーバーヘッドになります。PyAnnotate/MonkeyType 両方とも pytest でテスト実行時に型情報を取得する例が紹介されているので試しにどのぐらいのオーバーヘッドになるか計測してみます。

特に意味はありませんが、適当な分量のコードベースとして flask を対象に pytest でテストを実行して計測します。

$ git clone https://github.com/pallets/flask.git
$ cd flask
$ find flask -name "*.py" -exec wc -l {} \+;
...
    7735 total

プロファイラを設定しない状態で pytest を実行すると、私の環境ではだいたい9.7秒ぐらいでした。

$ time py.test --cache-clear tests/
real    0m9.699s
user    0m8.548s
sys     0m0.754s

PyAnnotate のプロファイル

PyAnnotate のリポジトリに pytest の conftest.py に追加する設定がサンプルとして用意されています。

この設定を flask の conftest.py に追加して実行してみます。メモリ関連のテストが1つ fail したのですが、ここでは無視します。24.5秒ぐらいかかるようになりました。オーバーヘッドによりテスト実行が約2.5倍かかるようになりました。

$ time py.test --cache-clear tests/
real    0m24.538s
user    0m23.127s
sys     0m1.216s

MonkeyType のプロファイル

MonkeyType は cli ツールで指定したモジュールを runpy で実行するときに自動的にプロファイラを設定するので conftest.py にプロファイラの切り替えを設定する必要はありません。例えば、pytest を実行したいときは以下のように実行します。

$ time monkeytype run $(which pytest) --cache-clear tests/
real    0m28.371s
user    0m26.766s
sys     0m1.146s

28.3秒ぐらいと PyAnnotate に比べると少しオーバーヘッドが大きいですが、大した違いではありません。

大雑把な感覚として、PyAnnotate/MonkeyType でプロファイラを設定すると、設定しない状態と比べて実行時間が2-3倍程度になると考えておけばいいのではないかと思います。

PyAnnotate

型情報の収集後にその情報を扱うための pyannotate という cli ツールが用意されています。

$ pyannotate --help
usage: pyannotate [-h] [--type-info FILE] [-p] [-w] [-j N] [-v] [-q] [-d] [-a]
                  [FILE [FILE ...]]

positional arguments:
  FILE                  Files and directories to update with annotations

optional arguments:
  -h, --help            show this help message and exit
  --type-info FILE      JSON input file (default type_info.json)
  -p, --print-function  Assume print is a function
  -w, --write           Write output files
  -j N, --processes N   Use N parallel processes (default no parallelism)
  -v, --verbose         More verbose output
  -q, --quiet           Don't show diffs
  -d, --dump            Dump raw type annotations (filter by files, default
                        all)
  -a, --auto-any        Annotate everything with 'Any', without reading
                        type_info.json

機能

型情報の収集

  • sys.setprofile を使って実行時に型情報を収集する
  • collect_types.resume()collect_types.pause() でテスト関数単位に制御できる
    • pause した後に collect_types.dump_stats() で収集した型情報をファイル出力する

収集した型情報を利用

  • pyannotate コマンドがある
  • type_info.json から型コメントをソースコードに挿入する

  flask 配下すべて

$ pyannotate --type-info type_info.json --write flask

  flask/app.py のみ

$ pyannotate --type-info type_info.json --write flask/app.py
  • type_info.json の型情報を dump する

  flask 配下すべて

$ pyannotate --type-info type_info.json --dump flask

  flask/app.py のみ

$ pyannotate --type-info type_info.json --dump flask/app.py

出力フォーマット

  • JSON 形式
    • type_info.json の例
    {
        "path": "flask/app.py",
        "line": 773,
        "func_name": "Flask.update_template_context",
        "type_comments": [
            "(Dict[str, str]) -> None",
            "(Dict[str, bool]) -> None",
            "(Dict[str, Union[markupsafe.Markup, str]]) -> None",
            "(Dict) -> None",
            "(Dict[str, int]) -> None"
        ],    
        "samples": 49
    },  

対象とする Python のバージョン

  • Python 2 のソースコードを対象
  • Python 2/3 両対応のソースコードを対象
  • Python 3 の型アノテーションは未対応

Python 3 code generation #4 に issue が登録されているように、現時点 (2018-05) ではまだ Python 3 の型アノテーションを生成することはできません。その背景として Dropbox 社の用途としては Python 2 ベースのソースコードに型コメントをつける必要性があったからだと Guido 氏は述べています。もちろん今後の展望として Python 3 の型アノテーションもサポートする予定ではあるものの、その優先度は Dropbox 社の意向によって影響されそうにも受け取れます。

実装の詳細

PyAnnotate の実装は基本的には標準ライブラリを使って実装されています。setup.py に定義されている依存パッケージは以下になります。

install_requires = [
   'six',                                                    
   'mypy_extensions',                                        
   'typing >= 3.5.3; python_version < "3.5"'                 
],                            

型コメントをソースコードに挿入する処理は、StdoutRefactoringTool を再利用して、任意の fixers を実装することで型コメントをソースコードに挿入する機能を実現しています。

標準ライブラリドキュメントによると、lib2to3 の API は stable ではないとありますが、今後 PyAnnotate が成功を収めたら lib2to3 に型コメントや型アノテーションを挿入する機能が追加される可能性もあるのではないかとも思ったりします。

注釈 lib2to3 API は安定しておらず、将来、劇的に変更されるかも知れないと考えるべきです。
lib2to3 - 2to3's library

注意事項

MyPy Blog の記事の中では、PyAnnotate は大規模なアプリケーションの実行時オーバーヘッドによる実行時間の懸念からすべての呼び出しを inspect しているわけではないと書かれています。

関数のシグネチャにおける *args や **kwargs を手動で修正する必要があったり、TypedDict などは実行時にみえないので扱わないといったことなど、いくつかの機能制限があることも紹介されています。

例えば、以下の request_context()environ をディクショナリで受け取るとき、PyAnnotate だと Dict[str, str] (または Dict[str, Any]) といった型コメントになります。

    def request_context(self, environ):                                              
        # type: (Dict[str, str]) -> RequestContext         

MonkeyType のスタブ生成だと Dict[str, Union[str, Tuple[int, int], BytesIO, EncodedFile, bool]] という型アノテーションになります。これはこれで本当に正しいのかという懸念はありますが、、、。

    def request_context(
        self,
        environ: Dict[str, Union[str, Tuple[int, int], BytesIO, EncodedFile, bool]]
    ) -> RequestContext: ... 

生成する型アノテーションの精度は実際に運用してみないとなんともいえないですね。

MonkeyType

monkeytype という cli ツールが用意されています。

pyannotate は収集した型情報を扱うための cli ツールでしたが、monkeytype は型情報を収集するときもこの cli ツールを使って実行します。

$ monkeytype --help
usage: monkeytype [-h] [--disable-type-rewriting]
                  [--include-unparsable-defaults | --exclude-unparsable-defaults]
                  [--limit LIMIT] [--config CONFIG]
                  {run,apply,stub,list-modules} ...

Generate and apply stub files from collected type information.

optional arguments:
  -h, --help            show this help message and exit
  --disable-type-rewriting
                        Show types without rewrite rules applied (default:
                        False)
  --include-unparsable-defaults
                        Include functions whose default values aren't valid
                        Python expressions (default: False, unless changed in
                        your config)
  --exclude-unparsable-defaults
                        Exclude functions whose default values aren't valid
                        Python expressions (default: True, unless changed in
                        your config)
  --limit LIMIT, -l LIMIT
                        How many traces to return from storage (default: 2000,
                        unless changed in your config)
  --config CONFIG, -c CONFIG
                        The <module>:<qualname> of the config to use (default:
                        monkeytype_config:CONFIG if it exists, else
                        monkeytype.config:DefaultConfig())

commands:
  {run,apply,stub,list-modules}
    run                 Run a Python script under MonkeyType tracing
    apply               Generate and apply a stub
    stub                Generate a stub
    list-modules        Listing of the unique set of module traces

機能

型情報の収集

  • sys.setprofile を使って実行時に型情報を収集する
    • monkeytype run コマンドに実行可能な Python モジュールをパラメーターで渡して型情報を収集する
$ monkeytype run $(which pytest) tests/

収集した型情報を利用

  • monkeytype.sqlite3 から型ヒントのスタブファイルを生成する
$ monkeytype stub flask.blueprints > flask/blueprints.pyi
  • monkeytype.sqlite3 から型アノテーションをソースコードに挿入する
$ monkeytype apply flask.blueprints
  • monkeytype.sqlite3 からモジュール一覧を出力する
$ monkeytype list-modules 
flask._compat
flask.app
flask.blueprints
...

モジュール一覧は monkeytype.sqlite3 から取得している

sqlite> select module from monkeytype_call_traces group by module order by date(created_at) desc;

出力フォーマット

  • sqlite データベース
    • デフォルトで用意されているのが SQLiteStore (カスタマイズ可能)
    • monkeytype.sqlite3 の例
sqlite> select count(*) from monkeytype_call_traces where module='flask.app' and qualname='Flask.update_template_context';                                   
56
sqlite> select count(*) from (select module, qualname, arg_types, return_type, yield_type from monkeytype_call_traces where module='flask.app' and qualname='Flask.update_template_context' group by module, qualname, arg_types, return_type, yield_type);
8
sqlite> select module, qualname, arg_types, return_type, yield_type from monkeytype_call_traces where module='flask.app' and qualname='Flask.update_template_context' group by module, qualname, arg_types, return_type, yield_type;
flask.app|Flask.update_template_context|{"context": {"elem_types": [{"module": "builtins", "qualname": "str"}, {"elem_types": [{"module": "builtins", "qualname": "str"}, {"elem_types": [{"module": "builtins", "qualname": "str"}], "module": "typing", "qualname": "List"}], "module": "typing", "qualname": "Dict"}], "module": "typing", "qualname": "Dict"}, "self": {"module": "conftest", "qualname": "Flask"}}|{"module": "builtins", "qualname": "NoneType"}|
flask.app|Flask.update_template_context|{"context": {"elem_types": [{"module": "builtins", "qualname": "str"}, {"module": "builtins", "qualname": "bool"}], "module": "typing", "qualname": "Dict"}, "self": {"module": "conftest", "qualname": "Flask"}}|{"module": "builtins", "qualname": "NoneType"}|
flask.app|Flask.update_template_context|{"context": {"elem_types": [{"module": "builtins", "qualname": "str"}, {"module": "builtins", "qualname": "int"}], "module": "typing", "qualname": "Dict"}, "self": {"module": "conftest", "qualname": "Flask"}}|{"module": "builtins", "qualname": "NoneType"}|
flask.app|Flask.update_template_context|{"context": {"elem_types": [{"module": "builtins", "qualname": "str"}, {"module": "builtins", "qualname": "int"}], "module": "typing", "qualname": "Dict"}, "self": {"module": "flask.app", "qualname": "Flask"}}|{"module": "builtins", "qualname": "NoneType"}|
flask.app|Flask.update_template_context|{"context": {"elem_types": [{"module": "builtins", "qualname": "str"}, {"module": "builtins", "qualname": "str"}], "module": "typing", "qualname": "Dict"}, "self": {"module": "conftest", "qualname": "Flask"}}|{"module": "builtins", "qualname": "NoneType"}|
flask.app|Flask.update_template_context|{"context": {"elem_types": [{"module": "typing", "qualname": "Any"}, {"module": "typing", "qualname": "Any"}], "module": "typing", "qualname": "Dict"}, "self": {"module": "conftest", "qualname": "Flask"}}|{"module": "builtins", "qualname": "NoneType"}|
flask.app|Flask.update_template_context|{"context": {"elem_types": [{"module": "typing", "qualname": "Any"}, {"module": "typing", "qualname": "Any"}], "module": "typing", "qualname": "Dict"}, "self": {"module": "flask.app", "qualname": "Flask"}}|{"module": "builtins", "qualname": "NoneType"}|
flask.app|Flask.update_template_context|{"context": {"elem_types": [{"module": "typing", "qualname": "Any"}, {"module": "typing", "qualname": "Any"}], "module": "typing", "qualname": "Dict"}, "self": {"module": "test_templating", "qualname": "test_custom_template_loader.<locals>.MyFlask"}}|{"module": "builtins", "qualname": "NoneType"}|

対象とする Python のバージョン

  • Python 3 のソースコードを対象
    • Python 3 の型アノテーションのみを生成
  • MonkeyType を実行するためには Python 3.6.2 以上
    • 後述する retype が 3.6.2 以上の制限があるためと思われる

Note: to retype modules using f-strings you need to run on Python 3.6.2+ due to bpo-23894.

実装の詳細

PyAnnotate が型情報の収集するための sys.setprofile の設定を collect_types モジュールで明示的に設定するのに対して、MonkeyType では monkeytype run コマンドのハンドラーが runpy ライブラリを使うことでその機能を提供しています。

これが便利なときもあるでしょうし、__main__ がハードコードされているので実行可能なモジュールであることを前提としています。

def run_handler(args: argparse.Namespace, stdout: IO, stderr: IO) -> None:          
    # remove initial `monkeytype run`                                               
    old_argv = sys.argv.copy()                                                      
    try:                                                                            
        with trace(args.config):                                                    
            if args.m:                                                              
                sys.argv = sys.argv[3:]                                             
                runpy.run_module(args.script_path, run_name='__main__', alter_sys=True)
            else:                                                                   
                sys.argv = sys.argv[2:]                                             
                runpy.run_path(args.script_path, run_name='__main__')               
    finally:                                                                        
        sys.argv = old_argv  
def trace(config: Optional[Config] = None) -> ContextManager:                       
    """Context manager to trace and log all calls.                                  

    Simple wrapper around `monkeytype.tracing.trace_calls` that uses trace          
    logger, code filter, and sample rate from given (or default) config.            
    """                                                                             
    if config is None:                                                              
        config = get_default_config()                                               
    return trace_calls(                                                             
        logger=config.trace_logger(),                                               
        code_filter=config.code_filter(),                                           
        sample_rate=config.sample_rate(),                                           
    )

@contextmanager                                                                     
def trace_calls(                                                                    
    logger: CallTraceLogger,                                                        
    code_filter: Optional[CodeFilter] = None,                                       
    sample_rate: Optional[int] = None,                                              
) -> Iterator[None]:                                                                
    """Enable call tracing for a block of code"""                                   
    old_trace = sys.getprofile()                                                    
    sys.setprofile(CallTracer(logger, code_filter, sample_rate))                    
    try:                                                                            
        yield                                                                       
    finally:                                                                        
        sys.setprofile(old_trace)                                                   
        logger.flush()     

また PyAnnotate がほぼ標準ライブラリで型コメントを生成する機能を実装していたのと対して、スタブファイルから型アノテーションを生成する処理がサードパーティの retype に依存しています。

install_requires=['retype']

たまたま flask.app の型アノテーションを生成しようとしたときに retype がエラーになりました。ライブラリが別だと、こういったときに原因調査するのが煩雑になるかもしれません (今回はこれ以上調べていない) 。

$ monkeytype apply flask.app
...
ERROR: Failed applying stub with retype:
error: /path/to/flask/flask/app.py: Annotation problem in function 'handle_exception': 1721:1: .pyi file is missing return value and source doesn't provide it either

そして MonkeyType は Config/CallTraceStore をカスタマイズして拡張することを想定して作られているようにみえます。Instagram 社の記事の中でも同社の運用では、カスタムの CallTraceStore を作成して、facebook SCUBA というデータ解析のプラットフォームと連携していることが紹介されています。

How we use MonkeyType at Instagram

We choose a small random sample of production web requests and turn on MonkeyType tracing via a Django middleware. Traces are stored and retrieved from SCUBA, Facebook’s data analysis platform, using a custom CallTraceStore. We run tracing constantly, so we are always adding new type traces from production. Since production code changes frequently, this keeps our traces up to date.

まとめ

実際に PyAnnotate/MonkeyType を使って運用しているわけではないのでツールが生成する型アノテーションの精度や品質などについては分かりません。

PyAnnotate は Python 2 のコードベースが大きい、または Python 2 からの移行を考慮するとよい選択肢です。また PyAnnotate が生成した型コメントを使って完全自動化するというよりも、それをベースにしてプログラマーが必要に応じて手を入れるぐらいのレベル感を想定しているような気がします。そしてコア開発者として Python の作者である Guido van Rossum 氏が関わっていることから、Python 本体や mypy との親和性を考慮しながら開発が進むのではないかという展望も期待できたりします。

一方で MonkeyType は Python 3 の型アノテーションを生成できるというメリットはあるものの、高い拡張性ゆえに複雑であることの懸念があります。一応はドキュメントもありますが、現時点ではドキュメントだけをみてカスタマイズできるレベルのものではないと私は思います。そして CI ツールと高度なインテグレーションを想定して作られているようにもみえます。

どちらのツールもまだ公開されて日が浅いことから機能が不足していたり、用途が曖昧だったりしているようにみえます。現時点では対象となる Python のコードベースのバージョンや開発者自身の好みでツールを選択して試してみるのでよいように思います。

リファレンス