🤩

型ベースで気持ちよくCLIを書けるコマンドラインパーサoppapiを作っています

8 min read

この記事は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 (おっぱっぴー)という新しいコマンドラインオプションパーサーを作り始めました。oppapidataclassesargparseをベースにしており、

  • 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)

使える型

@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.Enumenum.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

どうやって動いているか

ざっくりこんな流れになります。

graph TB
    A[dataclass定義からdocstringを取得する] --> B[docstringと型情報からargparse.Parserを作る]
    B --> C[parserを起動しコマンドライン解析をする]
    C --> D[解析結果をclassのオブジェクトにデシリアライズする]

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でできる機能の実装

今ならコントリビュートし放題なので、興味ある方は連絡ください。

脚注
  1. 2020 年版 Command Line Tool を作ってみる in Rust ↩︎

Discussion

ログインするとコメントできます