mypy で静的型付け Python プログラミング

Pythonへの型アノテーション導入を目指すGuido van Rossum氏

mypy の型アノテーションの構文を Python に取り込もうとメーリングリストで提案したニュースが話題になりました。今夏に話題になったニュースを今冬にフォローするという季節外れ感ですが、ずっと興味はあって「あとで読む」状態になっていたのを調べてみました。

基本的には mypy のドキュメントから自分の興味のあるところをかいつまんで説明しています。分かりにくいところがあれば原文を参照してください。

Welcome to Mypy documentation!

mypy とは

mypyPython の静的型チェッカー (static type checker) です。wikipedia:静的コード解析wikipedia:en:Lint (software) ツールの一種だと説明されています。

Python2 にはなく、Python3 で PEP 3107 - Function Annotations が追加されました。Python 3.0 がリリースされたのが 2008-12-03 なので6年ぐらい前のことです。

>>> def f(i: int, s: str) -> None: pass
>>> f.__annotations__
{'s': <class 'str'>, 'return': None, 'i': <class 'int'>}

但し、これは情報として保持しているだけで関数アノテーションをどう活用するかはライブラリやサードパーティツールに委ねられていました。mypy はそんなツールの1つであり、先のコードのように関数アノテーションに型シグネチャを記述することで静的解析に利用しようといったものです。重要な点の1つとして、関数アノテーションは実行時に影響を及ぼさないため、仮にこの型シグネチャの定義が間違っていたとしても何も起こりません。

さらに関数定義だけでなく、コレクションや変数定義にも型シグネチャを付加する仕組みがあり、それらをまとめて型アノテーション (type annotation) と呼ぶようです。Python 標準の組み込み型だけでは型シグネチャを記述できないため、Built-in types に mypy で定義された組み込み型が紹介されています。

実際に mypy の型アノテーションの宣言を見てみましょう。

>>> from typing import Dict, List
>>> l = List[int]()       # Create empty list with type List[int]
>>> l
[]
>>> d = Dict[str, int]()  # Create empty dictionary (str -> int)
>>> d
{}

これは空のリストとディクショナリの型アノテーションを宣言しています。型アノテーションを定義しつつも実際に空のリストが作成されています。typing.py に型アノテーションのための型定義があるので少し覗いてみます。

class TypeAlias:
    """Class for defining generic aliases for library types."""

    def __init__(self, target_type):
        self.target_type = target_type

    def __getitem__(self, typeargs):
        return self.target_type

List = TypeAlias(list)
...

TypeAlias に組み込み型を渡して self[key] で評価されたときにその組み込み型を返します。

>>> List is list
False
>>> List[int] is list
True

シンプル且つ実際に動く Python のコードな、うまい仕組みですね。

Python3 への導入

Guido 自ら提案したせいか、メーリングリストで多くの議論が行われたようです。興味がある方は以下のメールのスレッドを追いかけてみると良いと思います。私はそこまで根気がなくて発端となった Guido の提案メールのみを読んでみました。

前者のメールの要約は冒頭に紹介した InfoQ の内容なのでそれを参照してください。後者のメールは、その提案に対する反応が3つの質問に分類されるとみなし、それぞれの質問に対する Guido の回答のようです。いくつか要点を抜き出して意訳してみます。

(A) 選択的静的型付け (Optional static typing) の標準化は本当に必要なのか?

多くの人が肯定的であり、数人は不要だと判断しているが、反対している人の大半はその代替となる自分の提案があるようにみえる。確信はないのだけれど、自分の直感で言うと、できるだけこれはオプションにしておきたい。どんなケースでも、それが本当に価値があるものかどうかは実際に作り始めるまでは分からないし、これは大丈夫だろうと信じて推進するしかない。前提としていることを1つ付け加えると、主な用途は lint 的なこと、IDE、ドキュメント生成になるだろう。これらに1つ共通して言えることは、型チェックが失敗したとしてもプログラムは実行できるし、型を追加することがそのパフォーマンスを下げないということだ (上げることもないが :-) 。

(B) 選択的静的型付け (Optional static typing) の標準としてどんな構文にするか?

たくさんの興味深い質問がみられた。実現方法として、3つか4つの "families" があって、まずやることはその系統を選択することだ。

1. mypy family
関数アノテーションの特徴を活かし、Python の構文としてもそのまま有効だ。標準ライブラリや C 拡張のアノテーションを集約する "stub" ファイルを別に設けることもできる。mypy のアノテーションが (stub ファイルではなく) インラインに記述されると、アノテートされた関数本体の型チェクと同様に呼び出し側の入力の型チェックにも使われる。

2. pytypedecl family
独立した stub ファイルでカスタム構文を使うため、Python の構文に制約を受けず、若干 mypy よりは洗練されているようにみえる。

3. PyCharm family
docstings でのみ使われるカスタム構文である。stub ファイルを使うこともできる。

4. コメントに全て書く方法を4番目の系統になり得るが、誰もが真面目にコメントを書くとは思えないし、利点も分からない。

さぁ、どうやって選択しよう?私は攻撃的にも防御的にもそれぞれのアプローチで熱心に内容を読み取った。3つのプロジェクトは異なる成熟期にある (おもちゃ以上、プライムタイムには及ばない) というのが実感だ。特定の型システムの機能 (前方参照、総称型、ダックタイピング) に関しては、私は全てに満足できるものを期待していて、おそらくはまだやることがある。どのプロジェクトも stub をサポートしているので、既存のコードを修正することなくシグネチャを指定できる。

私にとって、間違いなく mypy が最も Pythonic なアプローチだ。我々が PEP 3107 (function annotations) を議論したとき、最終的に型アノテーションのために使われるのがずっと私の目標だった。当時は型チェックのルールになるといった合意はなかったが、構文上の位置付けからそれを疑う余地はなかった。そのため、我々は、サードパーティの実験の成果が最終的に満足できるものを創り出すのを願って Python3 に "annotation" を導入することを決定した。mypy はそういった実験の1つだ。mypy から私が得た大事な教訓の1つは、型アノテーションは linter に対して最も有益であり、(通常は) 実行時に型を強制するために使われるべきではないということだ。またそれらはコード生成の役には立たない。我々が PEP 3107 で議論していたときには全く分からなかったことだ!

(中略)

ということで、私は mypy family を選択していて、mypy の改善についての議論に移っていこう。そして誰かが pytypedel や PyCharm stub から mypy stub への変換ツールを作ってくれて、これらの2つのプロジェクトの stub 定義を再利用できることを願う。無論、PyCharm や pytypedecl が mypy の構文を導入することも願っている (まずはネイティブの構文に追加して、最終的には1つの構文になる) 。

(C) 他の機能にも対応する/すべき?
(追伸) この質問についてはあまり議論しなかったことに気付いた。わざとだ。特定の mypy の機能については別スレッドでこれから議論しよう (このスレッドでもいいけど :-) 。

Python 3.5 のリリーススケジュールは以下になります。feature freeze は Beta1 (May 24, 2015) のようです。

ちょうど型アノテーションの PEP のドラフト (のドラフト?) が Guido からメーリングリストに投稿されました。

アノテーションと型システムの議論

mypy について調べていて見つけた記事などのリンクです。

私には難しくてまとめきれないため、Guido が参照している漸進的型付け (Gradual Typing) も含め、また別の機会に、、、。

追記: 以下にまとめました。

mypy を使ってみよう

さて、ここからが本題です。

mypy のインストール

PyPI にも登録されていますが、ここでは github からソースをクローンしてインストールします。現時点では、mypy は Python 3.2 以上しかサポートしていませんが、 Python2 対応も開発中だと FAQ にあるのでいずれサポートされるかもしれません。

$ mkvirtualenv -p /opt/local/bin/python3.4 mypy
(mypy)$ git clone git@github.com:JukkaL/mypy.git
(mypy)$ cd mypy/
(mypy)$ python setup.py install
(mypy)$ mypy -h
usage: mypy [option ...] [-m mod | file]

Optional arguments:
  -h, --help         print this help message and exit
  --html-report dir  generate a HTML report of type precision under dir/
  -m mod             type check module
  --verbose          more verbose messages
  --use-python-path  search for modules in sys.path of running Python

Environment variables:
  MYPYPATH     additional module search path

ライブラリーのスタブ

Python の標準ライブラリに含まれるモジュールを使うコードの型チェックを行うには、public なインターフェイスやクラス、変数、関数などのスケルトンをスタブとして定義しないといけません。どんな雰囲気か mypy/stubs at master · JukkaL/mypy · GitHub にあるのを見た方が手っ取り早いと思います。作成したスタブの場所は環境変数で指定できるようです。

$ export MYPYPATH=~/work/myproject/stubs

チュートリアル

関数アノテーションに型アノテーションを指定する簡単な例から見てましょう。

  1 # -*- coding: utf-8 -*-                                                          
  2 import typing                                                                    
  3                                                                                  
  4 def greeting1(name: str) -> None:                                                
  5     return 'Hello, {}'.format(name)                                              
  6                                                                                  
  7 def greeting2(name: str) -> int:                                                 
  8     return 'Hello, {}'.format(name)                                              
  9                                                                                  
 10 def greeting3(name: str) -> str:                                                 
 11     return 'Hello, {}'.format(name)                                              
 12                                                                                  
 13 print(greeting1('john'))                                                         
 14 print(greeting2('bob'))                                                          
 15 print(greeting3('mike'))    

