⌨️

Pythonのお手軽CLI化ツールjsonargparseの紹介

2024/03/11に公開

はじめに

本記事では、Python スクリプトに CLI を追加するツール jsonargparse を紹介します。

jsonargparse は関数やクラスの型ヒントや docstring などを利用して CLI を構築するツールで、同種のツールと比べて、デコレータなどの「CLI のためのコード」をほとんど必要としないという特長があります。これにより、Python の基礎さえ分かっていればスムーズに扱える、学習コストが極めて低いツールとなっています。

このタイプのツールの草分け的存在に Python Fire がありますが、jsonargparse もそれに インスパイアされた ツールです。Fire とは方向性が少し違うので Fire を完全に置き換えるものではありませんが、お手軽 CLI 化ツールとして今一番のお勧めです。

なお、本記事は Fire 愛用者の筆者が jsonargparse に乗り換えるために調べた知見をまとめたものです。このため、Fire を意識しまくった内容となっており、また jsonargparse のすべての機能を網羅するものでもありません。ご了承ください。

インストール

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 を使ったことがある方ならお馴染みのやつですね。

main.py
def hello(name: str):
    print(f"Hello {name}!")


if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI()
command
python -m main world
result
Hello world!

ファイルに1関数しかない時は適切にその関数をコマンドとして認識してくれます。

複数の関数がある時は、各関数がその名前でそれぞれサブコマンドになります。

main.py
def hello(name: str):
    print(f"Hello {name}!")


def goodbye(name: str):
    print(f"Goodbye {name}!")


if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI()
command
python -m main hello world
result
Hello world!

クラス(メソッド)も呼び出すことができます。

main.py
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()
command
python -m main hello world
result
Hello world!

上記例のようにモジュール内に1クラスしかない時は、各メソッドがその名前でサブコマンドになります。複数ある時はクラスがその名前でサブサブコマンドになります。

main.py
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()
command
python -m main Greeting hello world
result
Hello world!

エントリポイントの制御

上記の使い方は便利ですが、モジュール内のすべての関数とクラスが CLI に含まれてしまいます。多くの場合、モジュールにはエンドユーザーに公開する必要がないものもあるので、明示的にエントリポイントを制御することになるでしょう。

特定の関数・クラスだけ公開したい時は、jsonargparse.CLI にそれを渡します。

if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI(hello)
command
python -m main world

複数ある場合はリストで渡します。この場合、関数・クラス名がサブコマンドになります。

if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI([hello, goodbye])
command
python -m main hello world

辞書を使うと、サブコマンド名を変更できます。

if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI({"hi": hello, "bye": goodbye})
command
python -m main hi world

クラスも同様にサブサブコマンド名を変更できます。

if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI({"gr": Greeting})
command
python -m main gr hello world

辞書をネストすればクラスの代わりに関数群でサブサブコマンドを定義したり、任意の深さのサブコマンドを作ったりできますが、ネストした部分には後述のヘルプが使えないので要注意です。

ヘルプコマンド

各関数・メソッドに docstring を書くと、ヘルプコマンドの説明文として使用されます。

main.py
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="挨拶をするツールです。")
command
python -m main --help
result
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 の説明文がちょっと長くて鬱陶しいですが、現状でこれを制御する方法は提供されていないようです。

サブコマンドにもヘルプが付きます。

command
python -m main hello --help
result
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 形式)で指定します。

main.py
def command(x: int, y: int = 0):
    print(locals())


if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI()
command
python -m main 1 --y 2
result
{'x': 1, 'y': 2}

= をセパレータとする形式にも対応しています。

command
python -m main 1 --y=2

なお、少しややこしくなるので、本記事では以降オプション引数(--key value 形式)の事を key-value 引数と呼ぶことにします。

関数側が * を使ってキーワード引数を強制していても、必須引数であればコマンドラインでは位置引数しか使えません。

main.py
def command(x: int, *, y: int):
    print(locals())


if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI()
command
python -m main 1 --y 2  # これはエラーになる。
python -m main 1 2      # こっちが正しい。

