optparse のコールバック処理を argparse モジュールへ移行する

Pythonコマンドライン引数の解析ツールは getoptoptparse、さらに Python 2.7 から argparse が標準ライブラリとして追加されています。

Python 2.6 環境では argparse 使えないからと、私は argparse の使用を少し躊躇していたところがあったのですが、PEP389(和訳) によると、argparse は Python 2.3 以上で使えるようなので、optparse がなければ argparse 1.2.1 : Python Package Index からインストールすれば良いんだと気付きました。

最近、車輪の再発明をしている(?)記事 *1 を見つけたりと、今から開発するコマンドライン引数を受け取るプログラムには、もっと積極的に argparse を使おうよと呼びかけてみようと思った次第なんです。argparse の使い方は、argparse – コマンドラインオプションと引数の解析argparseを使ってみた - そこはかとなく書くよ。 を参考にしてください。

argparse に限っては、よく分からなくても新しい方のライブラリ使っておいたら良いよ的に考えても大丈夫だと思います。

そこで過去に自分が実装した処理を optparse から argparse へ移行していたら、optparse でコールバック関数を定義していた処理が、argparse ではカスタムアクションを定義するように変わっていることに気付きました。コールバック関数のちょっとした移行 Tips として簡単にまとめておきます。その他の移行の注意点は optparse のコードをアップグレードする を参照してください。

まずは optparse で action にコールバック関数を呼び出す処理です。

def get_lower(option, opt_strings, values, parser):
    parser.values.lower = values.lower()

def get_optparse_args():
    """
    >>> import sys
    >>> sys.argv = ["sample.py", "-l", "TEST"]
    >>> get_optparse_args()
    ('Values', "{'lower': 'test'}")
    """
    import optparse
    parser = optparse.OptionParser()
    parser.add_option("-l", "--lower", dest="lower", type="string",
                      action="callback", callback=get_lower,
                      metavar="LOWER", help="callback test")
    opts, args = parser.parse_args()
    return opts.__class__.__name__, str(opts)

"-l" で受け取る引数の文字列を小文字に変換して格納するだけの処理です。この処理を argparse で実装する場合は、次のようにカスタムアクションを定義します。

import argparse

class LowerAction(argparse.Action):
    def __call__(self, parser, namespace, values, options_string=None):
        setattr(namespace, self.dest, values.lower())

def get_argparse_args():
    """
    >>> import sys
    >>> sys.argv = ["sample.py", "-l", "TEST"]
    >>> get_argparse_args()
    Namespace(lower='test')
    """
    parser = argparse.ArgumentParser()
    parser.add_argument("-l", "--lower", dest="lower",
                        action=LowerAction,
                        metavar="LOWER", help="callback test")
    opts = parser.parse_args()
    return opts

argparse.Action を継承して __call__ をオーバーライドします *2 。こういうシンプルな用途だと、わざわざクラスを作るのも面倒な気がしますが、より柔軟性のあるカスタムアクションが定義できるようになったんだろうと思います。

optparse で使用したコールバック関数 get_lower() と argparse で使用した LowerAction の簡単なテストコードは、それぞれ次のようになります。

# -*- coding: utf-8 -*-
from nose.tools import *
from migrate_optparse_to_argparse import *

def test_get_lower():
    class Parser(object):
        class Values(object):
            lower = None
        values = Values()
    parser = Parser()
    get_lower(None, None, "TEST", parser)
    assert_equal("test", parser.values.lower)

def test_lower_action():
    class Namespace(object):
        lower = None
    namespace = Namespace()
    a = LowerAction(["-l", "--lower"], dest="lower")
    a(None, namespace, "TEST", None)
    assert_equal("test", namespace.lower)

コマンドラインオプションのテストを書く

見てしまった、、、


昨日の今日なのでやりましょう、頼まれていませんがー(> <)

テスト対象のサンプルとして http://d.hatena.ne.jp/t2y-1979/20101124/1290534464 から optparse モジュール を利用している parse_option() 関数だけ抜き出してきます。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import optparse
import os
import sys

# Globals
VERSION = "0.30"