このプログラムは mypy の機能を使っていない純粋な Python3 のスニペットです。

(mypy)$ python check_function_signature.py 
Hello, john
Hello, bob
Hello, mike

普通に実行できました。しかし、せっかく指定した関数アノテーションの返り値が間違っているものがありますね。これを mypy というコマンドラインツールで実行すると lint 的に静的解析してくれます。

(mypy)$ mypy check_function_signature.py 
check_function_signature.py: In function "greeting1":
check_function_signature.py, line 5: No return value expected
check_function_signature.py: In function "greeting2":
check_function_signature.py, line 8: Incompatible return value type: expected builtins.int, got builtins.str
check_function_signature.py: At top level:
check_function_signature.py, line 13: "greeting1" does not return a value

mypy が静的型チェッカーだというのは、Python のプログラムをインタープリターを実行する前に、自分で mypy ツールを実行して型チェックを行い、そのエラーを修正するといった使用方法だからです。

次の例をみてみましょう。

  1 # -*- coding: utf-8 -*-                                                          
  2 from typing import Iterable                                                      
  3                                                                                  
  4 def greet_all1(names: Iterable[str]):                                            
  5     for name in names:                                                           
  6         print('Hello, {}'.format(name))                                          
  7         'mr. ' + name                                                            
  8         name + 1                                                                 
  9                                                                                  
 10 greet_all1(['john', 'bob'])                         

こんどは Iterable を使って型アノテーションを定義しています。

(mypy)$ mypy check_function_signature_with_error.py 
check_function_signature_with_error.py: In function "greet_all1":
check_function_signature_with_error.py, line 8: Unsupported operand types for + ("str" and "int")

おや!?8行目に str 型と int 型の加算があります。これを Python インタープリターで実行すると、

(mypy)$ python check_function_signature_with_error.py 
Hello, john
Traceback (most recent call last):
  File "check_function_signature_with_error.py", line 10, in <module>
    greet_all1(['john', 'bob'])
  File "check_function_signature_with_error.py", line 8, in greet_all1
    name + 1
TypeError: Can't convert 'int' object to str implicitly

エラーになりますね。とても単純な例ですが、1 というリテラルの値が int 型だと型推論されて mypy がエラーを指摘しています。

もっと分かりやすい型推論のサンプルも見てみましょう。

  1 # -*- coding: utf-8 -*-                                                                         
  2 import typing                                                                                   
  3                                                                                                 
  4 # int                                                                                           
  5 i = 1                                                                                           
  6 i = 2                                                                                           
  7 i = int(3)                                                                                      
  8 i = 'x'                                                                                         
  9                                                                                                 
 10 # list                                                                                          
 11 l = [1, 2]                                                                                      
 12 l.append(3)                                                                                     
 13 l.append('x')                                                                                   
 14 l.extend([4, 5])                                                                                
 15 l.extend([6, 'y'])                                                                              
 16                                                                                                 
 17 # dictionary                                                                                    
 18 d = {'x': 1}                                                                                    
 19 d['y'] = 2                                                                                      
 20 d['z'] = 'x'                                                                                    
 21 d[3] = 4  

mypy でチェックします。

(mypy)$ mypy check_type_inference.py 
check_type_inference.py, line 8: Incompatible types in assignment (expression has type "str", variable has type "int")
check_type_inference.py, line 13: Argument 1 to "append" of "list" has incompatible type "str"; expected "int"
check_type_inference.py, line 15: List item 2 has incompatible type "str"
check_type_inference.py, line 20: Incompatible types in assignment
check_type_inference.py, line 21: Invalid index type "int" for "dict"

変数へ最初に代入した型とあわない値を代入するとエラーとして指摘してくれます。

また型アノテーションをコメントで定義することもできます。

  1 # -*- coding: utf-8 -*-                                                          
  2 from typing import List                                                          
  3                                                                                  
  4 l1 = []  # type: List[int]                                                        
  5 l1.append('x')                                                                    
  6                                                                                  
  7 l2 = List[int]()                                                                  
  8 l2.append('y')                               

# type: で始まるコメントを型アノテーションとして扱うため、

(mypy)$ mypy check_type_annotations.py 
check_type_annotations.py, line 5: Argument 1 to "append" of "list" has incompatible type "str"; expected "int"
check_type_annotations.py, line 8: Argument 1 to "append" of "list" has incompatible type "str"; expected "int"

どちらの定義方法でも mypy では同様にエラーを指摘します。

最後に値の型を明示的に宣言する方法です。

  1 # -*- coding: utf-8 -*-                                                          
  2 from typing import Undefined                                                     
  3                                                                                  
  4 s = Undefined(str)                                                               
  5 if s:                                                                            
  6     print('not defined yet')                                                     
  7 s = 1                              

mypy でチェックすると、str 型の何かを期待しています。

(mypy)$ mypy check_explicit_types.py 
check_explicit_types.py, line 7: Incompatible types in assignment (expression has type "int", variable has type "str")

この Undefined はもう1つ仕掛けがあります。s に str 型の値が代入される前に操作を行うと例外が発生します。

(mypy)$ python check_explicit_types.py 
Traceback (most recent call last):
  File "check_explicit_types.py", line 5, in <module>
    if s:
  File "path/to/.virtualenvs/mypy/lib/python3.4/site-packages/typing.py", line 359, in __bool__
    raise TypeError("'Undefined' object is not valid as a boolean")
TypeError: 'Undefined' object is not valid as a boolean

ちょっと実装をみてみると、

class Undefined:
    def __repr__(self):
        return '<typing.Undefined>'

    def __setattr__(self, attr, value):
        raise AttributeError("'Undefined' object has no attribute '%s'" % attr)

    def __eq__(self, other):
        raise TypeError("'Undefined' object cannot be compared")

    def __call__(self, type):
        return self

    def __bool__(self):
        raise TypeError("'Undefined' object is not valid as a boolean")

Undefined = Undefined()

Undefined に対する操作 (特殊メソッド) を行うと例外を発生させています。if 文の場合は Python3 からは特種メソッド__bool__ に変更されています。Python2 のときは __nonzero__ でした。

静的型付けプログラミング

チュートリアルで静的型チェッカーとしての mypy の雰囲気は掴めました?ここではさらに mypy が提供する強力な型アノテーションをみていきましょう。

様々な型

ユーザー定義型
  1 # -*- coding: utf-8 -*-                                                          
  2 import typing                                                                    
  3                                                                                  
  4 class A:                                                                         
  5     def f(self) -> int:  # Type of self inferred (A)                             
  6         return 2                                                                 
  7                                                                                  
  8 class B(A):                                                                      
  9     def f(self) -> int:                                                          
 10          return 3                                                                
 11                                                                                  
 12     def g(self) -> int:                                                          
 13         return 4                                                                 
 14                                                                                  
 15 b = B()       # OK infer type B for b                                            
 16 print(b.g())                                                                     
 17                                                                                  
 18 a = B()       # type: A  # OK (explicit type for a; override type inference)     
 19 print(a.f())  # 3                                                                
 20 print(a.g())  # Type check error: A has no method g                             

18行目で型アノテーションで明示的に A と指定したら g メソッドがないと指摘されます。

(mypy)$ mypy check_user_defined_types.py 
check_user_defined_types.py, line 20: "A" has no attribute "g"

ちなみにこの型アノテーションの隣の実コメント部の # を取り除くと、

 18 a = B()       # type: A  OK (explicit type for a; override type inference)

パースエラーになりました。

(mypy)$ mypy check_user_defined_types.py 
check_user_defined_types.py, line 18: Parse error before "OK"

アノテーションとコメントは分割して書かないといけないようです。

任意の型を表す Any とキャスト

wikipedia:動的型付け したい値を定義するときは Any を使います。

  1 # -*- coding: utf-8 -*-                                                                         
  2 from typing import Any, Undefined, cast                                                         
  3                                                                                                 
  4 x = Undefined(Any)                                                                              
  5 x = 1                                                                                           
  6 x = 'x'

このとき、普通の Python のコードのように mypy では静的チェックをしなくなります。

(mypy)$ mypy check_any_type.py 

さらに Any で定義した値をキャストすることもできます。

  1 # -*- coding: utf-8 -*-                                                                         
  2 from typing import Any, Undefined, cast                                                         
  3                                                                                                 
  4 x = Undefined(Any)                                                                              
  5 x = 1                                                                                           
  6 x = 'x'                                                                                         
  7                                                                                                 
  8 y = cast(int, x)  # cast x as int to y                                                          
  9 y = 'y'                                                                                         
 10 y = 2                                                                            
 11                                                                                  
 12 z = Any(y)  # cast y as Any to z                                                 
 13 z = 3                                                                            
 14 z = 'z'                                           

今度は x を int でキャストしたものを y として定義し、y をまた Any にキャストして z として定義しています。

(mypy)$ mypy check_any_type.py 
check_any_type.py, line 9: Incompatible types in assignment (expression has type "str", variable has type "int")

Any(...)cast(Any, ...) は等価なようです。

Callable 型

