Clickを利用したPython CLIツール開発

2016.06.15

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

こんにちは、藤本です。

先日エントリしたCuratorによるElasticsearchのメンテナンスにてCuratorのソースコード読んでる時に知ったClickというCLIツール作成支援ライブラリをご紹介します。

Pythonのコマンドラインパーサー

PythonはCLIツールによく利用される言語の一つです。例えば、aws-cliやOpenStackClient、先日記事にしたCuratorといったCLIツールもPython実装です。私もCLIツールを作成する時はほぼPythonで書きます。

CLIツールを実装する時にサブコマンド、引数の実装、バリデーション処理、Usageの定義など全て自力で実装しようとするとかなり面倒です。そこでPythonでは標準ライブラリでコマンドラインパーサーを提供しています。2.3系から追加されたoptparseは2.7系から非推奨となり、現在は2.7系から追加されたargparseが広く利用されていると思っています。私もargparseを利用していました。今回ご紹介するClickはサードパーティー製ライブラリではありますが、argparseの機能はほぼ実装されていて、私は機能面よりも書きやすさに惹かれました。

Click

公式ドキュメントの冒頭に記載されているようにClickを利用することで必要最小限のソースコードで使い易いCLIを実装することができます。

Click is a Python package for creating beautiful command line interfaces in a composable way with as little code as necessary. It's the "Command Line Interface Creation Kit". It's highly configurable but comes with sensible defaults out of the box.

argparseとの比較

Python標準モジュールとの違いは何なのでしょうか?

公式ドキュメントに以下に記載がありました。

- argparse has built-in magic behavior to guess if something is an argument or an option. This becomes a problem when dealing with incomplete command lines as it’s not possible to know without having a full understanding of the command line how the parser is going to behave. This goes against Click’s ambitions of dispatching to subparsers.

- argparse currently does not support disabling of interspersed arguments. Without this feature it’s not possible to safely implement Click’s nested parsing nature.

うーん、わかりづらい。。

私が個人的に気に入っているところは、コマンドラインパーサーの定義をDecoratorにより設定するため、設定にプログラムな要素が強いargparseに比べて、プログラムの可読性の高さを保つことができるように感じられました。まぁ、この辺は好みの問題もあると思います。

やってみた

以下のブログがかなり網羅して検証しているので、下記を読んでいただければ、ここで書くことがない気もしますが。。

本エントリではargparseと比較しながら実装方法をご紹介します。

インストール

PyPIに公開されていますので、pipでインストールできます。

# pip install click
Collecting click
Installing collected packages: click
Successfully installed click-6.6

2016/06/09現在公開されているClickのバージョンは6.6です。

オプションを受け取る実装

まずはCLIツールでは間違いなく利用するであろうオプションの実装です。argparseClickといったコマンドラインパーサーを利用することで、定義するオプションの引数を与えることで、型指定、必須属性、利用可能文字などのバリデーションチェック設定、Helpの設定を用意に行うことできます。

argparseの場合

まずはargparseによる実装です。

#!/usr/bin/env python
import argparse

def main():
    parser = argparse.ArgumentParser(description='Simple argparse CLI') # (1)
    parser.add_argument('-n', '--name', dest='name', type=str, help='your name', required=True) # (2)
    parser.add_argument('-l', '--last-name', dest='last_name', type=str, help='your last name', default='Fujimoto', required=False)
    args = parser.parse_args() # (3)
    print("Your name is %s %s" % (args.name, args.last_name))

if __name__ == '__main__':
    main()
  1. ArgumentParserクラスでコマンドラインパーサーを定義します。argparseを利用してコマンドラインパーサーを実装する場合、ここから始まります。

  2. add_argumentメソッドでオプションを追加します。オプションの数だけこのメソッドにより追加します。最初の引数でオプションの指定文字列を指定します。,区切りでAliasで複数指定可能です。destは引数を設定する変数名、typeは受け取る値の型、helpはこのあとご紹介するusageに出力される説明、requiredはオプションが必須かどうか、defaultはオプションを省略した時に設定される値、となります。他の設定項目は公式ドキュメントをご参照ください。直感的に実装を理解できるのは嬉しいですね。

  3. 作成したAugumentParserインスタンスからparse_argsメソッドで引数を取り出すことができます。

