型アノテーション生成ツール
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 を使って設定します。標準ライブラリのドキュメントによると、ここで設定したプロファイラ関数はカレントスタックから frame と event と arg を取得できるとあります。
- sys.setprofile
-
threading.setprofile
- threading.setprofile にプロファイラ関数を設定しているのは PyAnnotate のみ
試しに動かしてみましょう。
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;
-
利用する CallTraceStore やコンテキスト設定などを monkeytype.config.Config を継承してカスタマイズできる
-
出力フォーマットは CallTraceStore interface を実装することでカスタマイズできる
出力フォーマット
- 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 のコードベースのバージョンや開発者自身の好みでツールを選択して試してみるのでよいように思います。