のーずいだんぷ

主に自分用メモですが、もしかしたら誰かの役に立つかもしれません

Pythonでサブコマンドを持つCLIを使えるパッケージをつくった

何をしようとしたのか?

タイトルの通り、グローバルコマンドをして使えるPython製のCLIプログラミングをつくろうとしていた。 日本語がおかしいかもしれないので、具体的に説明するとmy-cli <sub-command> <option>みたいなものだ。 例えばgitコマンドでgit add -pのようなものを作りたい。 最終的にはそれを配布できるパッケージにしてインストールすることを目指そうと思う。 path周りでちょっとハマってしまったので、その備忘録も兼ねて記事にする。

CLIを作成する順序

作成に必要な構成は以下2点

  1. デフォルトのPythonパッケージを作成する。(Cookiecutter)
  2. CLI用のスクリプトを作成する。(Click)
  3. Pythonの配布用パッケージの設定する。(setup.py)

Clickはサードパーティのモジュールでデコレータを用いることで非常に簡単にCLIを作成できる。

click.palletsprojects.com

1. デフォルトのPythonパッケージを作成する。(Cookiecutter)

docs.python.org

Python用のパッケージを作成する必要はいくつかあるが、著名なサードパーティライブラリとしてcookiecutterがある。 このライブラリはPythonパッケージを作成に必要なディレクトリ構造をテンプレートとして作成できる。

github.com

cookiecutterについては詳しく述べないが、以下のようにして導入&プロジェクト作成が可能

$ pip install cookiecutter
$ coockiecutter <cookiecutter template>

は基本的はcookiecutterで用意されているテンプレートをがあるのでそれを利用すればよい。 (おそらく自分でテンプレートを作成できるが今の所そこまでの必要性は感じていない。) 例えばざっと見る感じでも以下の種類があるようだ。

coockiecutterテンプレート一覧

  • cookiecutter-pypackage: @audreyr's ultimate Python package project template.
  • cookiecutter-pipproject: Minimal package for pip-installable projects.
  • cookiecutter-pypackage-minimal: A minimal Python package template.
  • cookiecutter-flask : A Flask template with Bootstrap 3, starter templates, and working user registration.
  • cookiecutter-pyvanguard: A template for cutting edge Python development. Invoke, pytest, bumpversion, and Python 2/3 compatibility.
  • Python-iOS-template: A template to create a Python project that will run on iOS devices.
  • Python-Android-template: A template to create a Python project that will run on Android devices.
  • cookiecutter-pytest-plugin: Minimal Cookiecutter template for authoring pytest plugins that help you to write better programs.
  • cookiecutter-tox-plugin: Minimal Cookiecutter template for authoring tox plugins to change or extend the behavior of your test automation.
  • cookiecutter-python-app: A template to create a Python CLI application with subcommands, logging, YAML

上記のテンプレートはcookiecutterデフォルトでは保持していないため、githubリポジトリを~/.coockiecuttergit cloneして使用する必要がある。(詳しくはここのテンプレートをおいているリポジトリを見よう) もちろんローカルのテンプレートリポジトリを使用する場合は上記の様に事前にクローンしておく必要があるが、以下のようにgh:を接頭辞として指定することで直接リモートリポジトリを指定できる。 また今回はgithubをCVSとして指定しているが、bitbucket等いくつか対応しているようにみえる。

今回は基本のcookiecutter-pypackageでこれを使用する。

$ cookiecutter -o my_cli/ gh:audreyr/cookiecutter-pypackage
// ...対話的な設定入力が始まる。

ここで-oをアウトプット先を決定している。 コマンドを実行すると、対話的に設定入力が始まるがその時に以下のようにCLIライブラリの選択画面が現れるので、ここでは1.Clickを選択する。 それ以外はCIツールやテストに関する情報等各自に応じて必要な情報を入力する。

Select command_line_interface:
1 - Click
2 - Argparse
3 - No command-line interface

インストールが完了すると、設定にもよるが大体以下のようなディレクトリ構造が生成される。

my_cli/
├── AUTHORS.rst
├── CONTRIBUTING.rst
├── HISTORY.rst
├── MANIFEST.in
├── Makefile
├── README.rst
├── docs
├── my_cli
│   ├── __init__.py
│   ├── cli.py
│   └── my_cli.py
├── requirements_dev.txt
├── setup.cfg
├── setup.py
├── tests
└── tox.ini

