InvokeをPython環境でタスクランナーにする

JX通信社アドベントカレンダー2日目を担当します、TatchNicolasと申します。
今年(2018年)の8月に入社し、主にサーバサイドの開発を担当しています。

## いきさつ

Pythonの環境分離にはいろいろな方法がありますが、私はJediでの補完が効かせやすく、環境の作り直しも簡単な[Pipenv](https://pipenv.readthedocs.io/en/latest/)(+Pyenv)を使っています。

しかしPipenvの独自コマンド定義機能である`scripts`では、 Node.jsのpackage.jsonのように`&&`によるコマンドの連続実行が定義できません。
https://pipenv.readthedocs.io/en/latest/advanced/#custom-script-shortcuts

list表記を使うなどして複数コマンドの一括実行を実装してほしいという声もあるのですが、なかなか実現しなさそうな様子です。
(GitHub Issue "[How to combine multiple command in one script?](https://github.com/pypa/pipenv/issues/2283)")

ならば古き良きMakefileで！とも考えたのですが、普段書き慣れていない構文を毎度調べる・思い出すのも大変です。
そこで、上記Issueのなかで[Invoke](http://www.pyinvoke.org/)なるものが言及されていましたので、紹介したいと思います。

## Invokeとは
Pythonでいろいろなタスクを定義できるライブラリで、コマンドラインから操作するタスクを手軽に定義できます。


## InvokeとPipenvと組み合わせて何が嬉しいか
- タスクの実行環境自体をPipenvに内包できる 
   - Djangoやgunicornのコマンドをその依存関係とセットでPipenvの環境内に持つことが出来る
- Pythonの豊富なライブラリを使える
- 書き慣れたPythonの構文で様々なタスクを定義できる
    - 文字列操作、ループやIFの条件を気軽に書ける
- コマンドを`pipenv run <task name>`の形式から、`inv <task name>`の形式へと短くできる
    - `pipenv shell`で仮想環境をオンにしている前提です
    - 仮想環境の外からなら、`pipenv run inv <task name>`の形式で呼び出せます。
- Invokeのタスクは別ファイルに定義されるので、Pipfileが散らからない

## やってみる

記事公開時の実行環境

- Ubuntu 18.04
- Python 3.7.0
- Pipenv 2018.7.1

Pipenvの導入、概要については公式ドキュメントをご参照ください。

### invokeのインストール

Pipenvを使って`dev-packages`にインストールします。

```
$ pipenv install --dev invoke
```

### タスクの定義

`tasks.py`というファイルを作成し、その中にタスクを定義していきます。
ターミナル上のコマンドは`invoke.run()`という関数の中に文字列で書きます。

```tasks.py
import invoke


@invoke.task
def hello(c):
    invoke.run('echo "hello invoke!"')
```
### タスクの実行

`invoke foo`で、上記tasks.pyで定義した関数名のタスクを実行できます。
試しにhelloを動かしてみます。

```
# 仮想環境の外から呼ぶ
$ pipenv run invoke 
hello invoke!

# 仮想環境の中から呼ぶ
$ invoke hello
hello invoke!
```

`invoke foo`は`inv foo`と短く書けます。たった三文字なので、`pipenv run foo`よりもちょっと楽ですね。
以降は仮想環境に入っている想定で、また短く`inv`で例を書きます。

### いろいろな使い方

先程の`tasks.py`を編集してみます。

```task.py
import invoke


@invoke.task
def hello(c, name):
    print(f'name is {type(name)}')
    invoke.run(f'echo "hello {name}!"')


@invoke.task
def run_webserver(c, port=8000):
    '''
    http.serverを実行します
    '''
    print(f'port is {type(port)}')
    invoke.run(f'python -m http.server {port}', pty=True)
```

#### コマンド一覧やヘルプの表示

`inv --list`で定義されているタスクの一覧を、`inv --help <タスク名>`で各タスクのヘルプを参照できます。

```
$ inv --list
Available tasks:

  hello
  run-webserver   http.serverを実行します

$ inv --help hello
Usage: inv[oke] [--core-opts] hello [--options] [other tasks here ...]

Docstring:
  none

Options:
  -n STRING, --name=STRING

$ inv hello --name John
name is <class 'str'>
hello John!

$ inv --help run-webserver
Usage: inv[oke] [--core-opts] run-webserver [--options] [other tasks here ...]

Docstring:
  http.serverを実行します

Options:
  -p INT, --port=INT

$ inv run-webserver -p 8888
port is <class 'int'>
Serving HTTP on 0.0.0.0 port 8888 (http://0.0.0.0:8888/) ...
```
#### タスクに引数を渡す
上記の例のとおり、定義したタスクの関数に引数を定義することで、タスクはコマンドライン引数を受け付けるようになります。
また、デフォルト値を指定すると型をよしなに変換してくれます。`hello`のオプション`name`はデフォルト値を指定していないのでヘルプでもSTRINGを想定しており、`run-webserver`のオプション`port`はINTを想定しており、タスク内でもそう扱われていることが分かります。

`invoke.run()`に`pty=True`を指定することでptyが有効になり、たとえば`docker-compose exec <コンテナ名> bash`のようにそのままコンテナ内に入ったりできるようになります。

#### タブ保管用のスクリプトを生成する

Makefileはそのままでもタブ補完が使えますが、Invokeでも`invoke --print-completion-script zsh`を実行した結果を`.zshrc`に入れるなり、別ファイルに書き出して`source`するなりしてタブ補完が効くようになります。

#### タスクの依存関係
あるタスクの前に呼びたいタスクを指定することも出来ます。tasks.pyに追記してみます。

```tasks.py
@invoke.task
def lint(c):
    invoke.run('pylint some_package')


@invoke.task
def test(c):
    invoke.run('coverage -m pytest')


@invoke.task(lint, test)
def package(c):
    invoke.run('python setup.py sdist bdist_wheel')
```

上記の例では、`inv package`を実行すると、その前にlintとtestが走ります。

#### 出力や終了コードを利用する
`invoke.run()`はstdout/stderrの内容、コマンドの終了コードなどを返却するので処理分けになどにも使えます。REPLで様子を見てみましょう。

```
>>> import invoke
>>> result=invoke.run('echo Hello!')
Hello!
>>> result.ok
True
>>> result.stdout
'Hello!\n'
```

## まとめ
タスクが増えてきたら`tasks.py`内で関数を定義して処理を共通化したり、`namespace`を使って`tasks.py`を複数ファイルに分割したりして、プロジェクトに合わせて拡張していくことができます。上手に使って、積極的に楽をしていきましょう。

たとえばAWSのboto3などと組み合わせてデフォルト値や条件を上手く設定すれば、プロジェクトごとに公式のAWS CLIよりも短く使いやすいコマンドラインツールにすることもできるでしょう。