argparseでconfigファイルを使えるようにする
CLI ツールを作るときに、設定ファイルを使いたい場合があると思います。
Python には argparse という素晴らしい CLI 用の引数パーサがありますが、設定ファイルの読み込みには対応していません。
configparser という設定ファイル読み込み用モジュールもありますが、argparse と連携させるのを想定されてません。
そこで、argparse に設定ファイルを読ませる方法を探しました。
- Which is the best way to allow configuration options be overridden at the command line in Python? - Stack Overflow
- GitHub - bw2/ConfigArgParse: A drop-in replacement for argparse that allows options to also be set via config files and/or environment variables.
これらはどちらも同じ目的を達成するアプローチですが、前者は argparse の subparser について触れていません。また、デフォルトを dict で設定するのは個人的に嫌でした。
後者は使ってみたところ、config の section が作れない、subcommand の config の設定ができない等の問題があるようでした。
以上を踏まえて、
- argparse の command オプションで
-c, --config_file
を受け取る - config_file が指定された場合はそれで command、subcommand のデフォルト値を上書きする
- config_file は section の設定が可能
- config_file は subcommand 用の記述が可能
- command, subcommand に CLI でオプションが与えられた場合は config で設定した値を上書きする
といった機能を考えました。実装したものが以下のものです。
インストールは、
git clone https://github.com/walkingmask/argconfigparse_dev.git cd argconfigparse_dev pip install -e argconfigparse
です。
言葉の定義
ちょっとだけこの記事での言葉を定義しておきます。
- arg:
parser.add_argument('arg')
のように--
なしで設定されたもの、必須 - config:
parser.add_argument('--sub_config')
のように設定されたもの、そのデフォルト値、config_file に設定されたその値 - option: CLI引数で config を指定されたもの、デフォルト値や config_file を上書きする
実装について
ごちゃごちゃ色々置いてありますが、メインのものはこれだけです。
特に、_cli_parser
がコア部分です。まずはその中の build_parser
のポイントを箇条書きしていきます。
- parser には必ず
conflict_handler='resolve'
をつける- これはデフォルト値を上書きした時に add_argument を再度実行する実装を以前行っていたため付けてましたが、今は必要ないかも
- config ファイル用の add_argument
- command の arg, config を設定
command arg1 ... --config1 config1 ...
の部分がこの時点で完成
- subcommand 用の parser 群を作成
- dest, required の指定で必須化 (参考)
- 全ての subcommands の add_argument を読んでデフォルト値の設定を行う
command ... subcommand sub_arg1 ... --sub_config1 ...
の部分が完成
- 各 subcommand に global な config を設定
- 例えば
--root_path
のような全てのサブコマンドで使いたいようなもの
- 例えば
これでパーサは完成しました。この時点でパースすると、デフォルトの設定が得られます。
次に設定ファイルを読み込み、option で上書きする手順です。
- 一旦パースして、subcommand 名と config_file、デフォルト値を得る
- この時点で arg が足りない、subcommand が求めている global な設定等が足りないとエラーになります
- config_file が指定されている場合はそれを読み込む
- config_file は汎用性を考えて json を想定、configparser は今回使わなかった
- config から読み込んだ値を対応する argparse の config のデフォルト値に設定します
- この辺は argparse の private メソッドでゴリゴリやってます...
- デフォルト値を上書きした場合は、パースし直します
- これによって、新たに得られるarg, config の値は config_file によって設定されたものに
- CLI で
--sub_config1
のように option が指定されていればその値が取得されます
- 最後に args, subcommand_args, config を分けて返します
これが基本の動作です。ここにたどり着くまで結構悩みながら試行錯誤しました。。。
特に、デフォルト値 -> config -> option の順で上書きしていくロジックは parse を二回するなど苦しい感じですね。
その他こだわりポイント
get_subcommands
や set_config
等は個人的なこだわりで作りました。コマンドを作る際、ディレクトリ構造はだいたいこんな感じになると思います。
command/ __init__.py subcommand1/ __init__.py ... ...
このディレクトリ構造からコマンドを設定していくためには、subcommand/__init__.py
等に何か作って、command/__init__.py
でそれを import することになると思うのですが、そうすると、
from subcommand1 import foo as sub1_foo, ... from subcommand2 import foo as sub2_foo, ... from subcommand3 import foo as sub3_foo, ...
という悲惨なことに...。そこで、importlib.import_module
と setuptools.find_packages
を使って、自動で subcommands を走査します。
is_subcommand = True
が subcommand/__init__.py
に設定された package を subcommand とみなし、set_args, set_config, check_config
を自動で import し実行します。それらが定義されていなくても問題はありません。
また、command/__init__.py
にある set_args, set_config, set_global_config
は定義されているだけで実行されます。
is_subcommand
を設定しなくても、CLIParser.register
で import した subcommand を登録しすることも可能です。
from argconfigparse import CLIParser import subcommand3 parser = CLIParser() parser.get_subcommands() parser.register(subcommand3) parser.parse()
うー。シンプル。
課題
今のところ、nargs といったオプションは想定してない (configargparse では実装されていた) ので、今後必要が出てきたら実装していきたいですね。