usageの表示

コマンドラインパーサーは自動で--helpオプションが実装されます。add_argumentメソッドに指定した引数に応じた表示がされます。実装せずとも見やすい形式でUsageを定義してくれるのは嬉しいです。

# ./argparse_cli.py --help
usage: argparse_cli.py [-h] -n NAME [-l LAST_NAME]

Simple argparse CLI

optional arguments:
  -h, --help            show this help message and exit
  -n NAME, --name NAME  your name
  -l LAST_NAME, --last-name LAST_NAME
                        your last name

バリデーションエラー

必須オプション(required=True)のNAMEを省略すると、以下のようにエラーとなります。

# ./argparse_cli.py
usage: argparse_cli.py [-h] -n NAME [-l LAST_NAME]
argparse_cli.py: error: argument -n/--name is required

正常動作

LAST_NAMEは省略してもdefaultで設定した値が反映されます。

# ./argparse_cli.py -n Shinji
Your name is Shinji Fujimoto

Clickの場合

次にClickによる実装です。
Clickはデコレータによる実装となります。デコレータの引数はargparseと互換性を保たれているので、argparseを利用している方はClickの利用は難しくないことでしょう。

#!/usr/bin/env python
import click

@click.command(help='Simple click CLI') # (1)
@click.option('-n', '--name', 'name', type=str, help='your name', required=True) # (2)
@click.option('-l', '--last-name', 'last_name', type=str, help='your last name', default='Fujimoto', required=False)
def main(name, last_name):
    print("Your name is %s" % name)

if __name__ == '__main__':
    main()
  1. click.commandデコレータをメソッドに設定することで、コマンド指定時にコールするメソッドを決定します。

  2. click.optionデコレータによりオプションを追加します。

usageの表示

ほぼ同じですね。大きな違いはコマンドの説明が[OPTIONS]で束ねられたことでしょうか。argparseだと直感的にコマンドを理解できますが、オプションが多くなった時に長くなって見づらくなるので好みの問題でしょうか。

# ./click_cli.py --help
Usage: click_cli.py [OPTIONS]

  Simple click CLI

Options:
  -n, --name TEXT       your name  [required]
  -l, --last-name TEXT  your last name
  --help                Show this message and exit.

バリデーションエラー

# ./click_cli.py
Usage: click_cli.py [OPTIONS]

Error: Missing option "-n" / "--name".

正常動作

# ./click_cli.py -n Shinji
Your name is Shinji Fujimoto

シンプルな引数を渡すケースであれば、どちらで実装してもよいかな、という印象です。

サブコマンドの実装

次にサブコマンドを実装します。サブコマンドを実装してくるあたりから、argparseは可読性が悪くなるような気がします。今回は2階層のサブコマンドでaws-cliっぽいのを実装します。

argparseの場合

まずはargparseによる実装です。argparseでサブコマンドを定義する場合、add_subparsersメソッドにより子parserのグループを生成し、add_parserメソッドによりサブコマンドを追加します。追加したparserにメソッドを紐付けて処理を記載します。

#!/usr/bin/env python
import argparse
import boto3
import json

def get_args():
    parser = argparse.ArgumentParser(description='Subcommand argparse CLI')
    subparsers = parser.add_subparsers() # (1)

    ec2_parser = subparsers.add_parser('ec2', help='EC2 API') # (2)
    s3_parser = subparsers.add_parser('s3', help='S3 API')
    rds_parser = subparsers.add_parser('rds', help='RDS API')

    ec2_subparsers = ec2_parser.add_subparsers() # (3)

    describe_instances_parser = ec2_subparsers.add_parser('describe_instances', help='EC2 DescribeInstances API')
    describe_instances_parser.add_argument('--instance-id', type=str)
    describe_instances_parser.set_defaults(func=describe_instances) # (4)
    run_instances_parser = ec2_subparsers.add_parser('run_instances', help='EC2 RunInstances API')
    run_instances_parser.set_defaults(func=run_instances)

    return parser.parse_args()

def describe_instances(args): # (4)
    instance_ids = [args.instance_id] if args.instance_id else []
    print(json.dumps(boto3.client('ec2').describe_instances(InstanceIds=instance_ids)))

