コマンドライン引数によって、オプションが変わるコマンドの書き方のメモ(2 phase parse?) (updated)

過去にこのような記事を書いた。

今ならどうするだろう?という記事。

これはsubcommandに他ならない

元の記事では以下の様な実行の仕方を許容しているようだ。--fnというオプションを元に使えるコマンドを変えている。

# --fn無し
$ python <cli.py>

# --fn f
$ python <cli.py> --fn f [--name name] [--verbose] [--y Y] [--z Z]

# --fn g
$ python <cli.py> --fn g [--name name] [--verbose] [--z Z] [--i I]

これはこのままの実装ならf,gという2つのサブコマンドがあることに他ならない。

今の自分が持つツールセットでこれをどうするかというと、以下の様な関数を書いてそのままhandofcats越しに実行するだけ。

handofcatsのサブコマンドについてはこの辺を参考にすると良い。

--fn で分岐する代わりにf,gというサブコマンドを実装している。

04subcommand.py

import typing as t
from handofcats import as_subcommand


@as_subcommand
def f(*, name: str, y: str, z: t.Optional[str] = None, verbose: bool = True):
    r = (name, y, z)
    return r, [type(x) for x in r]


@as_subcommand
def g(*, name: str, i: int, z: t.Optional[int] = 100, verbose: bool = True):
    r = (name, i, z)
    return r, [type(x) for x in r]


as_subcommand.run()

実際に試してみる。

$ python 04subcommand.py -h
usage: 04subcommand.py [-h] [--expose] [--inplace] [--untyped]
                       [--logging {CRITICAL,FATAL,ERROR,WARN,WARNING,INFO,DEBUG,NOTSET}]
                       {f,g} ...

optional arguments:
  -h, --help            show this help message and exit
  --expose              dump generated code. with --inplace, eject from handofcats dependency (default: False)
  --inplace             overwrite file (default: False)
  --untyped             untyped expression is dumped (default: False)
  --logging {CRITICAL,FATAL,ERROR,WARN,WARNING,INFO,DEBUG,NOTSET}

subcommands:
  {f,g}
    f
    g


$ python 04subcommand.py f -h
usage: 04subcommand.py f [-h] --name NAME -y Y [-z Z] [--verbose]

optional arguments:
  -h, --help   show this help message and exit
  --name NAME  - (default: None)
  -y Y         - (default: None)
  -z Z         - (default: None)
  --verbose    - (default: True)


$ python 04subcommand.py g -h
usage: 04subcommand.py g [-h] --name NAME -i I [-z Z] [--verbose]

optional arguments:
  -h, --help   show this help message and exit
  --name NAME  - (default: None)
  -i I         - (default: None)
  -z Z         - (default: 100)
  --verbose    - (default: True)

更に手抜き

