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

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

# はじめに

## 動機

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

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

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

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

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

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

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

```bash
➜ mycli create --name greeting_app
```

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

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

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

```bash
➜ mycli create app --name greeting_app
```

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

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

```bash
➜ pip install mycli, mycli-prj-a
```

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

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

## 環境

> Python 3.9.13
> click  8.1.3

## サンプルコード

https://github.com/takavfx/learn_writing_cli_plugins_with_python

# メイン CLI ツールを作る

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

### CLI モジュールを作る

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

### CLI スクリプトの作成

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

```python: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 の作成

```python: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` コマンドで概要を見てみる。

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

Options:
  --help  Show this message and exit.

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

```bash
➜ myapp create --name greeting_app
START CREATE: greeting_app
```

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

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

要件を満たすツールを作る際に参考になるのが [pytest](https://docs.pytest.org/en/7.1.x/) のプラグイン機構だ。
[こちら](https://docs.pytest.org/en/7.1.x/how-to/writing_plugins.html#pip-installable-plugins) にも書いてある通り、 `setuptools.setup` の `entry_points` の機構を利用して作ることができる。
`entry_points` に関する公式ドキュメントは[こちら](https://setuptools.pypa.io/en/latest/userguide/entry_point.html?highlight=console_scripts#console-scripts)。 

## プラグインを作る

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

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

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

```python: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)

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

```python: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.package` や `mycli_prj_a.convert` の様に書いておくと、お互いにエントリーポイント名が衝突せずにエラーが起きない。


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

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

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

```python: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()
```

この方法は[こちら](https://setuptools.pypa.io/en/latest/userguide/entry_point.html?highlight=console_scripts#advertising-behavior)にて公式の解説がある。
要約すると、 `entry_points` でキー設定された名前をもとに `importlib.metadata.entry_points()` を使って、エントリーポイントをグループごとの `EntryPoint` オブジェクトセットとして取得できるので、それおグループ指定でフィルタし、さらに、`EntryPoint.load()` メソッドを使用して使用可能なモジュールオブジェクトとして取得する。

試しに、`ep` の部分を出力すると次の様に得られる。
```python
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 関数が実行される前にプラグインのロードを実行できる。

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

```bash
➜ 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)

```python: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 として作成し、サブコマンドを登録していく。

```python: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))
```

### ここまでを実行してみる

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

Options:
  --help  Show this message and exit.

Commands:
  convert
  create
  package
```

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

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

Options:
  --help  Show this message and exit.

Commands:
  app
  task
```

試しに実行してみる。

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

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

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

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

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