LoginSignup
4
2

More than 1 year has passed since last update.

PLUGGABLE なコマンドラインツールを Python & Click モジュールで作る

Last updated at Posted at 2022-06-05

TL;DR

  • コマンドをプラグイン拡張できるようにする方法と、拡張コマンドを pip install でダイナミックに使用できるようにする方法。
    • setup.py に entry_points を書く方法を工夫する。
    • importlib.metadata.entry_points() を経由して、entry_points を実行可能なモジュールオブジェクトとして取得できる。
    • click モジュールのコマンドの後登録機能を使用する。
  • 詳しいポイントや書き方は POINT* がつくヘッダを参照されたし。

はじめに

動機

コマンドライン(以下 CLI)でできることを増やすことは、ユーザーに提供する機能の実装スピードやコストが最短になる。テクニカルなメンバーがいる場合、そういったメンバーに対してコマンドラインツールを提供することは、それらの運用コストを踏まえた上でも理想的な状態となる。

しかしながら、単純に CLI ツールを作る際、どうしても1リソースのツールとなってしまいがちで、汎用的なもの以外の機能を持たせづらい傾向にある。例えば、チームで共通で使う機能を実装していくが、ある特定の機能はプロジェクトAで追加したり、また別のプロジェクトBでは、共通機能の一部をオーバーライドしたりしたいことがある。

また、別の要件として、インストールはシンプルなものが望ましい。わざわざユーザーにコンフィグを書かせるといったことはさせたくないのが理想。

以下に要件をより具体的にまとめてみる。

要件1: コマンドのプラグインでのカスタマイズ性

コマンドの振る舞いとしては例えば以下の様。

チーム全員で使いたい場合、例えば以下のような新規アプリ作成のコマンドを用意したとする。

➜ mycli create --name greeting_app

プロジェクトA では次のようなパッケージングやコンバートを行うサブコマンド群を追加したいとする。

➜ mycli package .
➜ mycli convert -i original.avi -o converted.mp4 

また、プロジェクトB では次の様に作成コマンドをオーバーライドして使いたいとする。
(共通のものが不要で、プロジェクト固有の振る舞いが欲しい場合。)
(以下は、例えば create に app や task などのさらにサブオプションを持たせたいという要求に対応した場合。)

➜ mycli create app --name greeting_app

要件2: プラグインインストールの簡素さ

プラグインのインストールを手身近でシンプルなものにしたい。
Python には pip パッケージマネージメントがあるので、 pip install <パッケージ名> でインストールした際に即座に使えるようになっている事が望ましい。

➜ pip install mycli, mycli-prj-a

これらの要求を満たす CLI ツールを作れると、共通のコマンドと機能は提供しつつ、使うプロジェクトによって柔軟にカスタマイズが可能になる。

こういった pluggable なコマンドラインツールを作ってみる方法をまとめる。

環境

Python 3.9.13
click 8.1.3

サンプルコード

メイン CLI ツールを作る

まずは、ベースとなるメイン CLI ツールを作る。
今回は、メインコマンド、サブコマンドの機構や、オプションの宣言方法から --help ドキュメントの生成でスマートな書き方を提供する click モジュールを活用する。

CLI モジュールを作る

cli-main
    |- setup.py
    |- mycli
        |- __init__.py

CLI スクリプトの作成

今回は小分けにせず、 __init__.py に記述する。

mycli/__init__.py
import click


@click.group()
def main():
    pass


@main.command()
@click.option('-n', '--name', required=True, type=str)
def create(name):
    print("START CREATE: {}".format(name))


def cli():
    main()

setup.py の作成

setup.py
from setuptools import setup

setup(
    name="mycli",
    packages=["mycli"],
    entry_points={"console_scripts": ["mycli = mycli:cli"]}
)

ここでの要点は、

  • ユニークな name を付ける
    • これがかぶってしまうと、そもそもインストールで失敗する or 機能が後にインストールしたものでオーバーライドされてしまう。
  • ユニークな packages 名を設定する。
    • ここでプラグイン側ともかぶってしまうと、インストール先で後にインストールしたものが読み込まれてしまう。
  • entry_points で console_scripts をキーに持たせて設定する。
    • このキーを設定することで、setup が自動的にエントリーポイント用の実行スクリプトの作成を行ってくれる。

ここまでを終えての振る舞い

以上の状態で、pip install を行い、インストールをしてみる。
まずは --help コマンドで概要を見てみる。

