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 の方が使い勝手が良いと私は思いました。

あわせてやってみよう。

追記

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

イテレーターという抽象概念にもの思い

元ネタ:

最近、Java のコードばかり書いています。

Java には、Python にはない wikipedia:抽象型 としてのインターフェースがあります。
パーフェクト Java から引用すると、インターフェースは、

オブジェクトにそのインターフェースの決めた振る舞いを期待することの表明

だと説明されています。また Java でプログラミングする上で、インターフェースを意識した抽象化が、複雑さの軽減、保守しやすいコードを書くのに重要だとあります *1

以前、EuroPython 2011 のイベントレポートを執筆した際に *2 、私はイテレーターという用語の扱い方に何となく腑に落ちていませんでした。Python では抽象クラスやインターフェースが言語機能として提供されていません。そのとき @ に「イテレーターはインターフェースだと考えたら良い」と教えてもらって、分かったような分かってないような日々を過ごしていました。

wikipedia の定義によると、

イテレータ (Iterator) とは、プログラミング言語において配列やそれに類似するデータ構造の各要素に対する繰返し処理の抽象化である。

イテレータ - Wikipedia

とあります。イテレーターと聞いて Python で言う iterable なオブジェクトであるリストやタプルをイメージしてしまっていたのが理解を妨げていました。

また Java の話しに戻ります。

最近、Java のコードばかり、、、あっ、違う。

閑話休題Java5 から利用できる拡張 for 文という構文を使うと、Python の for in 文と同じような感覚でイテレーターを扱えます。例えば、インターフェース java.util.List の定義を調べてみます。すると、java.util.Iterator を返すインターフェース java.util.Iterable を継承していることが分かります。UML で書くと、こんな感じです (たぶん) 。

Java の Iterable インターフェースの iterator() メソッドが、Pythonイテレーター型をサポートするための __iter__() メソッドIterator インターフェースの next() メソッドが同じく Python で言う next() メソッドに相当します *3 。つまり、どちらの言語もほとんど同じような仕組みになっているわけです。

java.util.List で宣言したオブジェクトであれば、イテレーターのインターフェースを備えている (と期待できる) ので、どんなデータ型であっても拡張 for 文で繰り返し処理できます。言い換えれば、Java.util.List と宣言することが、繰り返し処理できることの表明であり、プログラマへもその意図を伝えられます。

インターフェースにより、型の違いや実装の詳細を意識させずに抽象化できることが Java におけるパラダイムの1つなんだと少し分かってきました。つまり、あるデータ型のオブジェクトが与えられたとき、

List<Item> data = new myData();

data オブジェクトの詳細を知らなくても iterable なオブジェクトだと分かります。もちろん Python でも似たようなことはできます。

class MyIterable(object):
    def __iter__(self):
        return self
    def next(self):
        return self.next()

class MyData(MyIterable):
    ...

data = MyData();

この場合、継承している MyIterable クラスの実装を覗き見ることで、あぁこれは iterable なオブジェクトなんだと分かるわけですね。

JavaPython で一体何が違うのか。

Javaイテレーターという抽象化された概念とその実装がインターフェースを使うことで明確に分離されています。しかし、Python の場合、分離されるかどうかは実装依存になってしまいます。MyIterable クラスを定義せずに直接、MyData クラスに __iter__() と next() メソッドを実装しても構いません。このことが私にとっては、イテレーターと呼び方を変えても、結局のところはリストやタプルのようなオブジェクトのことなんでしょ、、、といった、抽象概念としてのイテレーターと iterable なオブジェクトとの違いを混同して考えてしまう要因となっていました。

Python でプログラミングするだけなら、混同して考えてもそう害はないのかな?という気もしますが、プログラミング一般の概念として他の人と話したときにちょっと違和感を感じたものの正体がようやく分かってきた気がします。また Java を勉強していたら Python の特徴をより理解した気持ちになってちょっと嬉しかったです。

まとめると、最近、Java のコードばかり書いているんです。

パーフェクトJava (PERFECT SERIES) (PERFECT SERIES 2)

パーフェクトJava (PERFECT SERIES) (PERFECT SERIES 2)

Jython プログラミング

ずっと積ん読になっていました。

