ちょっとしたコマンドラインツールをpythonで作る時に便利なargparseなんですが、位置引数ありparserが出す例外をテストするコードを書いて初めて知ったことがありました。
そのメモ書きになります。
位置引数とは
argparseの公式ページにも載っている通り、コマンドライン実行時に必須となる引数のことです。以下引用。
位置引数は次のように作成します:
>>>
>>> parser.add_argument('bar')
parse_args() が呼ばれたとき、オプション引数は接頭辞 - により識別され、それ以外の引数は位置引数として扱われます:
>>>
>>> parser = argparse.ArgumentParser(prog='PROG')
>>> parser.add_argument('-f', '--foo')
>>> parser.add_argument('bar')
>>> parser.parse_args(['BAR'])
Namespace(bar='BAR', foo=None)
>>> parser.parse_args(['BAR', '--foo', 'FOO'])
Namespace(bar='BAR', foo='FOO')
>>> parser.parse_args(['--foo', 'FOO'])
usage: PROG [-h] [-f FOO] bar
PROG: error: too few arguments
位置引数ありのparserをテストする際に気をつけるべきこと
あれっ と思いました。
位置引数ありのparserをテストするとき、エラーケースのテストを書くにはどうするんだ??と。
例えば以下のようなコードを書いたとして、
import sys
import argparse
def init(argv=sys.argv[1:]):
arg = argparse.ArgumentParser(
description=""main program to test TS-MPPT-60 monitor modules"")
arg.add_argument(
""host_name"",
type=str,
help=""TS-MPPT-60 host address""
)
arg.add_argument(
""-xa"", ""--xively-api-key"",
type=str,
nargs='?', default=None, const=None,
help=""Xively API key string""
)
return arg.parse_args(argv)
以下のようなテストを書くと、
class TestArgParser(unittest.TestCase):
def test_default_args(self):
parsed = argparser.init([])
以下のようなエラーになります。
test_argparser.py: error: the following arguments are required: host_name
Eusage: test_argparser.py [-h] [-xa [XIVELY_API_KEY]] [-xf [XIVELY_FEED_KEY]]
[-kp [KEENIO_PROJECT_ID]] [-kw [KEENIO_WRITE_KEY]]
[-tck [TWITTER_CONSUMER_KEY]]
[-tcs [TWITTER_CONSUMER_SECRET]] [-tk [TWITTER_KEY]]
[-ts [TWITTER_SECRET]] [-be] [-bl [BATTERY_LIMIT]]
[-bs [BATTERY_LIMIT_HOOK_SCRIPT]]
[-ch [CHARGE_CURRENT_HIGH]]
[-bf [BATTERY_FULL_LIMIT]] [-i INTERVAL]
[-l LOG_FILE] [--just-get-status] [--status-all]
[--debug]
host_name
test_argparser.py: error: the following arguments are required: host_name
E
======================================================================
ERROR: test_battery_full_limit (__main__.TestArgParser)
----------------------------------------------------------------------
Traceback (most recent call last):
File ""/Users/takashi/Development/solar_monitor/test/test_argparser.py"", line 81, in test_battery_full_limit
parsed = argparser.init([""-bf"", ])
File ""/Users/takashi/.anyenv/envs/pyenv/versions/test_py35/lib/python3.5/site-packages/solar_monitor/argparser.py"", line 147, in init
return arg.parse_args(argv)
File ""/Users/takashi/.anyenv/envs/pyenv/versions/3.5.1/lib/python3.5/argparse.py"", line 1726, in parse_args
args, argv = self.parse_known_args(args, namespace)
File ""/Users/takashi/.anyenv/envs/pyenv/versions/3.5.1/lib/python3.5/argparse.py"", line 1758, in parse_known_args
namespace, args = self._parse_known_args(args, namespace)
File ""/Users/takashi/.anyenv/envs/pyenv/versions/3.5.1/lib/python3.5/argparse.py"", line 1993, in _parse_known_args
', '.join(required_actions))
File ""/Users/takashi/.anyenv/envs/pyenv/versions/3.5.1/lib/python3.5/argparse.py"", line 2385, in error
self.exit(2, _('%(prog)s: error: %(message)s\n') % args)
File ""/Users/takashi/.anyenv/envs/pyenv/versions/3.5.1/lib/python3.5/argparse.py"", line 2372, in exit
_sys.exit(status)
SystemExit: 2
最後の行に回答があるんですけどね。
SystemExit例外がraiseされることを想定してテストを書けば良いのです。
class TestArgParser(unittest.TestCase):
def test_default_args(self):
self.assertRaises(SystemExit, argparser.init, [])
SystemExit例外とは
これはsys.exit()
が送出する例外で、Exception
を継承した普通の例外とはちょっと扱いが異なります。
詳しくは公式のヘルプに載っていますが、重要な部分だけ引用したのが以下です。
Exception をキャッチするコードに誤ってキャッチされないように、Exception ではなく BaseException を継承しています。
つまり、以下のようなコードではキャッチできないんですね。try: argparser.init([]) except Exception as e: print(""hoge: "" + type(e).__name__)
SystemExit
をキャッチするにはこうする必要があります。try: argparser.init([]) except BaseException as e: print(""hoge: "" + type(e).__name__)
例外階層をみると、
SystemExit
以外にもKeyboardInterrupt
やGeneratorExit
なんかもBaseException
を継承しているようです。なるほど。まとめ
- 位置引数ありのparserに位置引数を与えずに実行すると、
SystemExit
例外を出す(要するにsys.exit()
する)SystemExit
はException
ではなくBaseException
を継承している- 位置引数ありのparserの
SystemExit
を出すケースをテストする際には、普通にunittest
のassertRaises
が使える