対して、型ヒントに Optional が使用されている場合は、デフォルト値が指定されていなくても任意引数扱いになり、key-value 指定が必要です。

main.py
from typing import Optional


def command(x: Optional[int]):
    print(locals())


if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI()
command
python -m main 1      # これはエラーになる。
python -m main --x 1  # こっちが正しい。

また、後述しますが dataclass は必須でも任意でも key-value 指定が必要になります。

少しややこしいですが、ヘルプを見れば一目瞭然なので、特に困ることはないと思います。

main.py
from typing import Optional


def command(x: int, *, y: int, z: Optional[int]):
    print(locals())


if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI()
command
python -m main -h
result
usage: main.py [-h] [--config CONFIG] [--print_config[=flags]] [--z Z] x y
                                                               ^^^^^^^^^^^
                                                                ここを見る

as_positional=False を使うと、すべての引数を key-value 指定にもできます。

main.py
def command(x: int, *, y: int, z: int = 0):
    """テストコマンド。"""
    print(locals())


if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI(as_positional=False)
command
python -m main -h
result
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 と比較して特にいいなと思った点をいくつか挙げます。

型ヒントによるバリデーション

関数引数の型ヒントを利用して、コマンドライン引数のバリデーションができます。

main.py
def command(x: int):
    print(locals())


if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI()
command
python -m main 1.0  # これはエラーになる。
result
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 を指定したのでエラーになっています。ちなみに反対に float1 を指定してもエラーにはなりません(参考)。

Fire だと型ヒントは無視して入力から自動判別されるので、自前でバリデートする必要があります。他にも例えば "20240102_123456" という文字列を int に変換してしまう罠なんかもあり、入力にも気を付けないといけません。その辺が型ヒントを書いておくだけで解決します。

型ヒントによる型の自動変換

型ヒントはエラー処理としてのバリデーションだけでなく、型の自動変換にも利用されます。これにより例えば Path オブジェクトを要求できます。

main.py
from pathlib import Path


def command(x: Path):
    print(locals())


if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI()
command
python -m main temp.txt
result
{'x': PosixPath('temp.txt')}

実際に Path のインスタンスが関数に渡されていることに注目です。

Fire だと str のインスタンスが渡されるので、プログラム内の「ファイルパスを表す引数」を Path に統一できなくて地味に厄介です。CLI はファイルパスを扱うことが多いので、これも型ヒントだけで解決できるのはかなり便利です。

Optional

Optional でも型ヒント通りに変換してくれます。

main.py
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()
command
python -m main --x temp.txt
result
{'x': PosixPath('temp.txt'), 'y': None}

型ヒント通りに、Path または None が渡されているところに注目です。

list, dict

listdict はもちろん、複雑にネストされた型でもいけます。

main.py
from pathlib import Path


def command(x: list[dict[str, list[Path]]]):
    print(locals())


if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI()
command
python -m main '[{"a": ["temp.txt"]}]'
result
{'x': [{'a': [PosixPath('temp.txt')]}]}

ここも Path のインスタンスが渡されていることに注目です。プリミティブ型だけなら evalast.literal_eval でも似たようなことはできますが、ちゃんと型ヒント通りに変換してくれるのがいいですね。

ちなみに、コマンドラインでこのような複雑なオブジェクトを入力することはあまりないのでちゃんと書けるか心配になりますが、「Python コードをそのまま書いてクォートで囲めばよい」とだけ覚えておけば大丈夫です。

Enum

Enum も扱えます。

main.py
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()
command
python -m main command foo
result
{'x': <Mode.foo: 1>}

もちろん定義されていない値を指定するとエラーになります。

command
python -m main command hoge  # これはエラーになる。
result
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 の dataclassBaseModel も扱えます)。

main.py
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()
command
python -m main command --x '{"a": 1, "b": false}'
result
{'x': Config(a=1, b=False)}

command 関数の x 引数は必須引数ですが、dataclass は key-value 指定が必要です。