def parse_option():
    usage = "usage: %prog [option] eratos.log"
    ver = "%s %s" % ("%prog", VERSION)
    parser = optparse.OptionParser(usage, version=ver)
    parser.add_option("-f", "--from", dest="date_from",
                      metavar="YYYYMMDDHHMM", help="filter with from date")
    parser.add_option("-t", "--to", dest="date_to",
                      metavar="YYYYMMDDHHMM", help="filter with date to")
    parser.add_option("-q", "--query", dest="query",
                      metavar="STRING", help="filter with string")
    parser.add_option("-o", "--outputfile", dest="output",
                      metavar="OUTPUT_FILE", default="checked.log",
                      help = "output file name")
    parser.add_option("-e", "--error", dest="error",
                      action="store_true", default=False,
                      help="filter with error log message")
    parser.add_option("-v", "--verbose", dest="verbose",
                      action="store_true", default=False,
                      help="print log on standard output")
    
    opts, args = parser.parse_args()
    if args and os.access(args[0], os.R_OK):
        return opts, args
    else:
        print parser.print_help()
        sys.exit(0)

def main():
    opts, args = parse_option()

if __name__ == "__main__":
    main()

Pikzie (ピクジー) のデータ駆動テストを利用してテストを書きます*1。デコレータでデータを登録できるのですっきり書けます。

# -*- coding: utf-8 -*-

import sys
import tempfile
import pikzie
from StringIO import StringIO

# target modules for test
from log_analyzer import VERSION, parse_option

class TestCommandLineParser(pikzie.TestCase):
    """Test for command line parser"""

    def setup(self):
        self.log_file = tempfile.NamedTemporaryFile()
        self.log_name = self.log_file.name
        sys.stdout, sys.stderr = StringIO(), StringIO()

    def teardown(self):
        self.log_file.close()

    @pikzie.data("ver01", ["prog", "--version"])
    def test_version(self, argv):
        sys.argv = argv
        self.assert_raise_call(SystemExit, parse_option)
        self.assert_equal(sys.stdout.getvalue().split()[-1], VERSION)

    @pikzie.data("from03", ["prog", "--from", "20101124" ])
    @pikzie.data("from02", ["prog", "-f", "201011241952", "-e"])
    @pikzie.data("from01", ["prog", "-f", "201011241952"])
    def test_date_from(self, argv):
        argv.append(self.log_name)
        sys.argv = argv
        opts, args = parse_option()
        self.assert_equal(opts.date_from, argv[2])
        self.assert_equal(args, argv[-1:])

    @pikzie.data("err_true03", ["prog", "-e", "-v", "--error"])
    @pikzie.data("err_true02", ["prog", "-t", "201011242045", "-e"])
    @pikzie.data("err_true01", ["prog", "-e"])
    def test_error_true(self, argv):
        argv.append(self.log_name)
        sys.argv = argv
        opts, args = parse_option()
        self.assert_true(opts.error)
        self.assert_equal(args, argv[-1:])

    @pikzie.data("err_false03", ["prog", "--outputfile", "t.log"])
    @pikzie.data("err_false02", ["prog", "-q", "クエリ", "-v"])
    @pikzie.data("err_false01", ["prog", "-f", "201011242035"])
    def test_error_false(self, argv):
        argv.append(self.log_name)
        sys.argv = argv
        opts, args = parse_option()
        self.assert_false(opts.error)
        self.assert_equal(args, argv[-1:])

    @pikzie.data("fail05", ["prog", "-v", "--detarame", "-e"])
    @pikzie.data("fail04", ["prog", "-o", "-t", "201011242036"])
    @pikzie.data("fail03", ["prog", "-q", "a", "--outputfile"])
    @pikzie.data("fail02", ["prog", "-f", "--error", "20101124"])
    @pikzie.data("fail01", ["prog", "--from"])
    def test_parser_fail(self, argv):
        argv.append(self.log_name)
        sys.argv = argv
        self.assert_raise_call(SystemExit, parse_option)

    @pikzie.data("noargs01", ["prog", "-v"])
    def test_no_args(self, argv):
        sys.argv = argv
        self.assert_raise_call(SystemExit, parse_option)

テストを実行します。

$ python test_log_analyzer_optparse.py 
................
Finished in 0.043 seconds

16 test(s), 33 assertion(s), 0 failure(s), 0 error(s),
 0 pending(s), 0 omission(s), 0 notification(s)

全部、成功しました!テストはやりだすと成功するのが嬉しくて楽しくなってきます。

