0
1

argparseはデフォルト引数を受け取ったのか?【改訂版】

Last updated at Posted at 2021-11-03

記事内容を全面的に書き直しました。

概要

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が返したNamespaceparse_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に頼り過ぎたせいで遠回りなコードになっている感は否めません。

  1. なおAction.__init__は常に呼び出されるようです。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1