辞書の代わりにフィールドごとに個別に指定することもできます。

command
python -m main command --x.a 1 --x.b false

手入力の場合はこちらの方が打ちやすいですね。

通常のクラスはメソッドをサブコマンドとして呼び出すことができますが、dataclass はクラス本体を呼び出すこともできます。jsonargparse.CLI は関数やメソッドを呼び出すとその実行結果を返し、dataclass のクラス本体を呼び出すとそのインスタンスを返します。これは後述する config ファイルを使う時に便利な機能ですが、他にも例えば次のように引数の一部だけを CLI にすることにも使えます。

main.py
if __name__ == "__main__":
    import jsonargparse

    model = jsonargparse.CLI(MODEL_CHOICES)
    predict(model, ...)
command
python -m main ModelA --param1 1000

なお、この機能は dataclass でのみ利用できます。通常のクラスはサブコマンド(メソッド呼び出し)が必須なので、引数が足りない旨のエラーになります。

型ヒントによる値の制約

型ヒントを拡張して、型だけでなく値のバリデーションもできます。

jsonargparse にも いくつかのプリセット が定義されていますが、個人的には Pydantic Types の利用がお勧めです。ネット上の情報も Pydantic の方が多いので、拡張したくなった際にも詰まりにくいと思います。

例えば下記は、正の整数を表す PositiveInt を使う例です。

main.py
from pydantic import PositiveInt


def command(x: PositiveInt):
    print(locals())


if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI()
command
python -m main -1  # これはエラーになる。
result
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 なんかも便利ですね。

main.py
from pydantic import FilePath


def command(x: FilePath):
    print(locals())


if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI(command)
command
python -m main not-existent-file  # これはエラーになる。
result
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 のインスタンスになるので、プログラムの動作には影響を及ぼしません。

command
python -m main temp.txt
result
{'x': PosixPath('temp.txt')}

bool のパースが柔軟

Fire では bool フラグは --option/--no-option という値を持たない指定を主としており、値のパースは基本的なものが1パターンあるだけです。コマンドラインで --option=false を指定すると関数には文字列の "false" が渡され、そのまま bool として使うと真になってしまうという罠があります。

jsonargparse では反対に --option/--no-option という指定は今のところサポートしていません。代わりに明示的な指定が柔軟です。

main.py
def command(x: bool = True):
    print(locals())


if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI()
command
python -m main --x false
result
{'x': False}

bool としてパースできる値は以下ですべてです。

  • True になるもの: true, True, TRUE, yes, Yes, YES
  • False になるもの: false, False, FALSE, no, No, NO

もちろん bool 型としてバリデートされるので、上記以外を指定するとエラーになります。

引数の短縮名が使える

jsonargparse は argparse の allow-abbrev に対応しています。

allow-abbrev が有効だと、

曖昧さがない (先頭文字列が一意である) かぎり、先頭文字列に短縮して指定できます

例えば次のような指定ができます。

main.py
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()
command
python -m main --x false --y true
result
{'x_value_with_long_name': False, 'y_value_with_long_name': True}

この機能により、「Python コード内では冗長でも説明的な名前を付けたいけれど、コマンドラインの引数名は短くしたい」というニーズを満たせます。

ただし、ヘルプコマンドには反映されないので、エンドユーザーにとっては隠し機能になってしまうこと、また、アップデートで引数名を大きく変えた時に、エンドユーザーが意図せずして異なる引数を指定してしまう潜在的リスクがあることは注意です。