実を言うとここまでで2のCLIモジュールについても大体できてしまっている。 my_cliの中にできて生成されているモジュールを確認してみる。 一つ注意として、この段階ではパッケージ名にハイフン(ダッシュ)を使用しないこと。理由は詳細には述べないが、__init__.pyに余計な処理を書く手間が増える。 後にsetup.pyを編集するときにグローバルコマンド名はmy-cliの様にダッシュをつけることができるので心配は不要だ。

# -*- coding: utf-8 -*-
  
"""Console script for my_cli."""
import sys
import click


@click.command()
def main(args=None):
    """Console script for my_cli."""
    click.echo("Replace this message by putting your code into "
               "my_cli.cli.main")
    click.echo("See click documentation at https://click.palletsprojects.com/")
    return 0


if __name__ == "__main__":
    sys.exit(main())  # pragma: no cover

このスクリプトがCLIを構成する部分となる。

2. CLI用のスクリプトを作成する。(Clickを使用する)

Clickは非常に直感的で基本的に以下の3つのデコレータで今回の要求はクリアできる。

  • @click.command() コマンドを指定するデコレータ。このデコレータを付けられた関数がグローバルコマンドとして実行される。

  • @click.option() オプションを生成するデコレータ。いくつか引数を指定することで

  • @click.group() サブコマンド作成時に使用するデコレータ。このデコレータを使用した関数名、つまり@<groupデコレータの関数名>.command()とすることで複数のコマンドをグルーピングしてサブコマンドとして利用できる。

早速いくつかのサブコマンドとオプションを持つCLIを作ってみよう。

my_cli.py

# -*- coding: utf-8 -*-
  
"""Console script for my_cli."""
import sys
import click


@click.group()
def cli():
        click.echo("Please select sub command. You can see all sub commands 'my-cli --help'")

@cli.command()
@click.option('--name', help='input file name', required=True)
@click.option('--path', help='file path')
def read_file(name ,path):
        click.echo("reading file")
        click.echo("name:{}, path:{}".format(name, path))


@cli.command()
@click.option('--text',default="default value", help='echo text', required=True)
def print_text(text):
        click.echo(text)

上記は以下のような構成だ。

  • read_fileprint_text という2つのサブコマンドを作成した。
  • read_fileコマンドの方はnamepathオプションを保持しており、そのうちnameオプションの方は指定が必須となっている。
  • print_textコマンドの方は、オプションで値を渡さない場合デフォルト値としてprint default valueが引き渡される。

これだけでもいつものようにif __name__=="__main__":でエントリポイントを指定後にスクリプトとして指定して使用することもできるが、Clickモジュールの詳細についてはまた別の記事で紹介する。

次にsetup.pyを編集してエントリポイントの設定をインストールを行う。

3. Pythonの配布用パッケージの設定する。(setup.py)