handofcats 04subcommand.py f などの形で実行するなら、as_subcommandデコレータも不要。(この辺りは先程の記事に書いた。

$ handofcats 14subcommand.py f --name="foo" -y=Y
('foo', 'Y', None) [<class 'str'>, <class 'str'>, <class 'NoneType'>]

14subcommand.py

import typing as t


def f(*, name: str, y: str, z: t.Optional[str] = None, verbose: bool = True):
    r = (name, y, z)
    print(r, [type(x) for x in r])


def g(*, name: str, i: int, z: t.Optional[int] = 100, verbose: bool = True):
    r = (name, i, z)
    print(r, [type(x) for x in r])

これを書いていて気づいたけれど、サブコマンドとして実行した関数の継続(のようなもの)がうまく扱えないかもしれない。例えばもともとの関数f,gは戻り値として結果を返していたが、printするように変更している。

完全に同じインターフェイスで扱いたい場合

元の記事を書いたときのモチベーションを正確には思い出せないが、おそらく何かサブコマンドとは異なる変更を加えたかったのかもしれない。完全に同じインターフェイスで扱いたい場合のことを考えてみる。つまり--fnで分岐する。

同様にparserを2つ作って対応してみる。直接handofcatsの内部の機能を使ってあげると、関数の引数の情報からコマンドラインオプションの設定を行うのに便利。handofcats.injector:Injectorを使えば良い感じにオプションを設定してくれる。

03updated.py

import typing as t
import argparse
from handofcats.injector import Injector


def main(argv: t.Optional[t.List[str]] = None) -> None:
    def _make_parser(*args, **kwargs):
        parser = argparse.ArgumentParser(*args, **kwargs)
        parser.print_usage = parser.print_help
        return parser

    # 1st parser
    parser = _make_parser(add_help=False)
    parser.add_argument("--fn", required=True, choices=[x.__name__ for x in [f, g]])
    args, rest_args = parser.parse_known_args(argv)

    fn = globals()[args.fn]

    # 2nd parser
    parser = _make_parser()
    Injector(fn).inject(parser, help_default=None)
    args = parser.parse_args(rest_args)
    params = vars(args).copy()

    print(fn(**params))



def f(*, name: str, y: str, z: t.Optional[str] = None, verbose: bool = True):
    r = (name, y, z)
    return r, [type(x) for x in r]


def g(*, name: str, i: int, z: t.Optional[int] = 100, verbose: bool = True):
    r = (name, i, z)
    return r, [type(x) for x in r]


if __name__ == "__main__":
    main()

実際に動かしてみる。

# --fn 指定 f
$ python 03updated.py --fn f -h
usage: 03updated.py [-h] --name NAME -y Y [-z Z] [--verbose]

optional arguments:
  -h, --help   show this help message and exit
  --name NAME
  -y Y
  -z Z
  --verbose

# --fn 指定 g
$ python 03updated.py --fn g -h
usage: 03updated.py [-h] --name NAME -i I [-z Z] [--verbose]

optional arguments:
  -h, --help   show this help message and exit
  --name NAME
  -i I
  -z Z
  --verbose

ただし python 03updated.py -h は終了ステータスが2。終了時のアクションをヘルプメッセージの表示にするというhackによってそれっぽい見た目を実現していた。

$ python 03*.py -h
usage: 03updated.py --fn {f,g}

optional arguments:
  --fn {f,g}
03updated.py: error: the following arguments are required: --fn

$ echo $?
2

helpが不親切

以前のコードでは -h は失敗する事によりヘルプメッセージを出していた。これを丁寧にやるならどうするかというと、argparseが設定するオプションそれ自体をaction="store_true"で設定してしまって自分でハンドリングしてあげれば良い。

05updated.py

--- 03updated.py 2020-01-18 15:14:33.000000000 +0900
+++ 05updated.py  2020-01-18 15:24:09.000000000 +0900
@@ -4,20 +4,26 @@
 
 
 def main(argv: t.Optional[t.List[str]] = None) -> None:
-    def _make_parser(*args, **kwargs):
-        parser = argparse.ArgumentParser(*args, **kwargs)
-        parser.print_usage = parser.print_help
-        return parser
+    from argparse import _
 
     # 1st parser
-    parser = _make_parser(add_help=False)
-    parser.add_argument("--fn", required=True, choices=[x.__name__ for x in [f, g]])
+    parser = argparse.ArgumentParser(add_help=False)
+    parser.add_argument(
+        "-h", "--help", action="store_true", help=_("show this help message and exit"),
+    )
+    parser.add_argument("--fn", required=False, choices=[x.__name__ for x in [f, g]])
     args, rest_args = parser.parse_known_args(argv)
 
+    if args.fn is None and args.help:
+        parser.print_help()
+        parser.exit()
+
+    if args.help:
+        rest_args.append("-h")
     fn = globals()[args.fn]
 
     # 2nd parser
-    parser = _make_parser()
+    parser = argparse.ArgumentParser()
     Injector(fn).inject(parser, help_default=None)
     args = parser.parse_args(rest_args)
     params = vars(args).copy()

今度は-hも失敗することなく実行されていく。

$ python 05*.py -h
usage: 05updated.py [-h] [--fn {f,g}]

optional arguments:
  -h, --help  show this help message and exit
  --fn {f,g}
$ echo $?
2

まとめ

昔よりも楽になっている。

追記:

元の記事がサブコマンドと何が違うのかということがはっきりしたので追記しておく。

サブコマンドはすべてのコマンドの候補を網羅して事前に設定している。一方で、元の記事の記述は実行時に対応する条件に従ったもののみのオプション設定が行われている。

別の味方をするとサブコマンドはコマンドをツリーと見なしたときの構造が静的に決まっている。元の記事の記述はその構造を動的に決めることをシミュレートしていた。

gist