組み込み型の Function を使います。

  1 # -*- coding: utf-8 -*-                                                                         
  2 from typing import Function, List                                                               
  3                                                                                                 
  4 def label_data(name: str, data: List[int]) -> str:                                              
  5     return '{}: {}'.format(name, ', '.join(str(i) for i in data))                               
  6                                                                                                 
  7 def caller(name: str, data: List[int], func: Function[[str, List[int]], str]) -> str:           
  8     return func(name, data)                                                                     
  9                                                                                                 
 10 print(caller('numbers', [1, 2, 3], label_data))                                                 
 11 print(caller('mixed', [1, 'x', 3], label_data))                                        

構文は Function[[引数1の型, ..., 引数nの型], 返り値の型] なので複雑な関数だとちょっと苦しい感じはあります。

(mypy)$ mypy check_callable_types.py 
check_callable_types.py, line 11: List item 2 has incompatible type "str"

lambda もコンテキストから双方向に型推論されます。

  1 # -*- coding: utf-8 -*-                                                                         
  2 from typing import Iterable, Undefined                                                          
  3                                                                                                 
  4 l1 = Undefined(Iterable[int])                                                                   
  5 l1 = map(lambda x: x + 1, [1, 2, 3])  # infer x as int and l as List[int]                       
  6                                                                                                 
  7 l2 = Undefined(Iterable[int])                                                                   
  8 l2 = map(lambda x: x + '+test', ['a', 'b'])                                                     
  9                                                                                                 
 10 l3 = Undefined(Iterable[int])                                                                   
 11 l3 = map(lambda x: str(x) + '+test', [1, 2])                                

あくまで例なので実際には lambda のような用途にわざわざ Undefined を定義するとは思いませんが、こういったコンテキストから凡ミスを防ぐのには良さそうにみえます。

(mypy)$ mypy check_lambda.py 
check_lambda.py, line 8: Incompatible return value type: expected builtins.int, got builtins.str
check_lambda.py, line 8: Argument 1 to "map" has incompatible type Function[[str], str]; expected Function[[str], int]
check_lambda.py, line 11: Incompatible return value type: expected builtins.int, got builtins.str
check_lambda.py, line 11: Argument 1 to "map" has incompatible type Function[[int], str]; expected Function[[int], int]

ダックタイピング

wikipedia:ダック・タイピング のコード例です。

  1 # -*- coding: utf-8 -*-                                                                         
  2 def quack(a: A) -> None:                                                                      
  3     print(a.f())                                                                                
  4                                                                                                 
  5 class A:                                                                                        
  6     def f(self) -> str:                                                                         
  7         return 'x'                                                                              
  8                                                                                                 
  9 quack(A())                                                                                      
 10                                                                                                 
 11 class B:                                                                                        
 12     def f(self) -> int:                                                                         
 13         return 1                                                                 
 14                                                                                  
 15 quack(B())                   

B は A と継承関係にないため、15行目の quack(B()) でエラーになります。

(mypy)$ mypy check_duck_typing.py 
check_duck_typing.py, line 15: Argument 1 to "quack" has incompatible type "B"; expected "A

ducktype というクラスデコレーターを使うことで回避できます。

 11 from typing import ducktype                                                                     
 12 @ducktype(A)                                                                                    
 13 class B:                                                                                        
 14     def f(self) -> int:                                                                         
 15         return 1                                                                 
 16                                                                                  
 17 quack(B())                   

さらに鋭い方は気付いたかもしれませんが、このコードは Python で実行できません。

(mypy)$ python check_duck_typing.py 
Traceback (most recent call last):
  File "check_duck_typing.py", line 2, in <module>
    def quack(a: A) -> None:
NameError: name 'A' is not defined

Python は前方参照 (forward reference) をサポートしていません。
関数アノテーションで指定したクラス名が解決できなくてエラーになります。mypy は前方参照をサポートしているのでこのままでも実行できますが、それでは実用的に意味がないので文字列リテラルでクラス名を指定することもできます。

  1 # -*- coding: utf-8 -*-                                                                         
  2 def quack(a: 'A') -> None:                                                                      
  3     print(a.f())            

ドキュメントには記載されていませんが、あるスライドに Go 言語風のインターフェースを提供する Protocol というのがあるのを知りました。

  1 # -*- coding: utf-8 -*-                                                          
  2 from abc import abstractmethod                                                   
  3 from typing import Protocol                                                      
  4                                                                                  
  5 class Sized(Protocol):                                                           
  6     @abstractmethod                                                              
  7     def __len__(self) -> int:                                                    
  8         pass                                                                     
  9                                                                                  
 10 def not_empty(x: Sized) -> bool:                                                 
 11     return len(x) > 0                

試しにコードを書いて実行してみたらエラーになりました。

(mypy)$ mypy check_protocols.py 
check_protocols.py, line 3: Module has no attribute 'Protocol'
check_protocols.py, line 5: Name 'Protocol' is not defined
(mypy)$ python check_protocols.py 

Feature proposal: Golang style interfaces · Issue #497 · JukkaL/mypy · GitHub によると、この仕組みはまだ開発中のようです。

class AbstractGenericMeta(ABCMeta):
    ...

class Protocol(metaclass=AbstractGenericMeta):
    """Base class for protocol classes."""

実装をみれば分かるように abc を使った抽象化で実現しています。abc をあまり使ったことがないので私にはまだピンときていませんが、まだまだ議論の余地はありそうです。

共用体 直和型 (Union Types) *1

wikipedia:共用体 と聞くと C 言語を思い浮かべますが 、汎用関数ではよくある処理です。Unionコンストラクタに受け入れられる型を指定します。

  1 # -*- coding: utf-8 -*-                                                          
  2 from typing import Union                                                         
  3                                                                                  
  4 def f(x: Union[int, str]) -> None:                                               
  5     x + 1     # Error: str + int is not valid                                    
  6     if isinstance(x, int):                                                       
  7         # Here type of x is int.                                                 
  8         x + 1      # OK                                                          
  9     else:                                                                        
 10         # Here type of x is str.                                                 
 11         x + 'a'    # OK                                                          
 12                                                                                  
 13 f(1)    # OK                                                                     
 14 f('x')  # OK                                                                     
 15 f(1.1)  # Error                               

型に特化した処理は isinstance で分岐することで mypy の型チェックでエラーになりません。

(mypy)$ mypy check_union_types.py 
check_union_types.py: In function "f":
check_union_types.py, line 5: Unsupported operand types for + ("Union[int, str]" and "int")
check_union_types.py: At top level:
check_union_types.py, line 15: Argument 1 to "f" has incompatible type "float"; expected "Union[int, str]"

Union がなかったらどうしたら良いんだろう?と思ってちょっと試してみました。

  1 # -*- coding: utf-8 -*-                                                          
  2 from typing import Any, cast                                                     
  3                                                                                  
  4 class MyModel:                                                                   
  5     def __init__(self, value: Any) -> None:                                      
  6         self.value = value                                                       
  7                                                                                  
  8 class MyInt(MyModel):                                                            
  9     def __add__(self, other: int) -> int:                                        
 10         return self.value + other                                                
 11                                                                                  
 12 class MyStr(MyModel):                                                            
 13     def __add__(self, other: str) -> str:                                        
 14         return self.value + other                                                
 15                                                                                  
 16 def g(y: MyModel) -> None:                                                       
 17     cast(MyInt, y) + 1                                                           
 18     cast(MyStr, y) + 'a'                                                         
 19     y + 1.1                                                                      
 20                                                                                  
 21 def h(z: Any) -> None:                                                           
 22     cast(int, z) + 1                                                             
 23     z + 'a'
 24     z + 1.1

明示的にキャストするか、Any を使う方法しか思いつかなかったのですが、Any を使うとそもそも静的解析できないのでダメですね。

(mypy)$ mypy check_union_types2.py 
check_union_types2.py: In function "g":
check_union_types2.py, line 19: Unsupported operand types for + ("MyModel" and "float")

余談ですが、汎用関数のような処理は 3.4 から標準に入った singledispatch を使うとすっきり書けます。

  1 # -*- coding: utf-8 -*-                                                          
  2 from functools import singledispatch                                             
  3 from typing import Any                                                           
  4                                                                                  
  5 @singledispatch                                                                  
  6 def f(x: Any) -> Any:                                                           
  7     return x                                                                     
  8                                                                                  
  9 @f.register(int)                                                                 
 10 def _(x: int) -> int:                                                            
 11     return x + 1                                                                 
 12                                                                                  
 13 @f.register(str)                                                                 
 14 def _(x: str) -> str:                                                            
 15     return x + 'a'                                                               
 16                                                                                  
 17 print(f(1))                                                                      
 18 print(f('x'))                                                                    
 19 print(f(1.1))

実行してみます。

(mypy)$ python check_singledispatch.py 
2
xa
1.1

試しに mypy でチェックしてみるとエラーになってしまいました。

(mypy)$ mypy check_singledispatch.py 
check_singledispatch.py, line 2: Module has no attribute 'singledispatch'
check_singledispatch.py, line 5: Name 'singledispatch' is not defined
check_singledispatch.py, line 9: 'overload' decorator expected
check_singledispatch.py, line 13: 'overload' decorator expected

functools のスタブに singledispatch の定義がなかったので以下の定義を追加してスタブディレクトリを環境変数にセットしたら2行目と5行目のエラーは消えました。

from typing import Any

def singledispatch(func: Any) -> Any: pass
(mypy)$ vi stub/functools.py 
(mypy)$ export MYPYPATH=./stub/
(mypy)$ mypy check_singledispatch.py
check_singledispatch.py, line 9: 'overload' decorator expected
check_singledispatch.py, line 13: 'overload' decorator expected

