Pythonのdocoptを使ったコマンドライン引数の処理

PythonCLIコマンドを作成する際にオプションの処理を行いたかったので、docoptを使って実装した。

docoptとは

コマンドライン引数として処理したいオプションなどをテキストベースで記述することができる。その内容から、実行時にコマンドライン引数のパーサーの生成と、パースを行う。

docoptはもともとはPythonのライブラリだったようだが、Command-line interface description languageと謳っているだけあって、各言語の実装が展開されている。

使い方

↓こんな感じで使用する。

"""Naval Fate.

Usage:
  naval_fate.py ship new <name>...
  naval_fate.py ship <name> move <x> <y> [--speed=<kn>]
  naval_fate.py ship shoot <x> <y>
  naval_fate.py mine (set|remove) <x> <y> [--moored|--drifting]
  naval_fate.py -h | --help
  naval_fate.py --version

Options:
  -h --help     Show this screen.
  --version     Show version.
  --speed=<kn>  Speed in knots [default: 10].
  --moored      Moored (anchored) mine.
  --drifting    Drifting mine.
"""

from docopt import docopt


if __name__ == '__main__':
    arguments = docopt(__doc__)
    print(arguments)

実行すると以下のようになる。

$ naval_fate.py ship Guardian move 10 50 --speed=20
{'--drifting': False,
 '--help': False,
 '--moored': False,
 '--speed': '20',
 '--version': False,
 '<name>': ['Guardian'],
 '<x>': '10',
 '<y>': '50',
 'mine': False,
 'move': True,
 'new': False,
 'remove': False,
 'set': False,
 'ship': True,
 'shoot': False}

Usageなどの規定のキーワードとフォーマットがあり、それに従ってテキストメッセージを書いておき(docstringに書くサンプルが多い)、それを利用してオプションのパーサーとすることができる。 optparserなどコード内でパースする内容を定義するものと違って、別途ヘルプを書かなくて良いので、この仕様は個人的にはありがたい。

docopt—language for description of command-line interfaces ↑こちらで実際に試すことができる。

コマンド・サブコマンドを処理したい

gitaws-cliのように、サブコマンドを使用したCLIアプリを実装したかったので調べてみた。公式などいくつかのサンプルを参考に最終的には以下のようにした。

myapp/
├── __init__.py
├── __main__.py
├── commands
│   ├── __init__.py
│   ├── cmd1.py
│   └── cmd2.py
(snip...)

myapp/__init__.py

"""Usage:
    myapp <command> <subcommand> [<args>...]

Commands:
    cmd1      Run cmd1
    cmd2      Run cmd2
"""

def main():
    args = docopt(__doc__)

    command_name = args.pop('<command>')

    if command_name == 'help':
        # docstringをそのまま表示できるのが良い
        print(__doc__)
        return

    # search module from <command>
    try:
        command = importlib.import_module('myapp.commands.'+command_name)
    except Exception:
        # import_moduleでExceptionが起きた場合にはコマンドがない扱いにする
        # UnknownCommandErrorは別途定義している
        raise UnknownCommandError(__doc__, command_name)

    # search function from <subcommand>
    subcommand_name = args.pop('<subcommand>')

    # commandとして読み込んだモジュールからsubcommandを取得する
    # subcommandは関数として定義している(後述)
    subcommand = getattr(command, subcommand_name, None)

    if subcommand:
        # subcommandが取得できたら、再度コマンドライン引数のパースを実行する
        # subcommandの内容はテンプレートになっているので、
        # global_optsを渡してformatして使用する
        subcommand_doc = subcommand.__doc__.format(**global_opts)
        if len(subcommand_doc.strip()) > 0:
            args = docopt(subcommand_doc, version=version)
    else:
        subcommand = gen_help(command.__doc__)

    subcommand(global_opts, args)

myapp/commands/cmd1.py

"""Usage:
    {app_name} cmd1 <subcommand> [options] [<args>...]

Options:
    --opt=<opt>       Option

Commands:
    sub1              Sub command 1
    sub2              Sub command 2
"""

def sub1(global_opts, args):
    """Usage:
    {app_name} cmd1 sub1 --opt1=<opt1> [--flag] [<args>...]

Options:
    --opt1=<opt1>     Option
    --flag            flag
"""
    # ↑app_nameはdocoptに渡される前にformatされる
    do_something()
    
def sub2(global_opts, args):
    """Usage:
    {app_name} cmd1 sub2 [<args>...]
"""
    
    do_something()

こんな感じの実装にしてみた。

今のところ自分のユースケースはカバーできているが、もう少しコマンドが増えてくると考慮しないといけないことが出てくるかもしれない。