デフォルトでは以下のよう大体なっているだろう。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""The setup script."""

from setuptools import setup, find_packages

with open('README.rst') as readme_file:
    readme = readme_file.read()

with open('HISTORY.rst') as history_file:
    history = history_file.read()

requirements = ['Click>=7.0', ]

setup_requirements = [ ]

test_requirements = [ ]

setup(
    author="Takuya Kodama",
    author_email='example@test.co.jp',
    python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*',
    classifiers=[
        'Development Status :: 2 - Pre-Alpha',
        'Intended Audience :: Developers',
        'Natural Language :: English',
        "Programming Language :: Python :: 2",
        'Programming Language :: Python :: 2.7',
        'Programming Language :: Python :: 3',
        'Programming Language :: Python :: 3.5',
        'Programming Language :: Python :: 3.6',
        'Programming Language :: Python :: 3.7',
        'Programming Language :: Python :: 3.8',
    ],
    description="test_cli",
    entry_points={
        'console_scripts': [
            'my_cli=my_cli.cli:main',
        ],
    },
    install_requires=requirements,
    long_description=readme + '\n\n' + history,
    include_package_data=True,
    keywords='my_cli',
    name='my_cli',
    packages=find_packages(include=['my_cli', 'my_cli.*']),
    setup_requires=setup_requirements,
    test_suite='tests',
    tests_require=test_requirements,
    url='https://github.com/takuya0412/my_cli',
    version='0.1.0',
    zip_safe=False,
)

今回は最小限の部分だけ説明する。確認が必要な部分は以下の2点。

  1. packages
  2. entry_points
1. packages

この項目では配布(pip installできる)パッケージに含める項目を選択する。 ここで使われているfind_package()では__init__.pyを含むディレクトリを自動で探索するが、上記のようにinclude()で探索するディレクトリを指定することもできる。 最低限含めるべきなのはソースコードを含むディレクトリなので今回の場合はこのままで良い。

2. entry_points

この項目でグローバルコマンド名を決定する。 'my_cli=my_cli.cli:main' これはコマンド名がmy_cli、実行される関数はmy_cli/cli.pymain()が実行されることを示している。 今回はmy-cliをコマンド名としたいので、編集したcli.pyにあわせて次の様に変更する。

    entry_points={
        'console_scripts': [
            'my-cli=my_cli.cli:cli',
        ],
    },

これで準備は完了した。実際にインストールして実行してみよう。 パッケージの形式には色々あるが今回は簡単としてtarで圧縮したパッケージとしてインストールする。

$ python setup.py sdist
$ pip install dist/my_cli-0.1.0.tar.gz 

これで準備が完了したので動作を確認してみよう。

$ my-cli 
Usage: my-cli [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  print-text
  read-file

サブコマンドを選択せずにmy-cli をそのまま実行すると、my-cli --helpを実行した結果が得られる。(--helpを無くす方法もあるが、今回は割愛する。) ちなみにClick7.0.0以降から、サブコマンド名は関数のアンダースコアがハイフンに変更された名前となっていることに注意しよう。(def print_text --> print-text) 結果を見ると、サブコマンドが表示されていることがわかる。それぞれのサブコマンドについても--helpオプションをつけて見てみよう。

$ my-cli print-text --help
Please select sub command. You can see all sub commands 'my-cli --help'
Usage: my-cli print-text [OPTIONS]

Options:
  --text TEXT  echo text  [required]
  --help       Show this message and exit.

$ my-cli read-file --help
Please select sub command. You can see all sub commands 'my-cli --help'
Usage: my-cli read-file [OPTIONS]

Options:
  --name TEXT  input file name  [required]
  --path TEXT  file path
  --help       Show this message and exit.

先程設定したオプションがちゃんと設定されていることがわかる。 helpに記載した内容が説明として記載され、required=Trueとしたものについては[required]がついている。 試しにそれぞれに付いてオプションをつけて実行してみよう。

$ my-cli print-text 
Please select sub command. You can see all sub commands 'my-cli --help'
default value
$ my-cli print-text --text="INPUT TEXT"
Please select sub command. You can see all sub commands 'my-cli --help'
INPUT TEXT

オプション有無でデフォルト値と引数の値が切り替わっている。

$ my-cli read-file --path .
Please select sub command. You can see all sub commands 'my-cli --help'
Usage: my-cli read-file [OPTIONS]
Try "my-cli read-file --help" for help.

Error: Missing option "--name".
$ my-cli read-file --path . --name test
Please select sub command. You can see all sub commands 'my-cli --help'
reading file
name:test, path:.

必須のオプションがない場合はエラーとなり、付けた場合は想定通りの動作となっている。

最後に

今回でサブコマンド持ちのグローバルコマンドの作成がPythonでできた。 Clickとsetuptoolsにはそれぞれ色々機能があり、リリースパッケージも本来は今回の形式ではあまり配布しない。 また検索状況をみて需要があれば書こうと思う。

ほんとに最後に…

今回はcli.pyしか編集しなかったが、当然本来は別のモジュールを作成し、importすることになる。 このときデフォルトのPYTHONPAHTの特徴で以下のように同一パッケージ内のモジュール(上記の例でmy-cli.pyとする)をimportしようとすると失敗(unable import mycli, not found ~)してしまう。

from mycli import *

このときはパッケージ名からinstallする必要があるようだ。(もしかしたら環境によっては解決できない場合もあるかもしれない)

from my_cli.mycli import *

私の場合はこれで解決できた。

参考

以下は古いがClickコマンドについて詳しく記載があるので、凝ったものを作りたい場合は参考にすると良いだろう。

blog.amedama.jp