def run_instances(args):
    :

def main():
    args = get_args()
    args.func(args)

if __name__ == '__main__':
    main()
  1. add_subparsersメソッドにより子parserのグループを作成します。

  2. add_parserメソッドによりサブコマンドを作成します。

  3. サブコマンドをネストできます。

  4. サブコマンドとコールする関数を紐付けます。

usageの表示

各サブコマンド毎に--helpオプションが自動で実装されています。

# ./argparse_awscli.py --help
usage: argparse_awscli.py [-h] {ec2,s3,rds} ...

Subcommand argparse CLI

positional arguments:
  {ec2,s3,rds}
    ec2         EC2 API
    s3          S3 API
    rds         RDS API

optional arguments:
  -h, --help    show this help message and exit

# ./argparse_awscli.py ec2 --help
usage: argparse_awscli.py ec2 [-h] {describe_instances,run_instances} ...

positional arguments:
  {describe_instances,run_instances}
    describe_instances  EC2 DescribeInstances API
    run_instances       EC2 RunInstances API

optional arguments:
  -h, --help            show this help message and exit

サブコマンド実行

# ./argparse_awscli.py ec2 describe_instances
{"Reservations": [], "ResponseMetadata": {"HTTPStatusCode": 200, "RequestId": "15941b27-087e-4840-94b5-61ee1c3564ff"}}

紐付けた関数が正常に実行されています。

Clickの場合

次にコマンドによる

#!/usr/bin/env python
import click
import boto3
import json

@click.group(help='Subcommand click CLI') # (1)
@click.option('-p', '--profile', type=str)
@click.pass_context
def main(ctx, profile):
    ctx.params['session'] = boto3.session.Session(profile_name=ctx.params.get('profile'))

@main.group(help='EC2 API') # (2)
@click.pass_context
def ec2(ctx):
    ctx.params['client'] = ctx.parent.params['session'].client('ec2')

@main.group(help='S3 API')
@click.pass_context
def s3(ctx):
    ctx.params['client'] = ctx.parent.params['session'].client('s3')

@main.group(help='RDS API')
@click.pass_context
def rds(ctx):
    ctx.params['client'] = ctx.parent.params['session'].client('rds')

@ec2.command(help='EC2 DescribeInstances API')
@click.option('--instance-id', type=str, help='specify instance id')
@click.pass_context
def describe_instances(ctx, instance_id):
    instance_ids = [instance_id] if instance_id else []
    print(json.dumps(ctx.parent.params['client'].describe_instances(InstanceIds=instance_ids)))

@ec2.command(help='EC2 RunInstances API')
def run_instances(ctx):
    pass

if __name__ == '__main__':
    main()
  1. click.groupデコレータによりサブコマンドのグループを定義します。argparseと違ったサブコマンドのグループで共通処理を書けます。

  2. サブコマンドをネストできます。

usageの表示

argparse同様、Clickでも各サブコマンド毎に--helpオプションが自動で実装されています。

./click_awscli.py --help
Usage: click_awscli.py [OPTIONS] COMMAND [ARGS]...

  Subcommand click CLI

Options:
  -p, --profile TEXT
  --help              Show this message and exit.

Commands:
  ec2  EC2 API
  rds  RDS API
  s3   S3 API

# ./click_awscli.py ec2 --help
Usage: click_awscli.py ec2 [OPTIONS] COMMAND [ARGS]...

  EC2 API

Options:
  --help  Show this message and exit.

Commands:
  describe_instances  EC2 DescribeInstances API
  run_instances       EC2 RunInstances API

サブコマンド実行

# ./click_awscli.py ec2 describe_instances
{"Reservations": [], "ResponseMetadata": {"HTTPStatusCode": 200, "RequestId": "8c3b5872-5813-4a91-92f0-e8fc44e89db6"}}

上位のサブコマンドグループの関数から順次実行されています。

まとめ

いかがでしたでしょうか?
Pythonで利用可能なコマンドラインパーサーであるargparse、Clickを簡単にご紹介しました。正直、好みの問題もあると思うので、使い易い方を使うのがベストかと。ソースコードを読んでると色々な便利ライブラリを知ることができるので、

参考資料