はじめに
本記事では、Python スクリプトに CLI を追加するツール jsonargparse を紹介します。
jsonargparse は関数やクラスの型ヒントや docstring などを利用して CLI を構築するツールで、同種のツールと比べて、デコレータなどの「CLI のためのコード」をほとんど必要としないという特長があります。これにより、Python の基礎さえ分かっていればスムーズに扱える、学習コストが極めて低いツールとなっています。
このタイプのツールの草分け的存在に Python Fire がありますが、jsonargparse もそれに インスパイアされた ツールです。Fire とは方向性が少し違うので Fire を完全に置き換えるものではありませんが、お手軽 CLI 化ツールとして今一番のお勧めです。
なお、本記事は Fire 愛用者の筆者が jsonargparse に乗り換えるために調べた知見をまとめたものです。このため、Fire を意識しまくった内容となっており、また jsonargparse のすべての機能を網羅するものでもありません。ご了承ください。
本記事は zenn にも投稿しています。内容は同一ですのでお好きな方でご覧ください。
インストール
jsonargparse は pip でインストールできます。
pip install jsonargparse
オプショナルの 追加の依存ライブラリ がいくつかあります。本記事で紹介する機能で使うのは下記の2つです。
-
signatures
: docstring からヘルプの生成ができるようになる -
ruyaml
: config ファイルへの docstring の出力ができるようになる
pip install "jsonargparse[signatures,ruyaml]"
また、依存ではありませんが、型ヒントを使った機能拡張に Pydantic を併用できます。
pip install pydantic
この他にも、本記事では扱いませんが argcomplete による タブ補完 にも対応しています。
基本的な使い方
jsonargparse は標準ライブラリの argparse のような使い方で高度にカスタマイズできますが、今回はより手軽な jsonargparse.CLI クラスを使う方法を紹介します。
なお、本記事では単に CLI と言ったら jsonargparse.CLI
ではなく Command Line Interface のことを指します。ちょっと紛らわしいですがご了承ください。
使い方はとてもシンプルで、コマンドラインから呼び出したい関数があるモジュール内で jsonargparse.CLI
を呼び出すだけです。Fire を使ったことがある方ならお馴染みのやつですね。
def hello(name: str):
print(f"Hello {name}!")
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI()
デフォルトではモジュール内の すべての 関数とクラスの引数に型ヒントが要求されます。
python -m main world
Hello world!
ファイルに1関数しかない時は適切にその関数をコマンドとして認識してくれます。
複数の関数がある時は、各関数がその名前でそれぞれサブコマンドになります。
def hello(name: str):
print(f"Hello {name}!")
def goodbye(name: str):
print(f"Goodbye {name}!")
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI()
python -m main hello world
Hello world!
クラス(メソッド)も呼び出すことができます。
class Greeting:
def hello(self, name: str):
print(f"Hello {name}!")
def goodbye(self, name: str):
print(f"Goodbye {name}!")
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI()
python -m main hello world
Hello world!
上記例のようにモジュール内に1クラスしかない時は、各メソッドがその名前でサブコマンドになります。複数ある時はクラスがその名前でサブサブコマンドになります。
class Greeting:
def hello(self, name: str):
print(f"Hello {name}!")
def goodbye(self, name: str):
print(f"Goodbye {name}!")
def dummy():
...
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI()
python -m main Greeting hello world
Hello world!
エントリポイントの制御
上記の使い方は便利ですが、モジュール内のすべての関数とクラスが CLI に含まれてしまいます。多くの場合、モジュールにはエンドユーザーに公開する必要がないものもあるので、明示的にエントリポイントを制御することになるでしょう。
特定の関数・クラスだけ公開したい時は、jsonargparse.CLI
にそれを渡します。
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI(hello)
python -m main world
複数ある場合はリストで渡します。この場合、関数・クラス名がサブコマンドになります。
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI([hello, goodbye])
python -m main hello world
辞書を使うと、サブコマンド名を変更できます。
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI({"hi": hello, "bye": goodbye})
python -m main hi world
クラスも同様にサブサブコマンド名を変更できます。
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI({"gr": Greeting})
python -m main gr hello world
辞書をネストすればクラスの代わりに関数群でサブサブコマンドを定義したり、任意の深さのサブコマンドを作ったりできますが、ネストした部分には後述のヘルプが使えないので要注意です。
ヘルプコマンド
各関数・メソッドに docstring を書くと、ヘルプコマンドの説明文として使用されます。
def hello(name: str):
"""出会いの挨拶をします。
Args:
name: あなたの名前を入力してください。
"""
print(f"Hello {name}!")
def goodbye(name: str):
"""別れの挨拶をします。
Args:
name: あなたの名前を入力してください。
"""
print(f"Goodbye {name}!")
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI(description="挨拶をするツールです。")
python -m main --help
usage: main.py [-h] [--config CONFIG] [--print_config[=flags]] {hello,goodbye} ...
挨拶をするツールです。
options:
-h, --help Show this help message and exit.
--config CONFIG Path to a configuration file.
--print_config[=flags]
Print the configuration after applying all other arguments and exit. The optional flags customizes the output and are one or more keywords separated by comma. The supported flags are:
comments, skip_default, skip_null.
subcommands:
For more details of each subcommand, add it as an argument followed by --help.
Available subcommands:
hello 出会いの挨拶をします。
goodbye 別れの挨拶をします。
docstring のスタイルは Epytext, Google, Numpydoc, reStructuredText の4種(参考)に対応しており、デフォルトで自動判別されます。
なお、--config
と --print_confg
については後述します。--print_config
の説明文がちょっと長くて鬱陶しいですが、現状でこれを制御する方法は提供されていないようです。
サブコマンドにもヘルプが付きます。
python -m main hello --help
usage: main.py [options] hello [-h] [--config CONFIG] [--print_config[=flags]] name
出会いの挨拶をします。
positional arguments:
name あなたの名前を入力してください。 (required, type: str)
options:
-h, --help Show this help message and exit.
--config CONFIG Path to a configuration file.
--print_config[=flags]
Print the configuration after applying all other arguments and exit. The optional flags customizes the output and are one or more keywords separated by comma. The supported flags are:
comments, skip_default, skip_null.
引数にもちゃんと説明文が付いているところに注目です。
ルートレベルの説明文(usage
のすぐ下に表示されるもの)は、コマンドが複数ある時は表示されません。上記コード例のように明示的に追加する必要があります。モジュールの docstring をそこに表示したい場合は、下記のようにします。
"""挨拶をするツールです。"""
...
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI(description=__doc__)
クラスの docstring を使う場合は下記のようにします。
class Greeting:
"""挨拶をするツールです。"""
...
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI(Greeting, description=Greeting.__doc__)
注意点として、description
は改行をスペースに置換してしまうので、長文の docstring を全文そのまま指定するのは避けた方が良いでしょう。jsonargparse でも(引数の説明文を除いて)docstring の最初の一行だけが使用されます。つまり、例えば次と同じです。
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI(description=__doc__.lstrip().partition("\n")[0])
必須引数とオプション引数
関数の必須引数はコマンドラインでは位置引数、関数の任意引数はコマンドラインではオプション引数(--key value
形式)で指定します。
def command(x: int, y: int = 0):
print(locals())
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI()
python -m main 1 --y 2
{'x': 1, 'y': 2}
=
をセパレータとする形式にも対応しています。
python -m main 1 --y=2
なお、少しややこしくなるので、本記事では以降オプション引数(--key value
形式)の事を key-value 引数と呼ぶことにします。
関数側が *
を使ってキーワード引数を強制していても、必須引数であればコマンドラインでは位置引数しか使えません。
def command(x: int, *, y: int):
print(locals())
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI()
python -m main 1 --y 2 # これはエラーになる。
python -m main 1 2 # こっちが正しい。
対して、型ヒントに Optional
が使用されている場合は、デフォルト値が指定されていなくても任意引数扱いになり、key-value 指定が必要です。
from typing import Optional
def command(x: Optional[int]):
print(locals())
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI()
python -m main 1 # これはエラーになる。
python -m main --x 1 # こっちが正しい。
また、後述しますが dataclass は必須でも任意でも key-value 指定が必要になります。
少しややこしいですが、ヘルプを見れば一目瞭然なので、特に困ることはないと思います。
from typing import Optional
def command(x: int, *, y: int, z: Optional[int]):
print(locals())
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI()
python -m main -h
usage: main.py [-h] [--config CONFIG] [--print_config[=flags]] [--z Z] x y
^^^^^^^^^^^
ここを見る
as_positional=False
を使うと、すべての引数を key-value 指定にもできます。
def command(x: int, *, y: int, z: int = 0):
"""テストコマンド。"""
print(locals())
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI(as_positional=False)
python -m main -h
usage: main.py [-h] [--config CONFIG] [--print_config[=flags]] --x X --y Y [--z Z]
テストコマンド。
options:
-h, --help Show this help message and exit.
--config CONFIG Path to a configuration file.
--print_config[=flags]
Print the configuration after applying all other arguments and exit. The optional flags customizes the output and are one or more keywords separated by comma. The supported flags are:
comments, skip_default, skip_null.
--x X (required, type: int)
--y Y (required, type: int)
--z Z (type: int, default: 0)
ただし、この方法では位置引数との併用はできません。
Fire との比較
jsonargparse は Fire を置き換えるものではありませんが、筆者が Fire と比較して特にいいなと思った点をいくつか挙げます。
型ヒントによるバリデーション
関数引数の型ヒントを利用して、コマンドライン引数のバリデーションができます。
def command(x: int):
print(locals())
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI()
python -m main 1.0 # これはエラーになる。
usage: main.py [-h] [--config CONFIG] [--print_config[=flags]] x
error: Parser key "x":
Expected a <class 'int'>. Got value: 1.0
型ヒントが int
の引数に 1.0
を指定したのでエラーになっています。ちなみに反対に float
に 1
を指定してもエラーにはなりません(参考)。
Fire だと型ヒントは無視して入力から自動判別されるので、自前でバリデートする必要があります。他にも例えば "20240102_123456"
という文字列を int
に変換してしまう罠なんかもあり、入力にも気を付けないといけません。その辺が型ヒントを書いておくだけで解決します。
型ヒントによる型の自動変換
型ヒントはエラー処理としてのバリデーションだけでなく、型の自動変換にも利用されます。これにより例えば Path
オブジェクトを要求できます。
from pathlib import Path
def command(x: Path):
print(locals())
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI()
python -m main temp.txt
{'x': PosixPath('temp.txt')}
実際に Path
のインスタンスが関数に渡されていることに注目です。
Fire だと str
のインスタンスが渡されるので、プログラム内の「ファイルパスを表す引数」を Path
に統一できなくて地味に厄介です。CLI はファイルパスを扱うことが多いので、これも型ヒントだけで解決できるのはかなり便利です。
Optional
Optional
でも型ヒント通りに変換してくれます。
from pathlib import Path
from typing import Optional
def command(x: Optional[Path], y: Optional[Path]):
print(locals())
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI()
python -m main --x temp.txt
{'x': PosixPath('temp.txt'), 'y': None}
型ヒント通りに、Path
または None
が渡されているところに注目です。
list, dict
list
や dict
はもちろん、複雑にネストされた型でもいけます。
from pathlib import Path
def command(x: list[dict[str, list[Path]]]):
print(locals())
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI()
python -m main '[{"a": ["temp.txt"]}]'
{'x': [{'a': [PosixPath('temp.txt')]}]}
ここも Path
のインスタンスが渡されていることに注目です。プリミティブ型だけなら eval
や ast.literal_eval
でも似たようなことはできますが、ちゃんと型ヒント通りに変換してくれるのがいいですね。
ちなみに、コマンドラインでこのような複雑なオブジェクトを入力することはあまりないのでちゃんと書けるか心配になりますが、「Python コードをそのまま書いてクォートで囲めばよい」とだけ覚えておけば大丈夫です。
辞書を入力する時は :
の後ろにスペースが必要です。
python -m main '{"a": 1}'
^
Enum
Enum
も扱えます。
from enum import Enum, auto
class Mode(Enum):
foo = auto()
bar = auto()
def command(x: Mode):
print(locals())
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI()
python -m main command foo
{'x': <Mode.foo: 1>}
もちろん定義されていない値を指定するとエラーになります。
python -m main command hoge # これはエラーになる。
usage: main.py [options] command [-h] [--config CONFIG] [--print_config[=flags]] {foo,bar}
error: Parser key "x":
Expected a member of <enum 'Mode'>: {foo,bar}. Got value: hoge
Fire だとこういう時は Literal
で誤魔化すなり str
から明示的に変換するなりで Path
同様に引数の型を Enum
に統一できない問題があるので、地味にありがたいポイントです。
ただし、Enum
の docstring はヘルプに使ってくれないので、各項目の説明は引数の方に明示的に書く必要があります。
dataclass
dataclass
も扱えます(Pydantic の dataclass
や BaseModel
も扱えます)。
from dataclasses import dataclass
@dataclass
class Config:
"""ツールの設定。"""
a: int
"""ひとつめの値。"""
b: bool = False
"""ふたつめの値。"""
def command(x: Config):
print(locals())
if __name__ == "__main__":
import jsonargparse
# これを追加すると dataclass の docstring もヘルプコマンドに使ってくれます。
jsonargparse.set_docstring_parse_options(attribute_docstrings=True)
jsonargparse.CLI()
dataclass
の docstring のパースには signatures
で追加インストールされるライブラリが必要です。詳細はインストールの項を参照してください。また、インストール済みでもデフォルト設定はオフなので、上記コード例のようにオプション設定が必要です。
python -m main command --x '{"a": 1, "b": false}'
{'x': Config(a=1, b=False)}
command
関数の x
引数は必須引数ですが、dataclass
は key-value 指定が必要です。
辞書の代わりにフィールドごとに個別に指定することもできます。
python -m main command --x.a 1 --x.b false
手入力の場合はこちらの方が打ちやすいですね。
通常のクラスはメソッドをサブコマンドとして呼び出すことができますが、dataclass
はクラス本体を呼び出すこともできます。jsonargparse.CLI
は関数やメソッドを呼び出すとその実行結果を返し、dataclass
のクラス本体を呼び出すとそのインスタンスを返します。これは後述する config ファイルを使う時に便利な機能ですが、他にも例えば次のように引数の一部だけを CLI にすることにも使えます。
if __name__ == "__main__":
import jsonargparse
model = jsonargparse.CLI(MODEL_CHOICES)
predict(model, ...)
python -m main ModelA --param1 1000
なお、この機能は dataclass
でのみ利用できます。通常のクラスはサブコマンド(メソッド呼び出し)が必須なので、引数が足りない旨のエラーになります。
型ヒントによる値の制約
型ヒントを拡張して、型だけでなく値のバリデーションもできます。
jsonargparse にも いくつかのプリセット が定義されていますが、個人的には Pydantic Types の利用がお勧めです。ネット上の情報も Pydantic の方が多いので、拡張したくなった際にも詰まりにくいと思います。
例えば下記は、正の整数を表す PositiveInt
を使う例です。
from pydantic import PositiveInt
def command(x: PositiveInt):
print(locals())
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI()
python -m main -1 # これはエラーになる。
usage: main.py [-h] [--config CONFIG] [--print_config[=flags]] x
error: Parser key "x":
1 validation error for constrained-int
Input should be greater than 0 [type=greater_than, input_value='-1', input_type=str]
For further information visit https://errors.pydantic.dev/2.5/v/greater_than. Got value: -1
他にもファイルの存在チェックをしてくれる FilePath
なんかも便利ですね。
from pydantic import FilePath
def command(x: FilePath):
print(locals())
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI(command)
python -m main not-existent-file # これはエラーになる。
usage: main.py [-h] [--config CONFIG] [--print_config[=flags]] x
error: Parser key "x":
1 validation error for function-after[validate_file(), lax-or-strict[lax=union[json-or-python[json=function-after[path_validator(), str],python=is-instance[Path]],function-after[path_validator(), str]],strict=j
son-or-python[json=function-after[path_validator(), str],python=is-instance[Path]]]]
Path does not point to a file [type=path_not_file, input_value='not-existent-file', input_type=str]. Got value: not-existent-file
FilePath
などの Pydantic の Annotated
型ヒントを使用した場合、実際に関数に渡されるのは FilePath
ではなく Path
のインスタンスになるので、プログラムの動作には影響を及ぼしません。
python -m main temp.txt
{'x': PosixPath('temp.txt')}
bool のパースが柔軟
Fire では bool
フラグは --option/--no-option
という値を持たない指定を主としており、値のパースは基本的なものが1パターンあるだけです。コマンドラインで --option=false
を指定すると関数には文字列の "false"
が渡され、そのまま bool
として使うと真になってしまうという罠があります。
jsonargparse では反対に --option/--no-option
という指定は今のところサポートしていません。代わりに明示的な指定が柔軟です。
def command(x: bool = True):
print(locals())
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI()
python -m main --x false
{'x': False}
bool
としてパースできる値は以下ですべてです。
-
True
になるもの:true
,True
,TRUE
,yes
,Yes
,YES
-
False
になるもの:false
,False
,FALSE
,no
,No
,NO
もちろん bool
型としてバリデートされるので、上記以外を指定するとエラーになります。
引数の短縮名が使える
jsonargparse は argparse の allow-abbrev に対応しています。
allow-abbrev が有効だと、
曖昧さがない (先頭文字列が一意である) かぎり、先頭文字列に短縮して指定できます
例えば次のような指定ができます。
from typing import Optional
def command(
x_value_with_long_name: Optional[bool],
y_value_with_long_name: Optional[bool],
):
print(locals())
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI()
python -m main --x false --y true
{'x_value_with_long_name': False, 'y_value_with_long_name': True}
この機能により、「Python コード内では冗長でも説明的な名前を付けたいけれど、コマンドラインの引数名は短くしたい」というニーズを満たせます。
ただし、ヘルプコマンドには反映されないので、エンドユーザーにとっては隠し機能になってしまうこと、また、アップデートで引数名を大きく変えた時に、エンドユーザーが意図せずして異なる引数を指定してしまう潜在的リスクがあることは注意です。
def command(
x_value_with_long_name: Optional[bool],
- y_value_with_long_name: Optional[bool],
+ z_value_with_long_name: Optional[bool],
+ yes: bool = False,
):
print(locals())
+ if not yes: # 危険な操作をするのでユーザーに確認するようにした。
+ ask_permission()
+ print("Performing an irreversible operation!")
python -m main --x false --y true # アップデート前と同じ引数を指定すると……
{'x_value_with_long_name': False, 'z_value_with_long_name': None, 'yes' True}
Performing an irreversible operation!
便利ですが、自分用あるいは小さなチーム内でのみ使うツールに適した機能だと思います。この機能を無効化したい時は allow_abbrev=False
を指定します。
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI(allow_abbrev=False)
Fire との比較はここまでです。続いて、その他の便利な機能について紹介します。
config ファイルの利用
jsonargparse の主要機能の一つに、引数を yaml ファイルに保存し、後から再利用できる機能があります。
def command(x: int, y: str, z: bool = False):
print("Running:", locals())
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI()
このコードは下記のように実行できます。
python -m main 42 abc --z yes
Running: {'x': 42, 'y': 'abc', 'z': True}
この呼び出しに --print_config
を付けると、関数は実行されず、代わりに実行時引数が yaml 形式で出力されます(下記はファイルにリダイレクトしています)。
python -m main 42 abc --z yes --print_config > ./config.yml
x: 42
y: abc
z: true
上記で生成した yaml ファイルを --config
で指定すると、保存した引数で関数を実行します。
python -m main --config ./config.yml
Running: {'x': 42, 'y': 'abc', 'z': True}
複数のコマンドがある時にコマンド名を含む config ファイルを生成したい時は、--print_config
引数をコマンドの前に入れます。
python -m main --print_config command 42 abc --z yes > ./config.yml
command:
x: 42
y: abc
z: true
なお、1ファイルに複数のコマンドの config をまとめて書き出しておくことはできません。あくまでも1回の呼び出しと1対1に対応するファイルになります。
config ファイルは as_positional=False
とセットで使うと、任意の引数だけ上書きして実行できます。
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI(as_positional=False)
$ python -m main --config ./config.yml
Running: {'x': 42, 'y': 'abc', 'z': True}
$ python -m main --config ./config.yml --y foo
Running: {'x': 42, 'y': 'foo', 'z': True}
関数内では2番目の位置引数である y
だけ指定していることに注目してください。
コメント付き config ファイル
ヘルプ同様に、docstring を使ってユーザーフレンドリーな yaml ファイルを生成できます。
コード側の変更は不要で、yaml ファイル生成時に --print_config=comments
を指定します。なお、このオプションは =
をセパレータとする形式での指定が必須です。
def command(x: int, y: str, z: bool = False):
"""テストコマンド。
Args:
x: ひとつめの値。
y: ふたつめの値。
z: みっつめの値。
"""
print(locals())
if __name__ == "__main__":
import jsonargparse
# これを追加すると dataclass の docstring もヘルプコマンドに使ってくれます。
jsonargparse.set_docstring_parse_options(attribute_docstrings=True)
jsonargparse.CLI()
python -m main 42 abc --z true --print_config=comments > ./config.yml
# テストコマンド。
# ひとつめの値。 (required, type: int)
x: 42
# ふたつめの値。 (required, type: str)
y: abc
# みっつめの値。 (type: bool, default: False)
z: true
yaml への docstring の出力には ruyaml
で追加インストールされるライブラリが必要です。詳細はインストールの項を参照してください。
凝った設定ファイルは作れませんが、ちょっとしたツールならこれで十分と思います。
dataclass の config ファイル
dataclass
も同様に config ファイルから読み込めます。
from dataclasses import dataclass
@dataclass
class Config:
"""ツールの設定。"""
x: int
"""ひとつめの値。"""
y: str
"""ふたつめの値。"""
z: bool = False
"""みっつめの値。"""
def command(a: Config):
print(locals())
if __name__ == "__main__":
import jsonargparse
# これを追加すると dataclass の docstring もヘルプコマンドに使ってくれます。
jsonargparse.set_docstring_parse_options(attribute_docstrings=True)
jsonargparse.CLI()
今回欲しいのは Config
クラスだけなので、コマンドに Config
を指定して生成します。
python -m main Config 42 abc --z yes --print_config > ./config.yml
x: 42
y: abc
z: true
Config
クラスの config ファイルができたら、このファイルを Config
クラスを受け取る引数に指定できます。
python -m main --a ./config.yml
--config
ではなく引数名の --a
指定なところに注目です。つまり、引数ごとに異なる設定の Config
を受け取ったり、同じ設定の Config
を異なるコマンドで使い回したりできます。
なお、Pydantic の BaseModel
でこの操作をする方法は分かりませんでした。ネストされている時(つまり command
関数呼び出し時の config ファイル)は生成できます。
デフォルトの config ファイル
デフォルトの config ファイルを設定できます。
if __name__ == "__main__":
import jsonargparse
# リストで複数渡すと先頭から順次適用されます。
jsonargparse.CLI(default_config_files=["./config.yml", "~/.config/myapp.yml"])
$ python -m main
{'x': 42, 'y': 'abc', 'z': True}
これができるとちょっと本格的なツールっぽくなりますね。
jsonargparse の欠点
どんなツールにも欠点はあります。jsonargparse は無駄のない洗練された仕様ですが、その分できない事も少なくないです。その中でも筆者が特に残念に思った点を挙げます。
なお、本記事では jsonargparse.CLI
のみ紹介しましたが、jsonargparse.ArgumentParser
の方を使えば(つまり自分で実装すれば)何でもできます。ただ、一応公式にも jsonargparse.CLI
が推奨されていますし、ArgumentParser
を使うくらいなら click とか Typer とか他にも選択肢はあるので、ここでは jsonargparse.CLI
を使う前提で書きます。
本記事執筆時の最新版である v4.27.5
での情報です。頻繁に更新されているライブラリなので、最新のステータスは公式ドキュメントで確認してください。
config ファイル機能を無効化できない
前述の config ファイルはすべてのツールで使う機能ではないのですが、これを無効化する手段は用意されていません。config ファイル機能には下記の問題があります。
-
config
またはprint_config
という名前の引数を扱えなくなる(重複エラーになる) -
--print_config
の説明文が長文のため、ヘルプコマンドが見づらくなる - この機能に関してエンドユーザーからの問い合わせが発生する可能性がある
store 系の action を使えない
ArgumentParser
には store_true
などの action がありますが、jsonargparse.CLI
ではこれは使えません。
python -m main --no-option # これはできない。
python -m main --option no # このように key-value 指定が必須。
値指定すれば良いだけではありますが、一般的な CLI では前者の方が圧倒的に多いので、違和感が拭えません。
ちなみに、同じ action でも append に相当するもの はあります。
短縮形・エイリアスが使えない
短縮名には対応していますが、エイリアスや -
1個の短縮形引数は使えません。
python -m main -f # これはできない。
もちろん短縮形を複数まとめる記述方法(ls -al
みたいなの)もできません。CLI にありがちな bool
のオプションを多く持つツールはかなり冗長な書き方になってしまいます。
python -m main -Lfv # これはできない。
python -m main --follow_link=true --force=true --verbose=true # 上と同じ内容。
複数行のヘルプが書けない
description
引数で指定するヘルプと各コマンドの引数のヘルプは、複数行を書いても改行がスペースに置き換わってしまいます。複数の段落に分けている長文や箇条書きなどはかなり読みづらくなりますし、図表や doctest 形式の説明などは文字通りゴミになります。
複雑なコマンドは苦手
Fire ではクラスのインスタンスフィールドを再帰的に探索してくれるので、複雑にネストされたサブコマンドでもクラスだけで直感的かつ整然と実装できます。
class CommandGroup:
def command(self, x: str):
...
class Command:
def __init__(self):
self.group = CommandGroup() # これがコマンドグループを表す。
if __name__ == "__main__":
import fire
fire.Fire(Command)
python -m main group command arg
jsonargparse はクラスのメソッドしか探索しないのでこれはできません。これと同じ階層のサブコマンドを作るには辞書(またはリスト)とクラスを組み合わせる必要があります。
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI({
"group": CommandGroup,
})
更に、例えば下記のように階層の異なるコマンドを構築したい時は、下記のように関数とクラスを使い分けることになります。
python -m main command1 arg
python -m main group command2 arg
def command1(x: str):
...
class CommandGroup:
def command2(self, x: str):
...
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI({
"command1": command1, # サブコマンドは関数で定義する。
"group": CommandGroup, # サブサブコマンドはクラスで定義する。
})
辞書をネストするという方法もありますが、辞書には docstring が書けないので、ネストされた部分に対して説明文を設定できません。
if __name__ == "__main__":
import jsonargparse
jsonargparse.CLI({
"command1": command1,
"group": { # group に対する説明文を付けられない。
"command2": command2,
},
})
ヘルプを捨てるという選択肢を除けば、関数とクラスを使い分けることになるでしょう。
――という説明を受けるとややこしいと思うかもしれませんが、これは jsonargparse のコンセプトが「最小の変更で既存の実装に CLI を付与する」であり、「既存の実装をありのままに CLI にする(実装が CLI を決める)」という矢印が設計思想の基盤だからです。つまり、「関数とクラスを使い分けてサブコマンドを定義する」のではなく、「呼び出したい関数やクラス群を辞書で束ねられる」ということであり、そもそもサブコマンドを定義するための機能ではないのです。そう考えるとこの仕様も腹に落ちるのではないでしょうか。
反対に「まずエンドユーザーのユースケースに合わせて CLI を設計し、それをどうやって既存の実装とすり合わせるか」という開発フローでの利用だと、ちょっと煩わしいこともあるかもしれません。そこは最初から割り切っておいた方が良さそうです。Fire だと上記の コマンドグループ の他 ファンクションチェイン なども充実していてあらゆるニーズを満たせそうな雰囲気があるので、この点に関しては Fire に分があります。
まとめ
Python スクリプトに CLI を追加できるツール jsonargparse を紹介しました。本格的な CLI ツールの開発にも採用できるかは微妙ですが、小物ツールのお手軽 CLI 化には一番のお勧めです。