購入したのはおよそ3年前。最近、私が Java を勉強し始めたという動機もあり、3日で読んでしまいました。3年も読めなかった本をいま読めてしまうというのは、いつか読むかもしれないから気になった書籍は取りあえず買っとけという気にさせてくれますね (^ ^;;

さて、本書は Jython の入門本であると同時にプログラミングの抽象概念を、著者の考察と簡潔な論理で説いてくれるので、とても分かりやすく参考になります。私は何となくそういうもんだと解釈していたことが、意図するものの背景を知って、新たな発見がいくつもありました。

Java の世界と Python の世界を行き来する

序盤は Eclipse の使い方や外部ライブラリのパスの通し方も丁寧に紹介されています。私は Eclipse を使い始めたばかりなので、そういった気遣いも嬉しいです。

Jython とは、Java で実装された Python の処理系です。JavaVM 上で Python の処理系が動くので、ほんの数行で Java の世界と Python の世界を行ったり来たりすることができます。本書を読む方は、どちらかの言語をある程度、知っていないと読んでいて混乱するかもしれません。本書内でも Java プログラマ向けに書かれたものだと説明されています。

Python から見たオブジェクト指向

5章まるまる1つの章がオブジェクト指向について著者の考察がサンプルコードと共に展開されます。冒頭の目的から引用します。

世の中の「オブジェクト指向」という言葉の使われ方を見ていると、おおざっぱに言って2つの「思想」があるように思えます。1つは「関連する変数や関数がコードのあちこちに散らばるのはよくない。ひとかたまりのモノとして扱えるべき。」という思想です。もう1つは「整数や関数などのいろいろなモノが、モノによって扱い方が違うのはよくない。統一的な方法で扱えるべき。」という思想です。この2つの思想はまったくの別物です。しかし、同じ「オブジェクト指向」という言葉で語られることが多く、まぎらわしいです。

私にとっては、5章が最もおもしろくて、とても勉強になりました。プログラミングは、ただ単に要件や仕様に沿ってコーディングするということじゃなくて、背景に「いろいろ」あって、効率良くプログラミングするために「いろいろ」あって、うまく言えないんだけど「いろいろ」考えとかないと後で大変なんだよー (> <)

。。。

といった「いろいろ」あるモノの1つが5章で分かりやすく説明されています。実際、私も経験して初めて実感したり、学べば学ぶほど新たな発見があったり、プログラミングって本当におもしろいものです。

オブジェクト指向にクラスは必須ではない

オブジェクト指向とは、クラスを作ってインスタンス化を指すのではないということを説明するために、Python のディクショナリと関数でクラスもどきを実装しています。

以前、私も同じようなことをハンズオン *1 で体験しましたが、当時の私には全く分かっていませんでした。クラスを使わずにクラスもどきを実装することで、オブジェクト指向の本質的な概念を理解するのに有用だと本章を読んで気付かされました。

タプルはなぜ生まれたか

Python のタプルから私が連想するものは、

  • 不変オブジェクト
  • ディクショナリのキーになる
  • 関数の返り値を1つするためにパックされるもの

といったものでした。厳密には、ディクショナリのキーになるのは、特殊メソッド __hash__ を実装して、ハッシュ値が算出できるオブジェクトという定義の方が正しいようです。

ディクショナリの検索は、ハッシュ値からキーに対する値を取得できますが、人間はハッシュ値ではなく、キーの中身で認識します。キーの中身が同じでハッシュ値が異なるものや、キーの中身が違うのにハッシュ値が同じものがあっては困ります。だから、リストではなく、タプルが必要なんだと、実際のコードサンプルと一緒に紹介されています。

継承は慎重に。多重継承はもっと慎重に。

「多重継承を使ったほうがいいコード」とは限りません。むしろ「使わないほうがいいのに多重継承を使ったコードはとても悪いコード」です。Java の作者が「いっそ多重継承は禁止にしてしまえ」と考えるほどです。きちんと理解せずに使うと大変なことになる、そんな危険性を秘めたテクニックだということを、まずは肝に銘じてください。

多重継承を考える際に、問題なのは菱形継承 (ダイヤモンド継承) であると著者は述べています。

Java はその複雑さを取り除くために、クラスの多重継承を禁止して、インターフェースのみ多重継承できるように解決しました。PythonC3 線形化アルゴリズムMRO (メソッド解決順序) を解決します *2

とはいえ、Python においても (理由なく) 菱形継承を行わない方が良さそうです。そういった課題はあるものの、多重継承ができるおかげで Mixin をうまく使って MRO を簡単に切り替えるというテクニックも使えます *3

テスト駆動開発Jython

Python は、wikipedia:グルー言語 としても便利なので、他の何かを扱うのも得意です。

本書では、JyConsole という補完機能をもつ Jython の対話的コンソールを Java プログラム (テストプログラム) 内に組み込むことで、「テストの清書」をする前に「テストのラフスケッチ」をすることを奨めています。

テストのような、地道に何度も実行して確認したりする用途には、Java でやるよりも Python の方がお手軽で良さそうです。対話的コンソールで、テストを書く前に試しに動かしてみる、色んな引数を与えてみるといったことも簡単にできます。おそらくそのお手軽さに反対する人はいないでしょう。

他にも Python には doctest というドキュメントにちょっとしたテストを兼ね備えた仕組みもあります。

Mac OS X で プロキシサーバーを使う

という要望を聞いたので調べてみました。

まずはデバッグのためにローカルの開発環境にプロキシサーバーを構築します。

ちょっとしたテストに squid : Optimising Web Delivery をインストールするのも面倒なので名前から軽量な印象を受ける Tinyproxy - A light-weight HTTP/HTTPS proxy をインストールします。

$ # MacPorts からインストールします
$ sudo port install tinyproxy

正常にインストールしたら設定を行います。デフォルトのままでも HTTP/HTTPS 共に有効になります。

$ # デフォルトの設定ファイルをそのままコピーします
$ cd /opt/local/etc/
$ cp -p tinyproxy.conf.default tinyproxy.conf

$ # 設定内容は man で調べます
$ man tinyproxy.conf

$ # log/run ディレクトリを作成して、環境にあわせて適切なパーミッションを設定してください
$ sudo mkdir /opt/local/var/log/tinyproxy
$ sudo chmod 775 /opt/local/var/log/tinyproxy
$ sudo mkdir /opt/local/var/run/tinyproxy
$ sudo chmod 775 /opt/local/var/run/tinyproxy

設定が完了したらプロキシサーバーを起動してみましょう。

最初は "-d" オプションを付けてフォアグランドで実行すると良いです。設定不備等でエラーがあると、そのメッセージがコンソールに出力されるからです。

$ # -d オプションで何もメッセージが出力されなかったら正常起動
$ tinyproxy -d

ログを確認してみましょう。

root で実行していないのですが、root で実行しないようにと WARNING が出てますね、、、ここでは気にしないことにします。

$ cat /opt/local/var/log/tinyproxy/tinyproxy.log
INFO      Sep 25 20:08:49 [48275]: Initializing tinyproxy ...
INFO      Sep 25 20:08:49 [48275]: Reloading config file
INFO      Sep 25 20:08:49 [48275]: Setting "Via" header to 'tinyproxy'
INFO      Sep 25 20:08:49 [48275]: Adding Port [443] to the list allowed by CONNECT
INFO      Sep 25 20:08:49 [48275]: Adding Port [563] to the list allowed by CONNECT
WARNING   Sep 25 20:08:49 [48275]: Not running as root, so not changing UID/GID.
...

動作を確認してみましょう。デフォルトでは tinyproxy はポート番号 8888 を使います。

$ python
Python 2.7.2 (default, Aug 22 2011, 13:53:27) 
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import os
>>> os.environ["http_proxy"] = "localhost:8888"
>>> import urllib2
>>> r = urllib2.urlopen("http://www.python.org")
>>> r.headers.headers
['Via: 1.1 tinyproxy (tinyproxy/1.8.3)\r\n', ...]

以下のようなログが出力されます。

$ tail -f /opt/local/var/log/tinyproxy/tinyproxy.log
CONNECT   Sep 25 20:24:40 [48403]: Connect (file descriptor 9): localhost [127.0.0.1]
CONNECT   Sep 25 20:24:40 [48403]: Request (file descriptor 9): GET http://www.python.org HTTP/1.1
INFO      Sep 25 20:24:40 [48403]: No upstream proxy for www.python.org
CONNECT   Sep 25 20:24:40 [48403]: Established connection to host "www.python.org" using file descriptor 10.
INFO      Sep 25 20:24:41 [48403]: Closed connection between local client (fd:9) and remote client (fd:10)

ikazuchi のプロキシサーバー対応

開発のためのデバッグ環境は構築できました。

さて、本題の ikazuchi のプロキシサーバー対応です。結論から言って、先の Python の対話シェルで設定したように、環境変数の HTTP_PROXY と HTTPS_PROXY に設定しておくと、urllib2 モジュール内部でプロキシ設定をデフォルトで行ってくれます。そのため、従来のバージョンでも環境変数に設定することでプロキシサーバーに対応しています。

また、以下のように明示的に ProxyHandler の設定もできます *1

>>> import urllib2
>>> proxy = urllib2.ProxyHandler({"http": "localhost:8888"})
>>> opener = urllib2.build_opener(proxy)
>>> urllib2.install_opener(opener)
>>> r = urllib2.urlopen("http://www.python.org")
>>> r.headers.headers
['Via: 1.1 tinyproxy (tinyproxy/1.8.3)\r\n', ...]

とはいえ、せっかく調べたので ikazuchi.conf で明示的に http_proxy と https_proxy を設定できるように修正した ikazuchi 0.5.3 をリリースしました *2

EuroPython 2011 イベントレポート 第3回 (最終回)

1週間遅れてしまいましたが、最後の #4 アプリケーションに関する講演,LT,PSFメンバーミーティング,まとめ を書きました。

イベントに参加した方、一緒に行ってくれた方、レビューしてくれた方、編集してくれた方、みなさん、お疲れ様でしたー。

EuroPython 2011 イベントレポート 第2回

先週に引き続き、第2回のレポートが公開されました。

私が執筆した #2 CPythonについてのハンズオン,講演id:rokujyouhitoma が執筆した #3 PyPyについての講演,ハンズオン,スプリント の2本立てです。

Python プログラミングのテクニックや (比較的) 新しめのライブラリをいくつか紹介しています。