TL;DR
- コマンドをプラグイン拡張できるようにする方法と、拡張コマンドを
pip install
でダイナミックに使用できるようにする方法。- setup.py に
entry_points
を書く方法を工夫する。 -
importlib.metadata.entry_points()
を経由して、entry_points
を実行可能なモジュールオブジェクトとして取得できる。 - click モジュールのコマンドの後登録機能を使用する。
- setup.py に
- 詳しいポイントや書き方は 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
に記述する。
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 の作成
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.setup
の entry_points
の機構を利用して作ることができる。
entry_points
に関する公式ドキュメントはこちら。
プラグインを作る
プラグインディレクトリ (A)
cli-plugin-prj-a
|- setup.py
|- mycli_prj_a
|- __init__.py
プラグイン本体を書く (A)
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)
ここが今回のキモとなる一つ目。
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.package
やmycli_prj_a.convert
の様に書いておくと、お互いにエントリーポイント名が衝突せずにエラーが起きない。
-
プラグインを読み取る機構を作る (POINT 2)
そして、ここが今回のキモとなる二つ目。
cli-main の __init__.py
ファイルの cli
関数に次の様に書き足す。
# ~省略~
@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)
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 として作成し、サブコマンドを登録していく。
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.setup
の entry_points
の機構と、importlib.metadata
の metadata.entry_points()
の機構を用いる事でコマンド関数をダイナミックに取得し、さらに click の後登録機能を使う事でダイナミックにコマンドを登録・上書きできることが確認できた。
もちろん、こうしたプラグイン機構には下手に追加・上書きしてしまう事による混乱を生むデメリットもあるが、運用をうまく行う事によってコマンドツールが乱立するのを防いだり、共通のコマンドを文化として根付かせるきっかけにもなると思う。