先日、引数に @Nullable アノテーションが付いた引数をもつ関数をリファクタリングして、関数分割してコミットしたら、ビルドサーバーに仕掛けられた FindBugs™ - Find Bugs in Java Programs に、@Nullable が付いてるのに Null チェックしてないよと怒られました (; ;)
Java のコードに慣れないため、Eclipse のお告げに従ってリファクタリングし、Eclipse がチェックできなかったものを見逃してしまったわけです。もちろん修正するのは簡単だけど、何か恥ずかしい。
ちょっと調べたら、Eclipse プラグインもあるようです *1 。Eclipse に FindBugs プラグインをインストールしてみようー。
。。。
( ゚д゚)ハッ! 間違えた!
今日は 2011 Pythonアドベントカレンダー(Python3) を書くよ!
最も分かりやすい利用例としてはテストですね。そこで、ランダム自動テストをやってみましょう。
QuickCheck: An Automatic Testing Tool for Haskell の Python 実装である paycheck が Python3 対応しています。paycheck を使うと、データ駆動テストを簡単に実装できます。本稿では paycheck と nose を使ってランダムなデータ駆動テストをやってみます。
その前に開発環境を作らないと、、、
そう言えば virtualenv も Python3 対応していました。仮想環境を作って、paycheck と nose をインストールします。
$ /opt/local/Library/Frameworks/Python.framework/Versions/3.2/bin/virtualenv --distribute ~/.virtualenvs3/advent
$ ~/.virtualenvs3/advent/bin/easy_install paycheck nose
$ source ~/.virtualenvs3/advent/bin/activate
(advent)$ which python
/Users/t2y/.virtualenvs3/advent/bin/python
(advent)$ python
Python 3.2.2 (default, Nov 5 2011, 19:51:07)
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import paycheck
それから IPython も使いたいですね。
$ sudo port install py32-ipython
IPython に virtualenv 環境を考慮したライブラリパスを設定します。このコードはどっかからのコピペです。print 文ではなく print 関数ですよ。
(advent)$ vi ~/.ipython/profile_python3/ipython_config.py
import site
from os import environ
from os.path import join
from sys import version_info
if 'VIRTUAL_ENV' in environ:
virtual_env = join(environ.get('VIRTUAL_ENV'),
'lib',
'python%d.%d' % version_info[:2],
'site-packages')
site.addsitedir(virtual_env)
print('VIRTUAL_ENV ->', virtual_env)
del virtual_env
del site, environ, join, version_info
(advent)$ ipython3-3.2
...
VIRTUAL_ENV -> /Users/t2y/.virtualenvs3/advent/lib/python3.2/site-packages
In [1]: import paycheck
はい。準備が整いました。ちゃんとした Python3 環境がなかったんです(> <)
とにかく関数アノテーションを実際に書いてみる
試しに書いてみる。型のみを記述するなら、そんなに気持ち悪くないかな (違和感を感じない) 。
(advent)$ vi others.py
__all__ = ["foo", "bar", "baz"]
def foo(a: str, b: int, c: {str: int}, d: float) -> tuple:
return a, b, c, d
def bar(a: str, b: int, k: str="keyword") -> str:
return "'{}' + '{}' + '{}'".format(a, str(b), k)
def baz(a: str, b: int, *args: tuple, **kwargs: dict) -> list:
return [a, b, args, kwargs]
__annotations__ の中身も覗いてみます。
In [2]: foo.__annotations__
Out[2]: {'a': str, 'b': int, 'c': {str: int}, 'd': float, 'return': tuple}
In [3]: bar.__annotations__
Out[3]: {'a': str, 'b': int, 'k': str, 'return': str}
In [4]: baz.__annotations__
Out[4]: {'a': str, 'args': tuple, 'b': int, 'kwargs': dict, 'return': list}
普通のデータ駆動テストをやってみる
先に paycheck の使い方を覚えておきましょう。
(advent)$ vi tests/test_with_paycheck_sample.py
from paycheck import with_checker
@with_checker(str, str, number_of_calls=3, verbose=True)
def test_func(a, b):
assert(isinstance(a + b, str))
こんな感じにコードを書くと test_func の引数にランダムな str 型の文字列を渡してくれます。verbose オプションを True にすると、ランダムに生成された入力値が標準エラー出力に表示されます。
(advent)$ nosetests tests/test_with_paycheck_sample.py
0: ('64+p57P8:G]NI.B5K', 'b#-O9SS#0#Ohq')
1: ('\\l<?[f$:}ld|1|Y<rd;XEi/^{)`', 'F*#(W,v6h2')
2: ('-9PBxyd(0y6j~/', 'CJMZPEIRn^>~#2')
.
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK
応用としては、irange や frange でその型の範囲指定を行ったり、choiceof で任意のリストから値を選択できます。
from paycheck import choiceof, irange, with_checker
@with_checker(irange(1, 10), number_of_calls=3, verbose=True)
def test_func2(i):
assert(i <= 10)
0: (9,)
1: (2,)
2: (3,)
@with_checker(choiceof([3, 5]), number_of_calls=3, verbose=True)
def test_func3(i):
assert(i == 3 or i == 5)
0: (3,)
1: (5,)
2: (5,)
その他にも positive_float や non_negative_float といったものもあるようです。期待値の実行結果のデータ駆動テストにも便利そうです。
ランダムデータ駆動テストを自動化する
さらにモジュールを自動的に探してきて、そのモジュールで提供されている関数をテストしてくれると便利だったりしないかな?シグネチャさえ分かればできるよ!ようやく関数アノテーションの出番です。
サンプル実装として以下のようなものを作ってみました。テストディレクトリの親ディレクトリから "*.py" ファイルを探してきて、そのモジュールの __all__ で提供されている関数のシグネチャからテストを実行します。
1
2
3 import glob
4 import imp
5 import inspect
6 import sys
7 from os.path import (abspath, dirname)
8
9 from nose.tools import *
10 from paycheck import with_checker
11
12 CHECKER_PARAMETER = {
13 "number_of_calls": 3,
14 "verbose": True,
15 }
16
17 def debug(msg: str) -> None:
18 sys.stderr.write("{}\n".format(msg))
19
20 def get_modules(target_dir: str):
21 for pyfile in glob.glob("{}/*.py".format(target_dir)):
22 mod_name = pyfile.split("/")[-1].replace(".py", "")
23 mod = imp.load_module(mod_name, *imp.find_module(mod_name))
24 yield mod
25
26 def get_functions_with_ann(modules):
27 funcs = (getattr(mod, name) for mod in modules for name in mod.__all__)
28 for func in funcs:
29 if hasattr(func, '__annotations__'):
30 yield func
31
32 def test_random_with_paycheck() -> None:
33 def tester(*args, **kwargs):
34 result = func(*args, **kwargs)
35 ok_(isinstance(result, ret_type))
36
37 base_dir = dirname(dirname(abspath(__file__)))
38 for func in get_functions_with_ann(get_modules(base_dir)):
39 debug("target function: {}".format(func.__name__))
40 spec = inspect.getfullargspec(func)
41 args = spec.args
42 if spec.varargs:
43 args.append(spec.varargs)
44 if spec.varkw:
45 args.append(spec.varkw)
46 ret_type = spec.annotations.get("return")
47 types = [spec.annotations[arg] for arg in args]
48 with_checker(*types, **CHECKER_PARAMETER)(tester)()
ディレクトリ構成は以下です。実行してみましょう。
(advent)$ tree .
.
├── others.py
└── tests
├── test_with_annotation.py
(advent)$ nosetests tests/test_with_annotation.py
target function: foo
0: ("O3FND..fOSWv{KWeW:gl8'%k|L", 7607741906685156877, {'': 8791364593896247432, 'A': 7981434242837100514, '>KbMIsq#0kV;U?yxj2s~g,[%LQyrE': -190598769762457072, 'S7J:Um?<{ZtN:L@': -7691133294110638585, '0eV71S07lh~e>rb5P_6zE;5': 1101451838899520496, '*qU4~J*': 6338273523869299236, '|wMLD^\\ysKOw\\c6&S!Be3|hcz': 5053081943822034822, '{C<': 1734444387651285061, '$As^l,_C/av)}1R&HNz7sYd\\1d;.ex': -885374290895090654, '(qs$Ej]f': -8267062632669025484, ")'lOY533cm;jjHP5oI{LVCmRR[": -8668668576751442202, '=rACn7|@C': 478968652357174282, '5SNk0l\\4': -867212168323926037, 'fbB3#+xwU|': 8473818803708212295, 'd2.xgfT.V*<(y': -6515904853273909217, "KGDeofip:[_~M+K~>!'": -3589212816856071640, 'ZgM~': -602505023626250450, "|IJGj~';YFE-1wPPrEs%\\'-h": 4094644477640025745, "r!%n%'qohCttnXe8=7SDi^|t3": 427941587074733809, 'h%': -1809851284353770487}, -0.00023076482227383914)
1: ('/Qhp"NzOc.[|5CiJ', 5190099172656242926, {'': 6382145368304854615, 'x.0?lg@l': -4519850178140629357, 'u?B\\D2': -6081180918953419200, 'w+8inf3XnQ)wF+R8Mx;': -5279979493522305960, '=x0Y"{v': -1051360238739264279, 'LXZv<vV': 8490996434245906021, 'Sa$H*ed^,`$-EZ_%': -6937052124172693463, 'Q);n5': 60653761990170108, "\\`F{`aQ5w'": 1358220429869542064, 'j,,EVP=2WXua8)<oW-W[UngZ8p': 6151527201046578895, "HjY4H:oC'38?.aCO": -5710875614350879758, '0': -3166246628482595309, '#PIc2.': -615037772330393927, 'k%/': -8539311459395790283, 'tx<1': -7016431055285318858, 'Y$"L}EDq&A@msm': -7487772718733717165, 'Epz<eD=qzxRP': -5309516819741565453, 'B>Z95&ON:G>\\rgakkK/XQ^J#': 1080556375731418693, '!x': -8305477197940126401, 'b"m|\\`.$LQ)x`w+q%L6s_a,9\'': -5627647156759687669, 'c': -8050980599323942487, 'K4m\\^HW\\Ki>x_Tr': 1451298324637113436, '9;5uPcy43@7qr[': 7557790634460355432, 'jV': -6775386229302154514, '5Mu[,g': 7789805996343655479, 'ln1MH2qtO-(#8@l_W]P': 7934835116394274442, 'Di64M>{;(t\\/YJ4=Q*"X^>qowh': 3744629399181575512, '7].i': -1231696801069995861}, 0.021354448475725422)
2: ('@KGvLsf{CXEkwudbb$&a>t?`q&-tL', 2813673244267029793, {'m4#3<\\^8=tK': 2445679757000420077}, -0.03955141006906784)
target function: bar
0: ('X9|wG.n+xJ1Uzj?`q]+\\6>C"8_', 7102757083111770696, '%Qd|@')
1: ('fw"F', -508039826724708831, 'v0W6a}u[""@#?o;ziXOd-eFv=+"')
2: ('AUI6|BTLp%1K$u', -3393106434267748224, 'O.')
target function: baz
.
----------------------------------------------------------------------
Ran 1 test in 0.005s
OK
ちゃんとカレントディレクトリの others.py を探し出してテストを実行してくれました。
おや!?
foo と bar はテストが実行されているけど、baz のテストは実行されていないようです。
def foo(a: str, b: int, c: {str: int}, d: float) -> tuple:
...
def baz(a: str, b: int, *args: tuple, **kwargs: dict) -> list:
...
詳しく調べていませんが、paycheck に渡すタプルやディクショナリは (int, int) や {str: str} といった記述をしないと、入力となるテストデータを生成してくれないようです。
次にテスト関数をみてみます。
33 def tester(*args, **kwargs):
34 result = func(*args, **kwargs)
35 ok_(isinstance(result, ret_type))
このテストで検証できるのは、様々な入力データに対して以下の内容です。
- 関数を実行してエラーが発生しない
- 期待した 型 の返り値が取得できる
つまり、予期していない入力データによるエラーがないことをテストできます。
また with_checker へ渡す型情報の引数は、テストする関数に指定された引数の順番通りに指定する必要があります。
...
40 spec = inspect.getfullargspec(func)
41 args = spec.args
...
47 types = [spec.annotations[arg] for arg in args]
48 with_checker(*types, **CHECKER_PARAMETER)(_tester)()
inspect.getfullargspec を使うと、アノテーションも含めた関数の全情報を取得できます。引数の順番が保持されたリストを取得したり、可変長引数 (*args や **kwargs) の有無も分かります。
In [12]: inspect.getfullargspec(baz)
Out[12]: FullArgSpec(args=['a', 'b'], varargs='args', varkw='kwargs', defaults=None, kwonlyargs=[], kwonlydefaults=None,
annotations={'a': <class 'str'>, 'b': <class 'int'>, 'args': <class 'tuple'>, 'return': <class 'list'>, 'kwargs': <class 'dict'>})
まとめ
関数アノテーションはドキュメントとしても有用ですし、静的解析のテクニックを応用したライブラリ等も今後出てくるでしょう。ふと気付いたことで、ジェネレーターを表すアノテーションが分かりませんでした。まだ決まってないのかな。
それと、初めて paycheck を使ってみましたが、関数アノテーションと組み合わせて相性の良さそうなところが見えました。1点だけ残念だったのは、with_checker 内でエラーが発生すると、例外を発生させて、そこでテストが終了してしまう点です。データ駆動テストとしては、ある入力データのテストがエラーになっても、その他の全入力データの結果もまとめて見れた方が便利です。ちょっと使ってみて、その点を改善できると良いなと思いました。あとドキュメントもほしいです。
次のアドベントカレンダーは @ です。以前から Python3 の発表をされていたので楽しみです。