➜ mycli --help
Usage: mycli [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  create

さらに、create サブコマンドを実行すると以下の様に帰ってくるコマンドが作れる。

➜ myapp create --name greeting_app
START CREATE: greeting_app

ここまでが、基本的にチーム全体で共有できるツールが作れる手順となる。
以降では、さらにこれをプラグインで拡張していく機構を作っていく。

プラグインとプラグイン機構を作る

要件を満たすツールを作る際に参考になるのが pytest のプラグイン機構だ。
こちら にも書いてある通り、 setuptools.setupentry_points の機構を利用して作ることができる。
entry_points に関する公式ドキュメントはこちら

プラグインを作る

プラグインディレクトリ (A)

cli-plugin-prj-a
    |- setup.py
    |- mycli_prj_a
        |- __init__.py

プラグイン本体を書く (A)

mycli_prj_a/__init__.py
import click
from pathlib import Path


@click.command()
@click.argument('path', default=".")
def package(path):
    path = Path(path)
    print("PACKAGING: {}".format(path.absolute()))

setup.py を書く (A) (POINT 1)

ここが今回のキモとなる一つ目。

setup.py
from setuptools import setup

setup(
    name="mycli-prj-a",
    packages=["mycli_prj_a"],
    entry_points={"mycli": ["mycli_prj_a.package = mycli_prj_a:package",
                            "mycli_prj_a.convert = mycli_prj_a:convert"]}
)

ここでのポイントは以下の点。

  • entry_points のキーに、プラグイン機構で共通のキーを与える。
    • なんでもいいが、ほかのライブラリとの衝突を避けるため、メインと同じパッケージ名などにするのが良い。
  • 値のリストにプラグイン実装したい関数のリストを渡す。
    • mycli_prj_a.packagemycli_prj_a.convert の様に書いておくと、お互いにエントリーポイント名が衝突せずにエラーが起きない。

プラグインを読み取る機構を作る (POINT 2)

そして、ここが今回のキモとなる二つ目。

cli-main の __init__.py ファイルの cli 関数に次の様に書き足す。

cli-main/mycli/__init__.py
# ~省略~

@click.group()
def main():
    pass

# ~省略~

def cli():
    from importlib import metadata
    eps = metadata.entry_points()
    if eps.get("mycli"):
        mycli_eps = eps["mycli"]
        for ep in mycli_eps:
            plugin = ep.load()
            main.add_command(plugin)
    main()

この方法はこちらにて公式の解説がある。
要約すると、 entry_points でキー設定された名前をもとに importlib.metadata.entry_points() を使って、エントリーポイントをグループごとの EntryPoint オブジェクトセットとして取得できるので、それおグループ指定でフィルタし、さらに、EntryPoint.load() メソッドを使用して使用可能なモジュールオブジェクトとして取得する。

試しに、ep の部分を出力すると次の様に得られる。

EntryPoint(name='mycli_prj_a.convert', value='mycli_prj_a:convert', group='mycli')
EntryPoint(name='mycli_prj_a.package', value='mycli_prj_a:package', group='mycli')

そして、この取得したモジュールオブジェクトを click のコマンド後登録機構を用いて、先の main click Group に追加する事で、コマンドの実行時に、main click 関数が実行される前にプラグインのロードを実行できる。

ヘルプコマンドを実行すると次の様に出力される。

➜ mycli --help
Usage: mycli [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  convert
  create
  package

プラグインでサブコマンド機能をオーバーライドする

プラグインディレクトリ (B)

cli-plugin-prj-b
    |- setup.py
    |- mycli_prj_b
        |- __init__.py

setup.py を書く (B)

setup.py
from setuptools import setup

setup(
    name="mycli-prj-b",
    packages=["mycli_prj_b"],
    entry_points={"mycli": ["mycli_prj_b.create = mycli_prj_b:create"]}
)

プラグイン本体を書く(B)

プラグイン本体では、オーバライドしたい部分を click Group として作成し、サブコマンドを登録していく。

mycli_prj_b/__init__.py
import click


@click.group()
def create():
    pass


@create.command()
@click.option("-n", "--name", default="myapp")
def app(name):
    print("CREATE APP: {}".format(name))


@create.command()
@click.option("-n", "--name", default="myapp")
def task(name):
    print("CREATE TASK: {}".format(name))

ここまでを実行してみる

➜ mycli --help
Usage: mycli [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  convert
  create
  package

さらに、create サブコマンドがオーバーライドされたかも確認してみる。

➜ mycli create --help
Usage: mycli create [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  app
  task

試しに実行してみる。

➜ mycli create app --name hello_app
CREATE APP: hello_app

ちゃんとオーバーライドされていることが確認できる。

注意点として、これらは、importlib.metadata.entry_points() の取得順番によって振る舞いが変わるので、プラグイン側でオーバーライドを多用するのには気を付ける必要がある。

最後に

Python の CLI ツール開発では、setuptools.setupentry_points の機構と、importlib.metadatametadata.entry_points() の機構を用いる事でコマンド関数をダイナミックに取得し、さらに click の後登録機能を使う事でダイナミックにコマンドを登録・上書きできることが確認できた。

もちろん、こうしたプラグイン機構には下手に追加・上書きしてしまう事による混乱を生むデメリットもあるが、運用をうまく行う事によってコマンドツールが乱立するのを防いだり、共通のコマンドを文化として根付かせるきっかけにもなると思う。

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2