型ベースで気持ちよくCLIを書けるコマンドラインパーサoppapiを作っています
この記事はPython Advent Calendar 2021 17日目の記事です。
Motivation
筆者は普段RustとPythonでコードを書くことが多いのですが、Rustにはstructopt [1]というとてもとても便利なライブラリがあり、コマンドラインパーサーを型を使って気持ちよく書けます。
structopt
Rustのstructopt
では以下のようにstructにattributeを付けると、structがコマンドラインパーサーとなり、 型とattributeから様々な機能を持ったパーサーを作れます。
#[derive(StructOpt, Debug)]
#[structopt(name = "basic")]
struct Opt {
#[structopt(short, long)]
debug: bool,
#[structopt(short, long, parse(from_os_str))]
output: PathBuf,
#[structopt(short = "c", long)]
nb_cars: Option<i32>,
#[structopt(name = "FILE", parse(from_os_str))]
files: Vec<PathBuf>,
}
click
筆者は以前からclickというpythonのコマンドラインパーサーをよく使っていました。click
を使うと以下のようにデコレータ使って宣言的にパーサーを作れて便利なのですが、解析結果が個別に変数になるのと、mypyやLSPで型を認識してもらうには、デコレータだけでなく変数側にも型宣言が必要だったりで面倒だと思っていました。
@click.command()
@click.option("--count", default=1, help="Number of greetings.")
@click.option("--name", prompt="Your name", help="The person to greet.")
def hello(count, name):
...
また、オプションが多くなると自然とclassにまとめたくなりますが、以下のように別途dataclassを作るのも面倒でした。
@dataclass
class Opt:
count: int
name: str
@click.command()
@click.option("--count", default=1, help="Number of greetings.")
@click.option("--name", prompt="Your name", help="The person to greet.")
def hello(**kwargs):
opt = Opt(**kwargs)
...
oppapi
以上の不満を解消するためにoppapi
(おっぱっぴー)という新しいコマンドラインオプションパーサーを作り始めました。oppapi
はdataclassesとargparseをベースにしており、
-
structopt
のように型を利用して気持ちよくCLIクライアントを書けること - 複雑な設定をしなくてもデフォルトの挙動でやりたいことができる
を目指しています。名前はoptionっぽい和名でテキトーに決めました。
Usage
以下のようにclass定義に@oppapi
デコレータを付けます。
from typing import Optional
from oppapi import from_args, oppapi
@oppapi
class Opt:
"""
Option parser using oppapi
"""
host: str
""" This will be a positional argument of type `str` """
port: Optional[int] = 8000
""" This will be an option argument of type `int` """
opt = from_args(Opt)
print(opt)
from_args(Opt)
を実行すると、Opt
クラスの型宣言に基づいてコマンドラインパーサーが生成されます。生成されたパーサーには以下の特徴があります。
- クラスのdocstringからパーサーのヘルプを生成する
- フィールドのdocstringから引数、オプション引数のヘルプを生成する
- フィールドは引数(Positional Argument)になる
-
Optional
なフィールドはオプション引数(Option Argument)になる
コマンドラインパーサーのヘルプを見てみます。
$ python simple.py -h
usage: simple.py [-h] [-p PORT] host
Option parser using oppapi
positional arguments:
host Primitive type will be positional argument
optional arguments:
-h, --help show this help message and exit
-p PORT, --port PORT Optional type will be option argument
スクリプトに引数を与えてコマンド解析が成功すると、解析結果がOpt
のオブジェクトデシリアライズされます。
$ python simple.py 127.0.0.1 -p 80
Opt(host='127.0.0.1', port=80)
使える型
- Primitives (
int
,float
,str
,bool
) - Containers (
List
,Tuple
) typing.Optional
enum
datetime
decimal
ipaddress
pathlib
uuid
@oppapi
class Opt:
datetime: datetime
date: Optional[date]
time: Optional[time]
opt = from_args(Opt)
print(opt)
例えばdatetime
, date
, time
を使ったOpt
クラスでコマンドライン解析をすると、ちゃんと正しい型で返ってきてくれます。
$ python mod_datetime.py 2021-10-23T11:11:11 -d 2021-10-23 -t 11:11:11
Opt(datetime=datetime.datetime(2021, 10, 23, 11, 11, 11), date=datetime.date(2021, 10, 23), time=datetime.time(11, 11, 11))
コマンドライン引数のshortとlongの設定
short
/long
名の変更
デフォルトではフィールド名からコマンドラインフラグ名が生成されますが、short
, long
フィールドアトリビュートを付ければ、任意のshort
/long
名に変更できます。
from typing import Optional
from oppapi import from_args, oppapi, field
@oppapi
class Opt:
host: Optional[str] = field(short="-n", long="--hostname")
enum
enum.Enum
、enum.IntEnum
を設定すると、自動でchoicesパラメータを設定してくれます。
class Food(Enum):
A = "Apple"
B = "Beer"
C = "Chocolate"
class Price(IntEnum):
A = 10
B = 20
C = 30
@oppapi
class Opt:
food: Food
price: Optional[Price]
usageはこのようになって、
positional arguments:
{Apple,Beer,Chocolate}
optional arguments:
-h, --help show this help message and exit
-p {10,20,30}, --price {10,20,30}
コマンド引数解析するとちゃんとEnum型に戻してくれます。
$ python choice.py Apple --price 20
Opt(food=<Food.A: 'Apple'>, price=<Price.B: 20>)
List
/Tuple
List
は任意長の引数(nargs="+"
)になり、Tuple
は固定長の引数(nargs=NUM
)になります。
@oppapi
class Opt:
values: List[int]
opts: Optional[Tuple[int, str, float, bool]]
$ python nargs.py 1 2 3 --opts 10 foo 10.0 True
Opt(values=[1, 2, 3], opts=(10, 'foo', 10.0, True))
SubCommand
※まだかなり限定的にしか動かないです
typing.Union
を使って他のoppapi
クラスをネストするとsubcommandにすることができます。
from typing import Optional, Union
from oppapi import from_args, oppapi
@oppapi
class Foo:
a: int
@oppapi
class Bar:
b: Optional[int]
@oppapi
class Opt:
cmd: str
sub: Union[Foo, Bar]
コマンドラインに"foo"
を与えるとFoo
になってくれます。
$ python subcommand.py hello foo 10
Opt(cmd='hello', sub=Foo(a=10)) True
今度はコマンドラインに"bar"
を与えるとBar
になってくれます。
$ python subcommand.py hello bar -b 100
Opt(cmd='hello', sub=Bar(b=100)) False
どうやって動いているか
ざっくりこんな流れになります。
dataclass定義からdocstringを取得する
structopt
の欠かせない機能は、structのコメントからコマンドラインのヘルプを生成することでした。dataclasses
で提供されている機能ではコメントを取得するのは不可能なので、新たにokomeというdataclass定義からクラスとフィールドのdocstringを取得するライブラリを作りました。
こんなクラスがあった場合
@dataclasses.dataclass
class Foo:
"""
This is a comment for class `Foo`.
"""
a: int
""" This is valid comment for field that can be parsed by okome """
クラスとdocstringのコメントを取得できます。
c = okome.parse(Foo)
print(f"Class comment: {c.comment}")
for f in c.fields:
print(f'Field "{f.name}" comment: {f.comment}')
$ python simple.py
Class comment: ['This is a comment for class `Foo`.']
Field "a" comment: ['This is valid comment for field that can be parsed by okome']
Field "b" comment: ['Multi line comment', 'also works!']
解析結果をclassのオブジェクトにデシリアライズする
argparse.ArgumentParser
の解析結果はvars
ビルトイン関数を使うとdict
に変換できるので、dictからクラスへのデシリアライズをpyserde
というライブラリを使って行っています。
今後について
oppapi
はまだ生まれたばかりのToyプロダクトですが、頑張って開発は続けていきたいと思います。とりあえずは、以下を実装予定です。
- subcommandの安定化
- flatten
- コマンドのヘルプのcolorize
- その他
argparse
,click
でできる機能の実装
今ならコントリビュートし放題なので、興味ある方は連絡ください。
Discussion