Pikzie の良いところの1つはテストが失敗したときの見易さもあります。ここではわざと test_date_from() のテストが失敗するように "from02" のデータを変更します。

(変更前)
    @pikzie.data("from02", ["prog", "-f", "201011241952", "-e"])

(変更後)
    @pikzie.data("from02", ["prog", "-v", "-f", "201011241952", "-e"])

テストを実行します。

$ python test_log_analyzer_optparse.py 
.F..............

1) Failure: TestCommandLineParser.test_date_from (from02):
            self.assert_equal(opts.date_from, argv[2])
  data: ['prog', '-v', '-f', '201011241952', '-e', '/tmp/tmpeX2M2B']
test_log_analyzer_optparse.py:33: self.assert_equal(opts.date_from, argv[2])
expected: <'201011241952'>
 but was: <'-f'>

Finished in 0.039 seconds

16 test(s), 31 assertion(s), 1 failure(s), 0 error(s),
 0 pending(s), 0 omission(s), 0 notification(s)

どのテストの失敗して、原因が何なのかが "expected" と "but was" の diff が付いていて分かり易いです。またデータ駆動テストなので他の "from01" や "from03" のテスト実行には影響がありません。"from02" のテストだけエラーだと分かるので、"from02" で登録したデータに問題があるんじゃ、、、とデバッグし易いですね。

次に parse_option() 関数内では print でヘルプを出力しています。

...
        print parser.print_help()
...

テスト結果に支障はありませんが、StringIO モジュール 等を使うと不要な出力をターミナルへ表示させなくて済みます。

...
        sys.stdout, sys.stderr = StringIO(), StringIO()
...

さらにバージョン表示のテストにも応用できたりします。

...
    def test_version(self, argv):
        sys.argv = argv
        self.assert_raise_call(SystemExit, parse_option)
        self.assert_equal(sys.stdout.getvalue().split()[-1], VERSION)
...

Pikzie でデータ駆動テストをやってみた

クリアコード さんが開発している Pikzie (ピクジー) という Python のための書きやすさとデバッグのしやすさを重視した Unit Testing Framework があります。ブログでデータ駆動テスト *1 の紹介をされていました。以前、素数を求めるアルゴリズム -エラトステネスの篩(ふるい)-] を書いたので、それを使って実際にやってみました。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import pikzie

def is_prime(number):
    def hurui(s, p):
        p.append(s.pop(0))
        for i, num in enumerate(s):
            if num % p[-1] == 0:
                s.pop(i)

    _number = int(number)
    prime  = []
    search = range(2, _number)
   
    while not prime or prime[-1] ** 2 < search[-1]:
        hurui(search, prime)
    prime += search
    return _number in prime

@pikzie.data("small", 2)
@pikzie.data("lucky", 7)
@pikzie.data("large", 1013)
def test_true(number):
    assert_true(is_prime(number))

@pikzie.data("negative", -3)
@pikzie.data("zero", 0)
@pikzie.data("unit", 1)
@pikzie.data("square", 4)
@pikzie.data("workload", 15342)
def test_false(number):
    assert_false(is_prime(number))

実行結果。

$ ./test_prime.py 
..EEEFFE

1) Error: __main__.test_false (unit)
  data: 1
./test_prime.py:32: assert_false(is_prime(number))
./test_prime.py:16: hurui(search, prime)
./test_prime.py:8: p.append(s.pop(0))
<type 'exceptions.IndexError'>: pop from empty list

2) Error: __main__.test_false (zero)
  data: 0
./test_prime.py:32: assert_false(is_prime(number))
./test_prime.py:16: hurui(search, prime)
./test_prime.py:8: p.append(s.pop(0))
<type 'exceptions.IndexError'>: pop from empty list

3) Error: __main__.test_false (negative)
  data: -3
./test_prime.py:32: assert_false(is_prime(number))
./test_prime.py:16: hurui(search, prime)
./test_prime.py:8: p.append(s.pop(0))
<type 'exceptions.IndexError'>: pop from empty list

4) Failure: __main__.test_true (large): assert_true(is_prime(number))
  data: 1013
./test_prime.py:24: assert_true(is_prime(number))
expected: <False> is a true value

5) Failure: __main__.test_true (lucky): assert_true(is_prime(number))
  data: 7