main_v2.py
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!")
command
python -m main --x false --y true  # アップデート前と同じ引数を指定すると……
result
{'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 ファイルに保存し、後から再利用できる機能があります。

main.py
def command(x: int, y: str, z: bool = False):
    print("Running:", locals())


if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI()

このコードは下記のように実行できます。

command
python -m main 42 abc --z yes
result
Running: {'x': 42, 'y': 'abc', 'z': True}

この呼び出しに --print_config を付けると、関数は実行されず、代わりに実行時引数が yaml 形式で出力されます(下記はファイルにリダイレクトしています)。

command
python -m main 42 abc --z yes --print_config > ./config.yml
config.yml
x: 42
y: abc
z: true

上記で生成した yaml ファイルを --config で指定すると、保存した引数で関数を実行します。

command
python -m main --config ./config.yml
result
Running: {'x': 42, 'y': 'abc', 'z': True}

複数のコマンドがある時にコマンド名を含む config ファイルを生成したい時は、--print_config 引数をコマンドの前に入れます。

command
python -m main --print_config command 42 abc --z yes > ./config.yml
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)
command
$ 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 を指定します。なお、このオプションは = をセパレータとする形式での指定が必須です。

main.py
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()
command
python -m main 42 abc --z true --print_config=comments > ./config.yml
config.yml
# テストコマンド。

# ひとつめの値。 (required, type: int)
x: 42

# ふたつめの値。 (required, type: str)
y: abc

# みっつめの値。 (type: bool, default: False)
z: true

凝った設定ファイルは作れませんが、ちょっとしたツールならこれで十分と思います。

dataclass の config ファイル

dataclass も同様に config ファイルから読み込めます。

main.py
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 を指定して生成します。

command
python -m main Config 42 abc --z yes --print_config > ./config.yml
config.yml
x: 42
y: abc
z: true

Config クラスの config ファイルができたら、このファイルを Config クラスを受け取る引数に指定できます。

command
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"])
command
$ python -m main
{'x': 42, 'y': 'abc', 'z': True}

これができるとちょっと本格的なツールっぽくなりますね。

jsonargparse の欠点

どんなツールにも欠点はあります。jsonargparse は無駄のない洗練された仕様ですが、その分できない事も少なくないです。その中でも筆者が特に残念に思った点を挙げます。

なお、本記事では jsonargparse.CLI のみ紹介しましたが、jsonargparse.ArgumentParser の方を使えば(つまり自分で実装すれば)何でもできます。ただ、一応公式にも jsonargparse.CLI が推奨されていますし、ArgumentParser を使うくらいなら click とか Typer とか他にも選択肢はあるので、ここでは jsonargparse.CLI を使う前提で書きます。

config ファイル機能を無効化できない

前述の config ファイルはすべてのツールで使う機能ではないのですが、これを無効化する手段は用意されていません。config ファイル機能には下記の問題があります。

  1. config または print_config という名前の引数を扱えなくなる(重複エラーになる)
  2. --print_config の説明文が長文のため、ヘルプコマンドが見づらくなる
  3. この機能に関してエンドユーザーからの問い合わせが発生する可能性がある

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 ではクラスのインスタンスフィールドを再帰的に探索してくれるので、複雑にネストされたサブコマンドでもクラスだけで直感的かつ整然と実装できます。

main.py
class CommandGroup:
    def command(self, x: str):
        ...


class Command:
    def __init__(self):
        self.group = CommandGroup()  # これがコマンドグループを表す。


if __name__ == "__main__":
    import fire

    fire.Fire(Command)
command
python -m main group command arg

jsonargparse はクラスのメソッドしか探索しないのでこれはできません。これと同じ階層のサブコマンドを作るには辞書(またはリスト)とクラスを組み合わせる必要があります。

main.py
if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI({
        "group": CommandGroup,
    })

更に、例えば下記のように階層の異なるコマンドを構築したい時は、下記のように関数とクラスを使い分けることになります。

command
python -m main command1 arg
python -m main group command2 arg
main.py
def command1(x: str):
    ...


class CommandGroup:
    def command2(self, x: str):
        ...


if __name__ == "__main__":
    import jsonargparse

    jsonargparse.CLI({
        "command1": command1,   # サブコマンドは関数で定義する。
        "group": CommandGroup,  # サブサブコマンドはクラスで定義する。
    })

辞書をネストするという方法もありますが、辞書には docstring が書けないので、ネストされた部分に対して説明文を設定できません。

main.py
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 化には一番のお勧めです。

来栖川電算

Discussion