記事内容を全面的に書き直しました。
概要
argparse
を使ったことはあるでしょうか?python標準で組み込まれているコマンドラインパーサですが、今回は解りにくいargparse
をさらにわかりにくく使ってみる実験です。
まずそのままパースしてみる
まず、今回の例に使う基本のコードを示します。コマンドラインからオプション引数-A
と-B
を受け取るだけの単純なコードです。
import argparse
perser = parser = argparse.ArgumentParser()
parser.add_argument('-A', default='A') # -A のデフォルトは A
parser.add_argument('-B', default='B') # -B のデフォルトは B
ns = parser.parse_args()
print(ns)
特におかしなことはなく、引数があればNamespace
に反映して、なければデフォルト値を返します。
$ python parse1.py -A X -B Y # 指定した引数が表示される
Namespace(A='X', B='Y')
$ python parse1.py # 引数がないのでデフォルト値が設定される
Namespace(A='A', B='B')
$ python parse1.py -A A -B B # デフォルト値と同じ引数
Namespace(A='A', B='B')
3回目の実行では引数がデフォルト値と同じなので、parse_args
が返した結果からは実際に引数が渡されていたのか判別できません。当たり前です。それを 知る必要がある値ならばデフォルト値を持つべきではないでしょう。
しかし、それでもそれを知りたいケースが出てきたので実験してみました。
Actionの挙動から判別する
Action
クラスを自作して、Action.__call__
の呼出しから引数の有無を出力させてみました。
import argparse
# カスタムアクションを定義
class ActionTester(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
print(f'call {self.dest}:{values}') # __call__が呼ばれたことを知らせる
setattr(namespace, self.dest, values)
perser = parser = argparse.ArgumentParser()
parser.add_argument('-A', default='A', action=ActionTester) # -A のデフォルトは A
parser.add_argument('-B', default='B', action=ActionTester) # -B のデフォルトは B
ns = parser.parse_args()
print(ns)
引数-A
だけを与えたところ、-A
に対する__call__
のみが呼び出されました。これで引数が渡されない場合はAction.__call__
が呼ばれないことが解りました。1
$ python parse2.py -A A
call A:A
Namespace(A='A', B='B')
2回パースしてしまう
しかし、Action.__call__
内では判別した結果を残しづらく、まだ他の引数のパースも終わっていません。もっと解りやすい方法を考えた結果、2回パースする方法に辿り着きました。
import argparse
# -Bの存在を先に確認する
parser1 = argparse.ArgumentParser(add_help=False)
parser1.add_argument('-B', nargs='?') # 値が不正でもエラーは起こさない
ns, other = parser1.parse_known_args()
# -B が指定されたかここで判る
print(ns)
# もう一度同じ引数をパースする
parser2 = argparse.ArgumentParser()
parser2.add_argument('-A', default='A') # -A のデフォルトは A
parser2.add_argument('-B', default='B') # -B のデフォルトは B
ns = parser2.parse_args()
print(ns)
1回目のparse_known_args
で-B
にデフォルト値を指定せず。ここで実際に指定されたかどうかが解ります。-B
が指定されていない場合は続くparse_args
でデフォルト値が設定されます。以下のようにパースの途中で-B
の存在が確認されます。
$ python parse4.py -A A
Namespace(B=None)
Namespace(A='A', B='B')
若干注意が要るのは、parse_known_args
時点でパースエラーを起こしてしまうとエラーメッセージが後続のparse_args
と違うものになってしまうので、パーサをparse_args
に使う時より緩く設定します。さらに、parse_known_args
が返したNamespace
をparse_args
に引き継いでしまうとデフォルト値が設定されないので、新しいNamespace
を受け取るようにします。これで実験としては成功と言えるでしょう。
これが必要になった経緯
この時書いていたプログラムは、プログラムの動作設定をコマンドライン以外にも設定ファイルから取得する。という動作がありました。
- コマンドラインで直接指定された値が最優先
- 設定ファイルに書き込まれた内容で値を補完する
- コマンドラインにも設定ファイルにも書かれていない値はデフォルト値を適用する
という、ややこしいものでしたが実装に挑戦してみたわけです。
import argparse
# -Bの存在を先に確認する
parser1 = argparse.ArgumentParser(add_help=False)
parser1.add_argument('-B', nargs='?') # 値が不正でもエラーは起こさない
ns, other = parser1.parse_known_args()
# B が X なら A のデフォルト値はXXXにする
ns2 = argparse.Namespace()
if ns.B is not None and ns.B == 'X':
# ここで設定ファイルから値を取り込む(イメージ)
setattr(ns2, 'A', 'XXX')
# もう一度同じ引数をパースする
parser2 = argparse.ArgumentParser()
parser2.add_argument('-A', default='A') # -A のデフォルトは A
parser2.add_argument('-B', default='B') # -B のデフォルトは B
ns2 = parser2.parse_args(namespace=ns2)
print(ns2)
実行するとこうなります。
$ python parse5.py
Namespace(A='A', B='B')
$ python parse5.py -A A -B B
Namespace(A='A', B='B')
python parse5.py -A AAA
Namespace(A='AAA', B='B')
$ python parse5.py -B X # この場合だけAを書き換える
Namespace(A='XXX', B='X')
Namespace
を複数作り直しているのがポイントです。これで目的は達成できたのですが、 argparse
に頼り過ぎたせいで遠回りなコードになっている感は否めません。
-
なお
Action.__init__
は常に呼び出されるようです。 ↩