型システムの拡張

かなり満足してきましたが、もうちょっと続きがあります。

今夏にメーリングリストで Guido が 3.5 に取り込もうと提案したのは mypy の型アノテーションの仕組みのみだったように思いますが、mypy には関数オーバーロードジェネリクスといった Python の型システムを拡張する機能も提供しています。ただ、先日投稿された PEP のドラフトには総称型にも言及しているため、さらに突っ込んだ仕組みになるのかもしれません。

関数オーバーロード

オーバーロードって日本語に訳すと wikipedia:多重定義 になるのですね。私の周りではオーバーロードと呼ぶ方が一般的です。

組み込み関数の absオーバーロードしてみましょう。

  1 # -*- coding: utf-8 -*-                                                          
  2 from typing import overload                                                      
  3                                                                                  
  4 @overload                                                                        
  5 def abs(n: int) -> int:                                                          
  6     print('called int version')                                                  
  7     from builtins import abs                                                     
  8     return abs(n)                                                                
  9                                                                                  
 10 @overload                                                                        
 11 def abs(n: float) -> float:                                                      
 12     print('called float version')                                                
 13     return n if n >= 0.0 else -n                                                 
 14                                                                                  
 15 @overload                                                                        
 16 def abs(s: str) -> str:                                                          
 17     print('called str version')                                                  
 18     return s[1:] if s[0] == '-' else s                                           
 19                                                                                  
 20 print(abs(-2))    # int                                                          
 21 print(abs(-1.5))  # float                                                        
 22 print(abs('-3'))  # str

実行してみます。

(mypy)$ mypy check_function_overloading.py 

(mypy)$ python check_function_overloading.py 
called int version
2
called float version
1.5
called str version
3

オリジナルの組み込み関数を呼び出すには builtins からインポートする必要があります。これは関数を探すときにローカルの名前空間から見つけてしまうのと同様です。

>>> type(abs)
<class 'builtin_function_or_method'>
>>> def abs(): pass
... 
>>> type(abs)
<class 'function'>

前節では singledispatchオーバーロードを実現しましたが、mypy の overload は、もうちょと汎用的な multiple dispatch だとドキュメントにあります。

This enables also a form of multiple dispatch.

Function overloading

実際に試してみましょう。

  1 # -*- coding: utf-8 -*-                                                          
  2 from typing import overload                                                      
  3                                                                                  
  4 @overload                                                                        
  5 def func(n: int, s: str) -> str:                                                 
  6     return 'int, str -> str'                                                     
  7                                                                                  
  8 @overload                                                                        
  9 def func(s: str, n: int) -> str:                                                 
 10     print('called str, int -> str')                                              
 11     return func(n, s)                                                            
 12                                                                                  
 13 print(func(1, 's'))                                                              
 14 print(func('s', 1))                  
(mypy)$ python check_multiple_dipatch.py 
int, str -> str
called str, int -> str
int, str -> str

Python の標準ライブラリにはその名の表す通り singledispatch しか導入されなかったわけですが、multiple dispatch の議論もまた活発になるかもしれません。

ジェネリクス

