Python でコマンドラインアプリを作って setup.py, argparse, GitHub Actions を理解する

setup.py, argparse, GitHub Actions あたりの技術を学ぶのに、Python でコマンドラインアプリを作ると良さそうだと思ったのでやってみました。ここでは個々の技術を詳しく解説しないので、記事中で紹介する公式ドキュメントや解説記事を参照してください。

## 成果物

[To-Do リストアプリ](https://github.com/pn11/python-command-line-sample) を作りました。と言っても、

```sh
todo add "ほげ"
todo complete "ほげ"
todo remove "ほげ"
```

などで To-Do を追加、完了、削除するだけのものです。また、

```sh
todo complete --all
todo remove -a
```

で全て完了や全て削除ができます。To-Do リストは `~/.todo.json` に保存され、

```sh
$ todo show
□ ほげ
☑ ほげほげ
```

のように表示できます。

## コマンドラインインターフェイスの作成

argparse を使ってコマンドライン引数の処理を定義します。

- [argparse --- コマンドラインオプション、引数、サブコマンドのパーサー — Python 3.8.0 ドキュメント](https://docs.python.org/ja/3/library/argparse.html)
- [ArgumentParserの使い方を簡単にまとめた - Qiita](https://qiita.com/kzkadc/items/e4fc7bc9c003de1eb6d0)

argparse は機能が多過ぎて把握しきれないですが、今回使ったのは以下です。コード全体は[こちら](https://github.com/pn11/python-command-line-sample/blob/master/src/todo/main.py)。

```python
parser = argparse.ArgumentParser()

# git commit とか docker run などの commit や run のようなもの
# をサブコマンドと名付けて追加
parser.add_argument('subcommand', help='add, complete, or remove')

# サブコマンドより後ろに来る引数を content とする。
# nargs は引数の数で、1つの場合は nargs=1
# nagrgs='*' とすると0以上の可変長引数になる
# nargs='+' は1以上の可変長引数
# nargs='?' は0または1個
# https://docs.python.org/ja/3/library/argparse.html#nargs
parser.add_argument('content', help='Content of To-Do', nargs='?')

# --all オプション (短縮形 -a) を追加
# 引数を取るオプションではなく、フラグなので、`store_true=True` を指定
parser.add_argument('-a', '--all', action='store_true', help='remove or complete all To-Dos')
```

なお、サブコマンドが `show` のときや、`remove --all` などのときは引数 'content' は必要ないのですが、指定できてしまいます。指定できないように argparse で書く方法が分からなかったため、引数が指定されても内部で無視するようにしています。

これを用いて、指定された引数に応じた処理を呼び出せば良いです（処理については特筆すべき知見はないので[ソースコード](https://github.com/pn11/python-command-line-sample/blob/master/src/todo/main.py)参照）。

## パッケージ化

`setup.py` を書いてコマンドラインアプリを自動でインストールされるようにします。

- [2. setup スクリプトを書く — Python 3.8.0 ドキュメント](https://docs.python.org/ja/3/distutils/setupscript.html)
- [（インターン向けに書いた）Pythonパッケージを作る方法 - Qiita](https://qiita.com/Kensuke-Mitsuzawa/items/7717f823df5a30c27077)

書いた `setup.py` は[こちら](https://github.com/pn11/python-command-line-sample/blob/master/setup.py)にあります (書き方は [pip](https://github.com/pypa/pip/blob/master/setup.py) や [numpy](https://github.com/numpy/numpy/blob/master/setup.py) のものを参考にしました) が、この中で肝心なのは以下の部分かと思います。

```python
    entry_points={
        "console_scripts": [
            "todo=todo.main:main"
        ]
    },
```

このようにすることで、`python3 setup.py install` したときに OS の PATH にコマンドがインストールされます。私の環境では以下のようになりました[^3]。

```sh
$ which todo
/home/linuxbrew/.linuxbrew/bin/todo
```

```sh
 $ cat $(which todo)
#!/home/linuxbrew/.linuxbrew/opt/python/bin/python3.7
# EASY-INSTALL-ENTRY-SCRIPT: 'todo==0.1.0','console_scripts','todo'
__requires__ = 'todo==0.1.0'
import re
import sys
from pkg_resources import load_entry_point

if __name__ == '__main__':
    sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
    sys.exit(
        load_entry_point('todo==0.1.0', 'console_scripts', 'todo')()
    )
```

これで、どのディレクトリにいようとも、ターミナルで todo と叩くと todo パッケージの main モジュール内の main という関数が呼ばれて、書いた処理が実行されます。

[^3]: Linuxbrew で入れた Python を使用しています。

### pip でインストール

先程は `setup.py` を実行してインストールしましたが、以下のように pip を用いて Git リポジトリからもインストールできます。汎用性のあるものコマンド、パッケージであれば PyPI に登録するのも良いでしょう。

```sh
pip3 install git+https://github.com/pn11/python-command-line-sample
```

## GitHub Actions を用いた自動テスト

いつの間にか[^1] GitHub に追加されていた Actions というのを使ってみたいと思ったのが本記事執筆のきっかけです[^2]。今回は無難に自動テストをしてみます。

[^1]:  [［速報］GitHub Actionsが正式版に。GitHub内でビルド／テスト／デプロイなど実行、CI/CDを実現。GitHub Universe 2019 － Publickey](https://www.publickey1.jp/blog/19/github_actionsgithubcicdgithub_universe_2019.html)
[^2]: MS に買収されてからどんどん新機能が追加されていてしゅごい。。

とりあえず、ToDo リストを全部消してから追加するだけの[クソみたいなテスト](https://github.com/pn11/python-command-line-sample/blob/master/tests/test.py)を書きました。

```python
import todo.main

def test_add():
    todo.main._remove_todo('', all_flag=True)
    todo.main._add_todo('foo')
    dic = todo.main._load_todos()
    assert dic['foo'] == 'Not Yet'
```

これを GitHub Actions を使って nose で実行してみたいと思います。

### Action 用 YAML 作成

リポジトリの Actions のタブに行くと、以下のような画面になるので、とりあえず `Python package` でやってみます。

![919fa3a4-176e-419d-b249-4892ffe4f18d.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63995/a938255b-666d-998f-ab90-2cd9215089f6.jpeg)

以下のような画面になるので、何も考えずとりあえず `Start Commit` を押してみます。

![71a5aa3e-4fbb-4a7b-aaad-32fcee02ad5f.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63995/af7eeb0f-8376-9ddf-32d0-3b96884d408c.jpeg)

テストに失敗します。

![9f8800a4-0658-4dc6-a264-a9aa1a1e1348.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63995/d8e16f45-4cc5-741f-c3a2-623e1a5b0ca7.jpeg)

YAML を見てみると以下のようになっています。

```yaml
name: Python package

on: [push]

jobs:
  build:

    runs-on: ubuntu-latest
    strategy:
      max-parallel: 4
      matrix:
        python-version: [2.7, 3.5, 3.6, 3.7]

    steps:
    - uses: actions/checkout@v1
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v1
      with:
        python-version: ${{ matrix.python-version }}
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
    - name: Lint with flake8
      run: |
        pip install flake8
        # stop the build if there are Python syntax errors or undefined names
        flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
        # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
        flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
    - name: Test with pytest
      run: |
        pip install pytest
        pytest
```

何となく、`steps:` 以下の

```yaml
- name: やること
  run: |
  コマンド1
  コマンド2 
```

みたいなところをいじれば良さそうです。ということで、以下のような処理を書きました。

```yaml
    - name: Install Package
      run: |
        python setup.py install
    - name: Test with nose
      run: |
        pip install nose
        nosetests
```

`setup.py` で To-Do のパッケージをインストールした後、nose もインストールしてテストを実行します。

これをコミットすると無事にテストが通りました。実行ディレクトリとかは特に意識しませんでしたが、リポジトリのルートで実行されているようです。また、何となく nose を使いましたが、特にライブラリに依存したテストではないので元のまま pytest でも動くはず？

![2e3474ac-5bb2-49ed-a87f-c349eaf64855.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63995/99cade73-72a7-8379-37c3-efc3111f6753.jpeg)

今後はコミットする度にテストが走ります。テストに通ると :white_check_mark: マークがついて良い感じです。

![caaef1bb-00d2-4be6-b7b8-eceac4bb906a.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63995/64f964e6-0400-3fba-ea4d-4281710c0d14.jpeg)

## まとめ

以上、argparse でコマンド作成、setup.py でパッケージ化、GitHub Actions で自動テストをやってみました。今後はこの方法でクソコマンドを量産していきたい所存です。

### その他

この記事では取り上げませんでしたが、コマンドラインツール作成の際には argparse 以外にも、click 等を使うのも良いかも知れません。

- [Clickで手軽にPythonのコンソールアプリケーションを作る - Qiita](https://qiita.com/ikuyamada/items/d24363d3dbc46d01a3d9)
- [Awesome Python：素晴らしい Python フレームワーク・ライブラリ・ソフトウェア・リソースの数々 - Qiita](https://qiita.com/hatai/items/34c91d4ee0b54bd7cb8b#%E3%82%B3%E3%83%9E%E3%83%B3%E3%83%89%E3%83%A9%E3%82%A4%E3%83%B3%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E9%96%8B%E7%99%BA)