./test_prime.py:24: assert_true(is_prime(number))
expected: <False> is a true value

6) Error: __main__.test_true (small)
  data: 2
./test_prime.py:24: assert_true(is_prime(number))
./test_prime.py:16: hurui(search, prime)
./test_prime.py:8: p.append(s.pop(0))
<type 'exceptions.IndexError'>: pop from empty list

Finished in 0.075 seconds

8 test(s), 2 assertion(s), 2 failure(s), 4 error(s), 
           0 pending(s), 0 omission(s), 0 notification(s)


ノーッ!!!

バグだらけだということが発覚してしまいました(T T)

wikipedia:素数 の定義は

1とその数自身以外に正の約数がない(つまり1とその数以外のどんな自然数によっても割り切れない)、1より大きな自然数のこと

なので、1以下の数のチェック処理が抜けていることに気付きました。

--- test_prime.py	2010-08-20 13:59:23.127652865 +0900
+++ test_prime2.py	2010-08-20 13:58:53.877652375 +0900
@@ -11,6 +11,9 @@
                 s.pop(i)
 
     _number = int(number)
+    if _number <= 1:
+        return False
+    
     prime  = []
     search = range(2, _number)

修正後、テストを実行してみます。

$ ./test_prime2.py 
.....FFE

1) Failure: __main__.test_true (large): assert_true(is_prime(number))
  data: 1013
./test_prime2.py:29: assert_true(is_prime(number))
expected: <False> is a true value

2) Failure: __main__.test_true (lucky): assert_true(is_prime(number))
  data: 7
./test_prime2.py:29: assert_true(is_prime(number))
expected: <False> is a true value

3) Error: __main__.test_true (small)
  data: 2
./test_prime2.py:29: assert_true(is_prime(number))
./test_prime2.py:21: hurui(search, prime)
./test_prime2.py:8: p.append(s.pop(0))
<type 'exceptions.IndexError'>: pop from empty list

Finished in 0.076 seconds

8 test(s), 5 assertion(s), 2 failure(s), 1 error(s),
           0 pending(s), 0 omission(s), 0 notification(s)

エラーが減りました。次に 7 と 1013 という素数を与えているのに False と判定されているテストに注目します。おや、探索リスト search の範囲指定にバグがありました(- -#

--- test_prime2.py	2010-08-20 14:06:43.875654037 +0900
+++ test_prime3.py	2010-08-20 14:06:36.699777499 +0900
@@ -15,7 +15,7 @@
         return False
     
     prime  = []
-    search = range(2, _number)
+    search = range(2, _number+1)
    
     while not prime or prime[-1] ** 2 < search[-1]:
         hurui(search, prime)

修正後、テストを実行してみます。

$ ./test_prime3.py 
.......E

1) Error: __main__.test_true (small)
  data: 2
./test_prime3.py:29: assert_true(is_prime(number))
./test_prime3.py:20: while not prime or prime[-1] ** 2 < search[-1]:
<type 'exceptions.IndexError'>: list index out of range

Finished in 0.074 seconds

8 test(s), 7 assertion(s), 0 failure(s), 1 error(s),
           0 pending(s), 0 omission(s), 0 notification(s)

あと1つです。素数 2 を与えた場合、探索リスト search が1つの要素しか持っていないため hurui 関数の中で search から pop() すると空リストになり prime[-1] ** 2 < search[-1] の条件判定で IndexError が発生します。2 は仕様として True を返すように修正することもできますが、せっかくテストケースがあるので while ループの脱出条件を変更してみましょう。

--- test_prime3.py	2010-08-20 14:06:36.699777499 +0900
+++ test_prime4.py	2010-08-20 15:07:52.620777507 +0900
@@ -17,8 +17,11 @@
     prime  = []
     search = range(2, _number+1)
    
-    while not prime or prime[-1] ** 2 < search[-1]:
+    while True:
         hurui(search, prime)
+        if not search or search[-1] <= prime[-1] ** 2:
+            break
+    
     prime += search
     return _number in prime

修正後、テストを実行してみます。

$ ./test_prime4.py
........
Finished in 0.069 seconds

8 test(s), 8 assertion(s), 0 failure(s), 0 error(s),
           0 pending(s), 0 omission(s), 0 notification(s)


やりました!

テストはバグを潰していく過程が目に見えるので楽しいですね。