Python のような言語で必要かという疑問はありますが、インターフェイスを明示したいときなどで役に立つのかもしれません。 当初、この例だけをあまりピンと来なかったんですが、コレクションを扱う API を静的解析しようと思ったらジェネリクスがないと不便というのが Javaジェネリクス導入の経緯からも分かることでした (´・ω・`)

ジェネリッククラス (generic class)
  1 # -*- coding: utf-8 -*-
  2 from typing import Generic, List, typevar                                        
  3                                                                                  
  4 T = typevar('T')                                                                 
  5                                                                                  
  6 class Stack(Generic[T]):                                                         
  7     def __init__(self) -> None:                                                  
  8         self.items = List[T]()  # Create an empty list with items of type T      
  9                                                                                  
 10     def push(self, item: T) -> None:                                             
 11         self.items.append(item)                                                  
 12                                                                                  
 13     def pop(self) -> T:                                                          
 14         return self.items.pop()                                                  
 15                                                                                  
 16     def empty(self) -> bool:                                                     
 17         return not self.items                                                    
 18                                                                                  
 19 stack_int = Stack[int]()                                                         
 20 stack_int.push(1)                                                                
 21 stack_int.push(2)                                                                
 22 stack_int.pop()                                                                  
 23 stack_int.push(3)                                                                
 24 stack_int.push('x')                                                              
 25 print(stack_int.items)                                                           
 26                                                                                  
 27 stack_str = Stack[str]()                                                         
 28 stack_str.push(1)                                                                
 29 stack_str.push('x')                                                              
 30 stack_str.push('y')                                                              
 31 print(stack_str.items)

型パラメーターを使うことで型を明示できた、やったー!と思うか、もともと動的型付けなのでこんなことしなくてもそのままコードが書けるのに、、、と思うか。

(mypy)$ mypy check_generic_classes.py 
check_generics.py, line 24: Argument 1 to "push" of "Stack" has incompatible type "str"; expected "int"
check_generics.py, line 28: Argument 1 to "push" of "Stack" has incompatible type "int"; expected "str"

実行結果。

(mypy)$ python check_generic_classes.py 
[1, 3, 'x']
[1, 'x', 'y']
ジェネリック関数 (generic function)

同様に関数にも応用できます。

  1 # -*- coding: utf-8 -*-                                                          
  2 from typing import Sequence, typevar                                             
  3                                                                                  
  4 T = typevar('T')                                                                 
  5                                                                                  
  6 def first(seq: Sequence[T]) -> T:                                                
  7     return seq[0]                                                                
  8                                                                                  
  9 print(first([1, 2, 3]))                                                          
 10 print(first('abc'))                                                              
型変数と制限

型変数の応用例です。

  1 # -*- coding: utf-8 -*-                                                          
  2 from typing import typevar                                                       
  3                                                                                  
  4 AnyStr = typevar('AnyStr', values=(str, bytes))                                  
  5                                                                                  
  6 def concat(x: AnyStr, y: AnyStr) -> AnyStr:                                      
  7     return x + y                                                                 
  8                                                                                  
  9 concat('a', 'b')    # Okay                                                       
 10 concat(b'a', b'b')  # Okay                                                       
 11 concat('a', b'b')   # Error!                                                     
 12 concat(1, 2)        # Error!                   

型チェックをすると11行目と12行目がエラーになります。11行目は str と bytes を連結することはできないからです。

(mypy)$ mypy check_generic_type_variables1.py 
check_generic_type_variables1.py, line 11: Type argument 1 of "concat" has incompatible value "object"
check_generic_type_variables1.py, line 12: Type argument 1 of "concat" has incompatible value "int"

これは一見 Union で代替できそうにみえますが、違うものだとドキュメントにあります。やってみましょう。

  1 # -*- coding: utf-8 -*-                                                          
  2 from typing import Union                                                         
  3                                                                                  
  4 def concat(x: Union[str, bytes], y: Union[str, bytes]) -> Union[str, bytes]:     
  5     return x + y  # Error: can't concatenate str and bytes                       
  6                                                                                  
  7 concat('a', 'b')    # Okay                                                       
  8 concat(b'a', b'b')  # Okay                                                       
  9 concat('a', b'b')   # mypy passes this line, but error at runtime                
 10 concat(1, 2)        # Error! 

5行目で Union だとサポートされてないといったエラーが出ますが、9行目のコードはパスしてしまいますね。

(mypy)$ mypy  check_generic_type_variables2.py 
check_generic_type_variables2.py: In function "concat":
check_generic_type_variables2.py, line 5: Unsupported operand types for + (likely involving Union)
check_generic_type_variables2.py: At top level:
check_generic_type_variables2.py, line 10: Argument 1 to "concat" has incompatible type "int"; expected "Union[str, bytes]"
check_generic_type_variables2.py, line 10: Argument 2 to "concat" has incompatible type "int"; expected "Union[str, bytes]"

このコードの意図することは、以下のように関数オーバーロードで定義するのと等価ですが、

@overload
def concat(x: str, y: str) -> str:
    return x + y

@overload
def concat(x: bytes, y: bytes) -> bytes:
    return x + y

ジェネリクスの型変数を使うとより短く書けるという利点があります。
なるほど、これはしっくりきました。

まとめ

全てではないですが、mypy の用途や機能のほとんどは俯瞰できたのではないかと思います。おもしろそうだと思ったら是非プロジェクトのサイトも参照してください。

いまの時点では、型アノテーションを指定しても実行時の最適化 (高速化) にはなりません。

Mypy and PyPy are orthogonal. Mypy does static type checking, i.e. it is basically a linter, but static typing has no runtime effect, whereas the PyPy is an Python implementation. You can use PyPy to run mypy programs.

How is mypy different from PyPy?

意訳すると、

mypy と PyPy は直交する。mypy は、基本的には linter のような静的型付けチェックを行うが、実行時には何の影響も及ぼさない。対して、PyPy は Python 実装の1つであり、mypy のプログラムを実行するのに使う。

PyPy で良いことがあるんじゃないかと妄想しますが、PyPy のコア開発者である Alex Gaynor は、型アノテーションが PyPy にとって価値がないと断言しています。彼は型アノテーションの導入よりも、Python の型システムを改善しようと提案していますが。

PS: You're right. None of this would provide *any* value for PyPy.

[Python-ideas] Proposal: Use mypy syntax for function annotations

静的型付けプログラミングっぽいことを Python でやることの是非は分かりません。Python じゃなくて、最初から静的型付き言語を使えば良いじゃないかというのも正しいでしょう。とはいえ、関数アノテーションが mypy を導いたように、型アノテーションが次のなにか (漸進的型付け?) を導く可能性もあります。何よりも動的型付き言語で実行前にエラーチェックできる範囲が広がることは、Python というプログラミング言語が好きな私にとってはとても嬉しいニュースでした。

2014-12-26 追記

実際に小さいコードで試してみました。
python3 - mypy で適当なスニペットを実際に静的型付けしてみた - Qiita

*1:共用体は C 言語で普及した訳語で型システムでは直和型という訳語の方が一般的な表現のようです

detox で tox テストを並列実行しよう

複数の Python バージョンでテストを実行するツールに tox があります。

tox ツールそのものがとても便利なのですが、この tox テストを並列実行してくれるツールがリリースされました。

既に tox を使っている環境であれば、detox をインストールするだけで良いです。

$ pip install detox

使い方は tox と全く同じで特別な設定は不要です。tox コマンドを実行する代わりに detox コマンドを実行します。試しに実行してみましょう。

(test)$ detox 
py26 create: /Users/t2y/work/repo/littlehttpserver/.tox/py26
GLOB sdist-make: /Users/t2y/work/repo/littlehttpserver/setup.py
py27 create: /Users/t2y/work/repo/littlehttpserver/.tox/py27
py32 create: /Users/t2y/work/repo/littlehttpserver/.tox/py32
py26 installdeps: :pypi:pytest, :pypi:pep8 
py27 installdeps: :pypi:pytest, :pypi:pep8
py32 installdeps: :pypi:pytest, :pypi:pep8
installdeps py26- py27/ py32-         

この例では、Python 2.6, 2.7, 3.2 の tox テストが並列実行され、依存パッケージのインストール中です。

最終的な実行結果です。

(test)$ detox 
GLOB sdist-make: /Users/t2y/work/repo/littlehttpserver/setup.py
py27 sdist-reinst: /Users/t2y/work/repo/littlehttpserver/.tox/dist/LittleHTTPServer-0.1.2.zip
py32 sdist-reinst: /Users/t2y/work/repo/littlehttpserver/.tox/dist/LittleHTTPServer-0.1.2.zip
py26 sdist-reinst: /Users/t2y/work/repo/littlehttpserver/.tox/dist/LittleHTTPServer-0.1.2.zip
py26 runtests: commands[0]    
py27 runtests: commands[0]               
py32 runtests: commands[0]               
_______________________________ summary ________________________________
  py26: commands succeeded
  py27: commands succeeded
  py32: commands succeeded
  congratulations :)

1つの tox 環境でテストが通ったときに素早く他の環境でも実行したい、ちょっとした修正を行ったときに他の環境でも一応テストしてみたい、といった用途にとても便利です。何よりも CPU パワーが有り余ってるマシンで並列処理を実行するのは気持ち良いです。

detox は、並列処理に eventlet を使っていて、detox のソース自体も百数十行というシンプルなものです。eventlet の GreePool から greenthread でプロセスを実行しているようです。私は eventlet を使っているソースを初めて見かけたので興味深かったです (^ ^;;

pytest のドキュメントを翻訳しました

Python のテストツールに pytest があります。ここ最近、徐々にテストを書くのに慣れてきて、さらにテスト設計や知見を高めようという思いが強くなってきました。

pytest は、wikipedia:設定より規約 を設計原則とするツールです。Python は明示する文化なのでやや違和感を感じる人もいるかもしれません。さらに xUnit スタイルではない funcarg という関数の引数をフックしてリソースインジェクションを行う仕組みを提供するなど、私にとっては新たな概念を学ぶのにおもしろそうな題材に見えました。

Holger Krekel 氏と出会い

先日 PyCon US 2012 に参加して、pytest の作者 Holger Krekel 氏と出会い、話すことができました。

最初に会ったのはカンファレンスが始まる前日のチュートリアルデーで、手持ち無沙汰な様子に見えたので、ランダムデータテストのやり方を相談してみると、pytest のパラメーターテストやフックの仕組みを教えてくれました。そこで顔を覚えてもらったせいか、その後のカンファレンスやスプリントを通して、サンプル実装したコードをレビューしてもらったり、アドバイスをしてもらったりして、最終的な成果物が以下のプラグインです。

カンファレンスも彼の発表を聴講しました。

pytest ドキュメント翻訳

日本へ帰国後、すぐに pytest の翻訳を始めました。

本当は2週間ほどで完了し、4月中旬には出来ていました。その後、メーリングリストで Holger 氏と翻訳ドキュメントの公開やメンテナンスについてやり取りをして、最終的には pytest 本体に翻訳ドキュメントを取り込んでもらいました *1 。とはいえ、やはり Sphinxi18n 機能を使った方が良いかどうかを協議中だったりします。

以下で和訳されたドキュメントが公開されています。

今後のバージョンアップに伴うメンテナンスも継続的に行います。誤字/脱字、誤り翻訳などあれば、こちらまで ご連絡頂けると助かります。

*1:2ヶ月近くかかった理由は Holger 氏が5月末まで休暇中だったため

pytest-pep8 を 0.7 から 0.8 にアップグレードしたらテストが失敗するようになった

タイトルは釣りです。

pytest で pep8 のテストを行う pytest-pep8 プラグインがあります。次のようなサンプルコードを用意します。

(test)$ vi sample.py 
x=3
class A(object): pass

def f(x):
    return x

このサンプルコードを pep8 でチェックすると、たくさんのエラーが出ます。

(test)$ pep8 -r sample.py 
sample.py:1:2: E225 missing whitespace around operator
sample.py:2:1: E302 expected 2 blank lines, found 0
sample.py:2:16: E701 multiple statements on one line (colon)
sample.py:4:1: E302 expected 2 blank lines, found 1
sample.py:6:1: W391 blank line at end of file

同じように pytest-pep8 0.7 で実行します。

(test)$ py.test --version
This is py.test version 2.2.4, imported from /Users/t2y/.virtualenvs/test/lib/python2.7/site-packages/pytest.pyc
setuptools registered plugins:
  pytest-pep8-0.7 at /Users/t2y/.virtualenvs/test/lib/python2.7/site-packages/pytest_pep8.pyc

(test)$ py.test --pep8 sample.py 
========================= test session starts ==========================
platform darwin -- Python 2.7.3 -- pytest-2.2.4
pep8 ignore opts: (performing all available checks)
collected 1 items 

sample.py F

=============================== FAILURES ===============================
______________________________ PEP8-check ______________________________
/Users/t2y/tmp/t/sample.py:1:2: E225 missing whitespace around operator
x=3
 ^
/Users/t2y/tmp/t/sample.py:2:1: E302 expected 2 blank lines, found 0
class A(object): pass
^
/Users/t2y/tmp/t/sample.py:2:16: E701 multiple statements on one line (colon)
class A(object): pass
               ^
/Users/t2y/tmp/t/sample.py:4:1: E302 expected 2 blank lines, found 1
def f(x):
^
/Users/t2y/tmp/t/sample.py:6:1: W391 blank line at end of file

^

======================= 1 failed in 0.01 seconds =======================

全く同じエラーが出ます。何もオプションを付けずに使っている分には問題ありません。

ここで pytest.ini に PEP8 のコーディングスタイルを無視するオプションを追加します。

(test)$ vi pytest.ini 
[pytest]
pep8ignore = E302 E701

E302 と E701 のエラーだけを無視して、E225 と W391 のエラーは出力されるはずなのですが、、、

(test)$ py.test --pep8 sample.py 
========================= test session starts ==========================
platform darwin -- Python 2.7.3 -- pytest-2.2.4
pep8 ignore opts: E302 E701
collected 1 items 

sample.py .

======================= 1 passed in 0.01 seconds =======================

pytest-pep8 0.7 では、テストが成功してしまいます。これは pep8ignore に設定した無視したいエラー総数が、PEP8 違反としたいエラー総数よりも多いときにテストが成功してしまう不具合がありました。

pytest-pep8 0.8 ではこの不具合が修正されています。

(test)$ pip install pytest-pep8==0.8
(test)$ py.test --pep8 sample.py 
========================= test session starts ==========================
platform darwin -- Python 2.7.3 -- pytest-2.2.4
pep8 ignore opts: E302 E701
collected 1 items 

sample.py F

=============================== FAILURES ===============================
______________________________ PEP8-check ______________________________
/Users/t2y/tmp/t/sample.py:1:2: E225 missing whitespace around operator
x=3
 ^
/Users/t2y/tmp/t/sample.py:6:1: W391 blank line at end of file

^

======================= 1 failed in 0.01 seconds =======================

今度は正しく E225 と W391 のエラーが検出されましたね。

おそらく pep8ignore オプションを設定していると、1つ2つのエラーを見逃してしまっている可能性があります。あるとき pytest-pep8 をアップグレードしたら、ソースいじってないのにエラーが出るようになったと不思議に思うことがあるかもしれません。

参考までに修正された内容です。

もしかしたら、あるときに pep8 の API 仕様が変わったのかな?詳しく調べてはいません。

tox と pytest で Python 2/3 両対応のテストを実行する

pytest *1 に関連して tox も一緒に覚えておくと良さそうです。


tox については id:Ehren の入門記事が分かりやすいです。

複数の Python バージョン毎に virtualenv で仮想環境を作成して、そこに自分のパッケージと必要なライブラリ等をインストールして、それぞれのバージョン毎にテストをまとめて実行してくれます。例えば、Python 2/3 の両対応を考えたときに、自分で環境を切り分ける手間隙を軽減できて、かなり便利です。ここで言う Python 2/3 両対応は 2.6/2.7 と 3.x の対応を指します *2

環境

(test)$ pip freeze
distribute==0.6.24
py==1.4.7
pytest==2.2.3
tox==1.3
virtualenv==1.7
wsgiref==0.1.2

bitbucket にこの記事で紹介するテストコードを置きました。興味のある方は試してみてください。

設定

wikipedia:エラトステネスの篩 を実装した eratosthenes.py を含むパッケージを作成してテストを行います。

カレントディレクトリの構成は以下になります。

(test)$ tree .
.
├── setup.py
├── src
│      ├── eratosthenes.py
│      └── tests
│              └── test_eratosthenes.py
└── tox.ini

Python のパッケージングや仮想環境の作成方法については Python Hack-a-thon 4 ハンズオン 中級コース がとても参考になります。

setup.py の設定

tox を使ってテストするには Python のパッケージを作成しないといけません。

ここではテストすることが目的なので、パッケージ名とバージョンのみの設定をもつ setup スクリプトを記述します。

$ vi setup.py
# -*- coding: utf-8 -*-
from setuptools import setup
setup(
    name="mytoxtest",
    version="0.1.0",
)
tox.ini の設定

tox の設定ファイルは ini 形式で記述します。

$ vi tox.ini
[tox]
envlist = py26, py27, py32

[testenv]
deps = pytest
commands = py.test --doctest-module -v src/eratosthenes.py src/tests

[tox] セクションには、テスト対象の Python バージョンやワークディレクトリの位置などを設定します *3

[testenv] セクションには、依存するライブラリと実行するテストコマンドを記述しています。ここでは pytest を使っていますが、コマンドラインで実行できるものであれば何でも構いません。

pytest のおさらい

pytest で doctest とテストスクリプトを実行します。

commands = py.test --doctest-module -v src/eratosthenes.py src/tests

まずは指定した Python バージョンでテストを実行します。"-e" オプションを指定すると、envlist で指定した任意の Python バージョンでのみ実行します。試しに実行します。

(test)$ tox -e py27
_________________________________ [tox sdist] __________________________________
[TOX] ***creating sdist package
[TOX] /Users/t2y/work/repo/misc/tox_test
$ /Users/t2y/.virtualenvs/test/bin/python setup.py sdist --formats=zip --dist-dir .tox/dist >.tox/log/0.log
[TOX] ***copying new sdistfile to '/Users/t2y/.tox/distshare/mytoxtest-0.1.0.zip'
______________________________ [tox testenv:py27] ______________________________
[TOX] ***reusing existing matching virtualenv py27
[TOX] ***upgrade-installing sdist
[TOX] /Users/t2y/work/repo/misc/tox_test/.tox/py27/log
$ ../bin/pip install --download-cache=/Users/t2y/work/repo/misc/tox_test/.tox/_download 
  /Users/t2y/work/repo/misc/tox_test/.tox/dist/mytoxtest-0.1.0.zip -U --no-deps >5.log
[TOX] /Users/t2y/work/repo/misc/tox_test
$ .tox/py27/bin/py.test --doctest-module -v src/eratosthenes.py src/tests
============================= test session starts ==============================
collected 173 items 

src/eratosthenes.py: [doctest] PASSED
src/tests/test_eratosthenes.py: [doctest] PASSED
src/tests/test_eratosthenes.py:21: test_sieve_of_eratosthenes[10-3] FAILED
src/tests/test_eratosthenes.py:21: test_sieve_of_eratosthenes[30-10] PASSED
src/tests/test_eratosthenes.py:21: test_sieve_of_eratosthenes[997-168] PASSED
src/tests/test_eratosthenes.py:29: test_is_prime[2] PASSED
src/tests/test_eratosthenes.py:29: test_is_prime[3] PASSED
src/tests/test_eratosthenes.py:29: test_is_prime[5] PASSED
... (snip)
src/tests/test_eratosthenes.py:29: test_is_prime[983] PASSED
src/tests/test_eratosthenes.py:29: test_is_prime[991] PASSED
src/tests/test_eratosthenes.py:29: test_is_prime[997] PASSED

================================= FAILURES =================================
_______________________ test_sieve_of_eratosthenes[10-3] _______________________

max_num = 10, expected = 3

    @pytest.mark.parametrize(("max_num", "expected"), [
        (10, 3),
        (30, 10),
        (PRIMES[-1], len(PRIMES)),
    ])
    def test_sieve_of_eratosthenes(max_num, expected):
>       assert expected == len(list(sieve_of_eratosthenes(max_num)))
E       assert 3 == 4
E        +  where 4 = len([2, 3, 5, 7])
E        +    where [2, 3, 5, 7] = list(<generator object sieve_of_eratosthenes at 0x10185c7d0>)
E        +      where <generator object sieve_of_eratosthenes at 0x10185c7d0> = sieve_of_eratosthenes(10)

src/tests/test_eratosthenes.py:27: AssertionError
===================== 1 failed, 172 passed in 0.35 seconds =====================
[TOX] ERROR: InvocationError: '.tox/py27/bin/py.test --doctest-module -v src/eratosthenes.py src/tests'
________________________________ [tox summary] _________________________________
[TOX] ERROR: py27: commands failed

おっと、test_sieve_of_eratosthenes() のテストに失敗したようです。

pytest の結果レポートを表示させるためにあえて失敗させました。詳細は省きますが、len(list(...)) の内部処理が展開されていて、10までの素数は 2, 3, 5, 7 の 4 つ値が返り値だと分かります。このテストは、@pytest.mark.parametrize でパラメーター指定した値が誤っていました。

テストを修正して再実行します。全てパスすると "congratulations :)" と表示されます。

(test)$ tox -e py27
________________________________ [tox sdist] _________________________________
[TOX] ***creating sdist package
... (snip)
========================= 173 passed in 0.34 seconds =========================
_______________________________ [tox summary] ________________________________
[TOX] py27: commands succeeded
[TOX] congratulations :)

tox を使ってテストする

ある Python バージョンで成功したので、他の Python バージョンでもテストを実行してみましょう。"-e" オプションを指定せずに実行すると envlist で定義した全てのバージョンでテストが実行されます。

$ tox
(test)$ tox 
________________________________ [tox sdist] _________________________________
[TOX] ***creating sdist package
[TOX] /Users/t2y/work/repo/misc/tox_test
$ /Users/t2y/.virtualenvs/test/bin/python setup.py sdist --formats=zip --dist-dir .tox/dist >.tox/log/0.log
[TOX] ***copying new sdistfile to '/Users/t2y/.tox/distshare/mytoxtest-0.1.0.zip'
_____________________________ [tox testenv:py26] _____________________________
[TOX] ***reusing existing matching virtualenv py26
[TOX] ***upgrade-installing sdist
...
... (snip)
...
_____________________________ [tox testenv:py27] _____________________________
[TOX] ***reusing existing matching virtualenv py27
[TOX] ***upgrade-installing sdist
...
... (snip)
...
_____________________________ [tox testenv:py32] _____________________________
[TOX] ***reusing existing matching virtualenv py32
[TOX] ***upgrade-installing sdist
...
... (snip)
...
_______________________________ [tox summary] ________________________________
[TOX] py26: commands succeeded
[TOX] py27: commands succeeded
[TOX] py32: commands succeeded
[TOX] congratulations :)
(test)$ 

Python 2.6, 2.7, 3.2 で全てのテストが成功しました、本当に簡単ですね o(^ ^)o

インデックスサーバーの切り替え

tox.ini で indexserver を指定して、任意の pypi サーバーを設定できます。

[tox]
envlist = py26, py27, py32
indexserver =
    testrun = http://pypi.testrun.org
    pypi = http://pypi.python.org/simple

[testenv]
deps =
 :testrun:pytest
 :pypi:sphinx

pytest や tox の tox.ini を見ると、http://pypi.python.org/ に加えて http://pypi.testrun.org/ も設定されていました。ローカルの pypi ミラーサーバー、もしくは開発版のパッケージを置いておくサーバーなどに活用できます。ローカルに pypi サーバーを構築する方法はまた別の機会に書きます。

まとめ

tox は本当に簡単で便利なツールです。

Python 3 未対応のパッケージに出会ったとき、tox.ini を追加してテスト環境を構築できれば、互換性を保持した上で Python 3 対応を行う敷居が格段に低くなります。さらに pytest の結果レポートの分かりやすさと組み合わせて、失敗するテストを直すのがおもしろくなってきたりします。また tox と pytest 共に開発者が Holger Krekel 氏なので、今後もお互いの親和性を保って拡張されていくように期待できます。

pytest と tox を使って Python 3 対応を本格的に始めてみてはどうでしょうか。

データ駆動テストを nose と pytest でやってみた

pytest で初めてテストを書いてみました。

nose と比べて、データ駆動テスト *1 *2 の違いが大きかったのでまとめてみます。

準備

以下の素数判定を行うテスト対象関数があるとします。

PRIME = {2: True, 3: True, 4: False, 5: True, 6: False, 7: True}

def is_prime(num):
    return PRIME[num]

bitbucket にこの記事で紹介するテストコードを置きました。興味のある方は試してみてください。

Python 2.7 でテストしています。

(test)$ pip freeze
distribute==0.6.24
nose==1.1.2
py==1.4.7
pytest==2.2.3
virtualenv==1.7
wsgiref==0.1.2

最も簡単なデータ駆動テスト

nose でデータ駆動テストを行う場合、ジェネレーターでテストケースを生成します *3 。以下のようにループを使ったテストコードになります。

from nose.tools import ok_

def test_is_prime():
    for num in [3, 4, 5]:
        yield ok_, is_prime(num)

実行結果。

(test)$ nosetests -v test_nose-data-driven.py 
test_nose-data-driven.test_is_prime(True,) ... ok
test_nose-data-driven.test_is_prime(False,) ... FAIL
test_nose-data-driven.test_is_prime(True,) ... ok

======================================================================
FAIL: test_nose-data-driven.test_is_prime(False,)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/t2y/.virtualenvs/test/lib/python2.7/site-packages/nose/case.py", line 197, in runTest
    self.test(*self.arg)
  File "/Users/t2y/.virtualenvs/test/lib/python2.7/site-packages/nose/tools.py", line 25, in ok_
    assert expr, msg
AssertionError

----------------------------------------------------------------------
Ran 3 tests in 0.001s

FAILED (failures=1)

3, 4, 5 という入力データに対して、num = 4 のときにテストが失敗します。num = 4 のときにテストが失敗しても 3 と 5 のテストは実行されているので、一回の実行で全ての入力データに対するテストが行えて便利です。もっとたくさんの入力データを取るテストの場合、何度も実行する手間隙が省けます。

しかし、テストが失敗したとき、この結果レポートでは、テストコードのどこで、どんな入力データに対して失敗したのか分かりません。パッと見て何のエラーなのかよく分かりません。

ここで pytest でも同じことをやってみます。

pytest でデータ駆動テストを行う場合、デコレーターで入力データを与えます *4 。ループを書かなくて良いのでテストコードもすっきりしますね。

import pytest

@pytest.mark.parametrize("num", [3, 4, 5])
def test_is_prime(num):
    assert is_prime(num)

実行結果。

(test)$ py.test -v test_pytest-data-driven.py 
============================= test session starts ==============================

test_pytest-data-driven.py:10: test_is_prime[3] PASSED
test_pytest-data-driven.py:10: test_is_prime[4] FAILED
test_pytest-data-driven.py:10: test_is_prime[5] PASSED

================================= FAILURES =================================
_______________________________ test_is_prime[4] _______________________________

num = 4

    @pytest.mark.parametrize("num", [3, 4, 5])
    def test_is_prime(num):
>       assert is_prime(num)
E       assert is_prime(4)

test_pytest-data-driven.py:12: AssertionError
====================== 1 failed, 2 passed in 0.02 seconds ======================

nose の結果レポートと比べると、驚くほど懇切丁寧なレポートです。テストコードの、どこで、どんな入力データに対してテストが失敗したのか一目瞭然です。

この結果レポートの違いを見るだけでも pytest を使う価値があります。

デバッガを使う

pytest はデコレーターで入力データを渡せましたが、この違いはデバッグのやり方にも影響します。nose と pytest 共にテストが失敗したときに pdb デバッガを起動するオプションがあります。

まずは nose でテストが失敗したときにデバッガを起動します。"--pdb-failure" オプションを指定します。

(test)$ nosetests -v --pdb-failure test_nose-data-driven.py
test_nose-data-driven.test_is_prime(True,) ... ok
test_nose-data-driven.test_is_prime(False,) ... > /Users/t2y/.virtualenvs/test/lib/python2.7/site-packages/nose/tools.py(25)ok_()
-> assert expr, msg

(Pdb) locals()
{'msg': None, 'expr': False}

(Pdb) w
  /opt/local/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/unittest/case.py(327)run()
-> testMethod()
  /Users/t2y/.virtualenvs/test/lib/python2.7/site-packages/nose/case.py(197)runTest()
-> self.test(*self.arg)
> /Users/t2y/.virtualenvs/test/lib/python2.7/site-packages/nose/tools.py(25)ok_()
-> assert expr, msg

(Pdb) u
> /Users/t2y/.virtualenvs/test/lib/python2.7/site-packages/nose/case.py(197)runTest()
-> self.test(*self.arg)

(Pdb) locals()
{'self': test_nose-data-driven.test_is_prime(False,)}

(Pdb) u
> /opt/local/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/unittest/case.py(327)run()
-> testMethod()

(Pdb) locals()
{'self': test_nose-data-driven.test_is_prime(False,), 
 'orig_result': <nose.result.TextTestResult run=2 errors=0 failures=0>,
 'testMethod': <bound method FunctionTestCase.runTest of test_nose-data-driven.test_is_prime(False,)>,
 'success': False, 'result': <nose.result.TextTestResult run=2 errors=0 failures=0>}

デバッガを起動したものの、これは test_is_prime() のコンテキストではありません。そのため、入力値 (num) の情報もありません。

次に pytest でテストが失敗したときにデバッガを起動します。"--pdb" オプションを指定します。

(test)$ py.test -v --pdb test_pytest-data-driven.py 
... (snip)
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
> /Users/t2y/work/repo/misc/data_driven_test/test_pytest-data-driven.py(12)test_is_prime()
-> assert is_prime(num)

(Pdb) locals()
{'@py_format4': 'assert False\n{False = is_prime(4)\n}', 'num': 4, '@py_assert2': False}

(Pdb) w
... (snip)
> /Users/t2y/work/repo/misc/data_driven_test/test_pytest-data-driven.py(12)test_is_prime()
-> assert is_prime(num)

test_is_prime() のコンテキストであることから 'num': 4 が確認できました。

より複雑なテストで失敗したとして、テスト関数のコンテキストにおける情報をみたいときに

import pdb; pdb.set_trace()

のようにテストコードを修正しなくても pytest なら "--pdb" オプションを指定してデバッグできます。

やはり pytest 良いですね。

テストフィクスチャを使ったデータ駆動テスト

wikipedia:XUnit スタイルの setup/teardown を使ったデータ駆動テストもやってみます。

nose も pytest もクラス内の setup()/teardown() メソッドを認識して、テスト関数の前後で実行してくれます *5 *6 。テストコード内の print() で出力したメッセージを表示するには、どちらも "-s" オプションを指定します。

実行順序を分かりやすくするためにデバッグメッセージを出力するように変更します。

def is_prime(num):
    print("called is_prime: {0}".format(num))
    return PRIME[num]

まずは pytest からテストします。

class TestPrimeNumber(object):
    def setup(self):
        print("\ncalled setup")

    def teardown(self):
        print("\ncalled teardown")

    @pytest.mark.parametrize("num", [3, 4, 5])
    def test_is_prime(self, num):
        assert is_prime(num)

    def test_function(self):
        print("called test function")
        assert True

実行結果。

(test)$ py.test -v -s test_pytest-data-driven.py 
============================= test session starts ==============================
collected 4 items 

test_pytest-data-driven.py:23: TestPrimeNumber.test_is_prime[3] 
called setup
called is_prime: 3
PASSED
called teardown

test_pytest-data-driven.py:23: TestPrimeNumber.test_is_prime[4] 
called setup
called is_prime: 4
FAILED
called teardown

test_pytest-data-driven.py:23: TestPrimeNumber.test_is_prime[5] 
called setup
called is_prime: 5
PASSED
called teardown

test_pytest-data-driven.py:27: TestPrimeNumber.test_function 
called setup
called test function
PASSED
called teardown
================================= FAILURES =================================
_______________________ TestPrimeNumber.test_is_prime[4] _______________________

self = <test_pytest-data-driven.TestPrimeNumber object at 0x1017ddc50>, num = 4

    @pytest.mark.parametrize("num", [3, 4, 5])
    def test_is_prime(self, num):
>       assert is_prime(num)
E       assert is_prime(4)

test_pytest-data-driven.py:25: AssertionError
====================== 1 failed, 3 passed in 0.02 seconds ======================

前節の普通の関数と同じ感覚でテストが記述できて、その結果レポートも期待したものが表示されます。良いですね。

次に nose でテストします。

class TestPrimeNumber(object):
    def setup(self):
        print("\ncalled setup")

    def teardown(self):
        print("called teardown")

    def test_is_prime(self):
        for num in [3, 4, 5]:
            yield ok_, is_prime(num)

    def test_function(self):
        print("called test function")
        ok_(True)

やはり先ほどと同様に記述して、一見テストも実行できるのですが、、、

(test)$ nosetests -v -s test_nose-data-driven.py 
... (snip)
test_nose-data-driven.TestPrimeNumber.test_function ... 
called setup
called test function
called teardown
ok

called is_prime: 3
test_nose-data-driven.TestPrimeNumber.test_is_prime(True,) ... 
called setup
called teardown
ok
... (snip)

is_prime()setup() の前に実行されています。このテストコードは意図した順番で実行されません。昔、これではまりました (> <)

nose のテストジェネレーターはテストケースを生成するものなので、正しくは以下のように記述します。

def test_factory(): 
    class FactoryTestCase(object): 
        def __init__(self, num):
            self.num = num

        def __call__(self): 
            ok_(is_prime(self.num))

        def setup(self): 
            print("\ncalled setup")

        def teardown(self): 
            print("called teardown")

    for num in [3, 4, 5]:
        yield FactoryTestCase(num)

実行結果。

(test)$ nosetests -v -s test_nose-data-driven.py 
test_nose-data-driven.test_factory ... 
called setup
called is_prime: 3
called teardown
ok
test_nose-data-driven.test_factory ... 
called setup
called is_prime: 4
FAIL
called teardown
test_nose-data-driven.test_factory ... 
called setup
called is_prime: 5
called teardown
ok
======================================================================
FAIL: test_nose-data-driven.test_factory
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/t2y/.virtualenvs/test/lib/python2.7/site-packages/nose/case.py", line 197, in runTest
    self.test(*self.arg)
  File "/Users/t2y/work/repo/misc/data_driven_test/test_nose-data-driven.py", line 41, in __call__
    ok_(is_prime(self.num))
  File "/Users/t2y/.virtualenvs/test/lib/python2.7/site-packages/nose/tools.py", line 25, in ok_
    assert expr, msg
AssertionError
----------------------------------------------------------------------
Ran 3 tests in 0.001s
FAILED (failures=1)

何となくテスト関数が少し分かり難くなった気がします。

さらに補足として、テストジェネレーターは unittest.TestCase のサブクラスでは動作しません。

関数単位のテストフィクスチャを使ったデータ駆動テスト

前節では、テスト関数の入力データに対するそれぞれの呼び出し毎に setup()/teardown() が呼ばれていました。似て非なる例として、テスト関数に対して一回だけ呼び出すテストをやってみます。

nose だと関数に対する with_setup() デコレーターで指定します。テストジェネレーターで実行するときは、やはり FactoryTestCase を定義しないといけません。

from nose.tools import with_setup

def mysetup():
    print("called mysetup")

def myteardown():
    print("called myteardown")

@with_setup(mysetup, myteardown)
def test_is_prime_with_setup_teardown():
    class FactoryTestCase(object): 
        def __init__(self, num):
            self.num = num

        def __call__(self): 
            ok_(is_prime(self.num))

    for num in [3, 4, 5]:
        yield FactoryTestCase(num)

実行結果。

(test)$ nosetests -v -s test_nose-data-driven.py 
called mysetup
test_nose-data-driven.test_is_prime_with_setup_teardown ...
called is_prime: 3
ok
test_nose-data-driven.test_is_prime_with_setup_teardown ...
called is_prime: 4
FAIL
test_nose-data-driven.test_is_prime_with_setup_teardown ...
called is_prime: 5
ok
called myteardown
... (snip)

mysetup()/myteardown() が一回だけ呼ばれていますね。

次に pytest です。pytest は、もう少し汎用的な仕組みで、テストモジュール内の、全ての関数に対してフックする setup_function()/teardown_function() を定義する方法があります。

これまでの例と違う点として、このテストは pytest.mark.parametrize に入力値のリストを渡して、テスト関数内にループを記述しています。

def setup_function(function):
    print("\ncalled setup: {0}".format(function))

def teardown_function(function):
    print("\ncalled teardown: {0}".format(function))

@pytest.mark.parametrize("nums", ([3, 4, 5],))
def test_is_prime2(nums):
    for num in nums:
        assert is_prime(num)

実行結果。

(test)$ py.test -v -s test_pytest-data-driven.py 
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.2.3 -- /Users/t2y/.virtualenvs/test/bin/python
collected 1 items 

test_pytest-data-driven.py:39: test_is_prime_with_setup_teardown[.0] 
called setup: <function test_is_prime_with_setup_teardown at 0x1017d0758>
called is_prime: 3
called is_prime: 4
FAILED
called teardown: <function test_is_prime_with_setup_teardown at 0x1017d0758>
... (snip)

実行できましたが、このテスト方法はループ内に assert 文を記述しているので num = 4 で失敗するとテストが終了します。さらに setup_function()/teardown_function() は全ての関数に対してフックしてしまうので、使い勝手が悪いかもしれません。

もう1つの方法として、テスト関数の引数に対してフックする方法があります *7 *8 。これは nose にはない仕組みで、ちょっと驚きました。

def mysetup():
    print("\ncalled mysetup")
    return [3, 4, 5]

def myteardown(nums):
    print("\ncalled myteardown: {0}".format(nums))

def pytest_funcarg__nums(request):
    return request.cached_setup(setup=mysetup, teardown=myteardown)

def test_is_prime_funcarg_setup_teardown(nums):
    for num in nums:
        assert is_prime(num)

"nums" という引数に対してフックする pytest_funcarg__nums() を定義して、その中で任意の setup/teardown 関数を指定します。そして、mysetup() がテスト関数の入力データになる "nums" を返します。

実行結果は先ほど同じです。こちらの方が任意の関数に対しては使いやすそうですが、テストコードが分かり難くなってしまうので使い方は限定されるように思います。

(test)$ py.test -v -s test_pytest-data-driven.py 
... (snip)
test_pytest-data-driven.py:55: test_is_prime_funcarg_setup_teardown 
called mysetup
called is_prime: 3
called is_prime: 4
FAILED
called myteardown: [3, 4, 5]

まとめ

データ駆動テストを nose と pytest で行うときの違いをまとめました。

複数の入力データを与えるテストを行う場合、どちらのライブラリも機能的には同じようにテストできますが、結果レポートの分かりやすさ・デバッグのしやすさを考慮すると pytest の方が使い勝手が良いと私は思いました。

あわせてやってみよう。

追記

ちょっと勘違いしたみたい。

Python3 の関数アノテーションを使って自動テストする

先日、引数に @Nullable アノテーションが付いた引数をもつ関数をリファクタリングして、関数分割してコミットしたら、ビルドサーバーに仕掛けられた FindBugs™ - Find Bugs in Java Programs に、@Nullable が付いてるのに Null チェックしてないよと怒られました (; ;)

Java のコードに慣れないため、Eclipse のお告げに従ってリファクタリングし、Eclipse がチェックできなかったものを見逃してしまったわけです。もちろん修正するのは簡単だけど、何か恥ずかしい。

ちょっと調べたら、Eclipse プラグインもあるようです *1EclipseFindBugs プラグインをインストールしてみようー。

。。。

( ゚д゚)ハッ! 間違えた!

今日は 2011 Pythonアドベントカレンダー(Python3) を書くよ!

Python も関数アノテーションが書けるようになりました

PEP 3107 -- Function Annotations によると、Python3 から関数アノテーションを書けるようになりました。

def foo(a: 'x', b: 5 + 6, c: list) -> max(2, 9):
    ...

このサンプルを見ると、式を記述できることを意図してるのか (?)、普通に intstr といった型を表す方が一般的な用途かなと思います。そして、func.__annotations__シグネチャがディクショナリとして保持されます。

{'a': 'x',
 'b': 11,
 'c': list,
 'return': 9}

また Python2orPython3 - Python Wiki によると、関数アノテーションPython 2.x にはバックポートされないようです。Python3 でしか利用できないため、実際に関数アノテーションを書いているコードを私は見たことがありませんでした。

関数アノテーションがあると何が嬉しいの?

そういう方は、先にアドベントカレンダーの3日目 *2 を書かれた @第7回 関数アノテーションでスマートにプラスアルファの実現:Python 3.0 Hacks|gihyo.jp … 技術評論社 を読みましょう。

この記事の中では、関数アノテーションを使うと、以下のようなことが簡潔に表現できて嬉しいと紹介されています。

  • それ自体がドキュメントになる
  • 自動型変換に利用する
  • overloading(多重定義)を定義する

但し、現在のところ、関数アノテーションは単に情報として保持しているだけです。そのため、このシグネチャをどう使うかはプログラマー次第、そしてサードパーティーのライブラリを待ちましょうという段階のようです。

まだ Python3 が普及していないせいか、関数アノテーションを使って型チェックやバリデーションをしてくれる anntools も開発が活発ではないようです。anntools を使うと、Python 2.x 系もデコレーターで関数アノテーションを追加することができます。とはいえ、この類いの拡張は、 (必要なら) 自分で実装済みだと思うので、そうではない既存のコードをわざわざ修正しようというインセンティブは低いかなと思います。

シグネチャを使って何をするか?

最も分かりやすい利用例としてはテストですね。そこで、ランダム自動テストをやってみましょう。

QuickCheck: An Automatic Testing Tool for HaskellPython 実装である 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 は MacPorts を使ってる

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 
# -*- coding: utf-8 -*-

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

応用としては、irangefrange でその型の範囲指定を行ったり、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_floatnon_negative_float といったものもあるようです。期待値の実行結果のデータ駆動テストにも便利そうです。

ランダムデータ駆動テストを自動化する

さらにモジュールを自動的に探してきて、そのモジュールで提供されている関数をテストしてくれると便利だったりしないかな?シグネチャさえ分かればできるよ!ようやく関数アノテーションの出番です。

サンプル実装として以下のようなものを作ってみました。テストディレクトリの親ディレクトリから "*.py" ファイルを探してきて、そのモジュールの __all__ で提供されている関数のシグネチャからテストを実行します。

  1 # -*- coding: utf-8 -*-                                                           
  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 の発表をされていたので楽しみです。