Python-Fire でCLIを自動生成してみる

# はじめに
オプション解析処理を行うモジュールを調べているうちに [python-fire]( https://github.com/google/python-fire) を知りました。
既存コードをほとんど修正することなくCLIアプリケーションにできるので、
知っておいても無駄にはならないと思い、自習のためとして資料にしました。

この資料は公式ドキュメントの翻訳程度のものですが、
これから使ってみようという方々の助けになれば幸いです。


## Python-Fire について
Python-Fire はPythonオブジェクトからコマンドラインインターフェイス(CLI)を自動的に生成するためのライブラリで、次のような特徴があります。

* Python-Fireは、PythonでCLIアプリケーションを簡単に作成できる。
  - 既存コードもほとんど修正することなくCLIアプリケーションにできる。
* Python-Fireは、Pythonコードを開発/デバッグで便利なツールとなる。
* Python-Fireは、既存のコードを探索するときに便利なツールとなる。
* Python-Fireは、会話形インタフェースREPLを簡単に提供することができる。
  - モジュールと変数などのオブジェクトを読み込まれていてすぐに使かえる。

## Python-Fire のインストール
Python-Fire は拡張モジュールなのでインストールする必要があります。

```Bash
$ pip install fire
```

## 使用方法

今、次のような関数が定義されたファイルがあるとします。

```python:hello.py
def hello(name="World"):
    return "Hello %s!" % name
```

この`hello()`をCLIアプリケーションとしたい場合は、単に `fire.Fire()` を呼び出すだけで、既存コードを修正する必要はありません。

```python:hello_cli.py
from hello import hello
import fire

fire.Fire(hello)
```


ヘルプメッセージを表示するためには、`--help` もしくは `-h` をオプションで与えます。

```bash
$ python hello_cli.py --help
```

fire は組み込み関数 `help()`のようにヘルプメッセージを表示してくれます。

```bash
NAME
     hello_cli.py
 
SYNOPSIS
     hello_cli.py <flags>
 
FLAGS
     --name=NAME
```
 

```bash
$ python hello_cli.py
Hello World!
 
$ python hello_cli.py --name Jack
Hello Jack!
```

複数のオプションが与えられると最後に指定した値が使用されます。

```bash
$ python hello_cli.py --name Jack --name Eddie
Hello Eddie!
```

また、Python 実行時に `-m fire` を与えると、
`hello_cli.py` のような呼び出すためにスクリプトを作成しなくても、既存コードをそのままCLIアプリケーションとして動作させることができます。

```bash
$ python -m fire hello
```

ここで与えている hello は fire が読み込むモジュール名です。つまり、このディレクトリにある hello.py が読み込まれます。

次のようにヘルプメッセージが表示され、関数`hello()` がサブコマンドとして実行受け付けることがわかります。

```bash
NAME
     hello
 
SYNOPSIS
     hello COMMAND
 
COMMANDS
     COMMAND is one of the following:
 
      hello
```
 
```bash
$ python -m fire hello hello --help
```

```bash
NAME
     hello hello
 
SYNOPSIS
     hello hello <flags>
 
FLAGS
     --name=NAME
```

```bash
$ python -m fire hello hello
Hello World!
 
$ python -m fire hello hello Jack
Hello Jack!
 
$ python -m fire hello hello --name David
Hello David!
```

この使い方では、指示するモジュールは`PYTHONPATH`から検索します。
また、docstrings をヘルプメッセージとして表示されます。

```
$ python -m fire json --help
```

```
NAME
    json - JSON (JavaScript Object Notation) <http://json.org> is a subset of JavaScript syntax (ECMA-262 3rd edition) used as a lightweight data interchange format.

SYNOPSIS
    json GROUP | COMMAND

DESCRIPTION
    :mod:`json` exposes an API familiar to users of the standard library
    :mod:`marshal` and :mod:`pickle` modules.  It is derived from a
    version of the externally maintained simplejson library.

    Encoding basic Python object hierarchies::

        >>> import json
        >>> json.dumps(['foo', {'bar': ('baz', None, 1.0, 2)}])
        '["foo", {"bar": ["baz", null, 1.0, 2]}]'
        >>> print(json.dumps("\"foo\bar"))
        "\"foo\bar"
        >>> print(json.dumps('\u1234'))
        "\u1234"
        >>> print(json.dumps('\\'))
        "\\"
```


## 複数の関数があるときはどうするのか？
次のように複数の関数があっても、fire は対応できます。

```bash
def add(x, y):
    return x + y
 
def multiply(x, y):
    return x * y
 
def squre(n):
    return(n**2)
 
def cube(n):
    return(n**3)
```

これまでと同様に `fire.Fire()` を実行するだけです。

```python:mymath_cli.py
from mymath import *
import fire 

fire.Fire()
```

ヘルプメッセージを表示してみましょう。

```bash
$ python mymath_cli.py --help
```

ファイルにある関数を読み込んで複数のサブコマンドとして受け付けることがわかります。

```bash
NAME
     mymath_cli.py
 
SYNOPSIS
     mymath_cli.py GROUP | COMMAND
 
GROUPS
     GROUP is one of the following:
 
      fire
        The Python Fire module.
 
COMMANDS
     COMMAND is one of the following:
 
      add
 
      multiply
 
      squre
 
      cube
```


## 特定の関数だけにCLIを追加したい
デフォルトではfireが読み取れるすべての関数をサブコマンドとして扱います。
１つの関数だけを対象にしたいのであれば、`hello_cli.py` と同様に`fire.Fire()` に対象の関数を引数で与えるだけです。

```python:mymath_cli2.py
from mymath import squre, cube
import fire

fire.Fire(cube)
```

`fire.Fire()` を呼び出すときに対象関数を辞書で指定することもできます。

```python:mymath_cli3.py
from mymath import squre, cube
import fire

fire.Fire({
       'Squire': squre,
       'Cube': cube,
    })
```

ヘルプメッセージを表示してみましょう。

```bash
$ python mymath_cli2.py --help
```

```bash
NAME
     mymath_cli2.py
 
SYNOPSIS
     mymath_cli2.py COMMAND
 
COMMANDS
     COMMAND is one of the following:
 
      Squire
 
      Cube
```

辞書で与えたキーの文字列がサブコマンド名となります。

## クラスを呼び出す

`fire.Fire()` はクラスもCLIアプリケーションにすることができます。

今、次のようなクラスがあるとします。

```python:calculator.py
class Calculator(object):
     """A simple calculator class."""
  
     def double(self, number):
        return 2 * number
```

`fire.Fire()` の引数にCLIにするクラスを与えます。

```python:calculator_cli.py
from calculator import Calculator
import fire

fire.Fire(Calculator)
```

このクラスの `double()` メソッドにはデフォルト値が定義されていません。
そのため。コマンド引数を与えずに実行すると、`--help` オプションが与えられたときと同様にヘルプメッセージを表示します。

```bash
$ python calculator_cli.py
```

```bash
NAME
     calculator_cli.py - A simple calculator class.
 
SYNOPSIS
     calculator_cli.py COMMAND
 
DESCRIPTION
     A simple calculator class.
 
COMMANDS
     COMMAND is one of the following:
 
      double
```

`Calculatror`クラスの`double`メソッドの関数名がサブコマンド名となります。
サブコマンドのヘルプを表示してみましょう。

```bash
$ python calculator_cli.py double --help
```

```bash
NAME
     calculator_cli.py double
 
SYNOPSIS
     calculator_cli.py double NUMBER
 
POSITIONAL ARGUMENTS
     NUMBER
 
NOTES
     You can also use flags syntax for POSITIONAL ARGUMENTS
```
 
```bash
$ python calculator_cli.py double 20
 40
$ python calculator_cli.py double --number 20
 40
```

関数よりもクラスを使用する方が柔軟性が高まります。
次の例を見てみましょう。


```python:calculator_cli2.py
class BrokenCalculator(object):
 
    def __init__(self, offset=1):
         self._offset = offset
 
    def add(self, x, y):
         return x + y + self._offset
 
    def multiply(self, x, y):
         return x * y + self._offset
 
if __name__ == '__main__':
    import fire
    fire.Fire(BrokenCalculator)
```

この`BrokenCalculator` はインスタンス生成時に`offset` 引数を取ることができますが、これをオプションとしてコマンドラインから与えることができます。

```bash
$ python example.py add 10 20
 31
$ python example.py multiply 10 20
 201
$ python example.py add 10 20 --offset=0
 30
$ python example.py multiply 10 20 --offset=0
 200
```

## サブコマンドのグループ化
サブコマンドをグループ化することもできます。

```python:group.py
class IngestionStage(object):

  def run(self):
    return 'Ingesting! Nom nom nom...'

class DigestionStage(object):
  def run(self, volume=1):
    return ' '.join(['Burp!'] * volume)

  def status(self):
    return 'Satiated.'

class Pipeline(object):
  def __init__(self):
    self.ingestion = IngestionStage()
    self.digestion = DigestionStage()

  def run(self):
    value = list()
    result = self.ingestion.run()
    value.append(result)
    result = self.digestion.run()
    value.append(result)
    return value

if __name__ == '__main__':
  import fire
  fire.Fire(Pipeline)
```

コマンドラインで引数を何も与えずに実行するとサブコマンドが選ばれていないため、
ヘルプメッセージが表示されます。

```bash
$ python group.py
```

```bash
NAME
    group.py

SYNOPSIS
    group.py GROUP | COMMAND

GROUPS
    GROUP is one of the following:

     digestion

     ingestion

COMMANDS
    COMMAND is one of the following:

     run
```

サブコマンドのヘルプを見てみましょう。

```bash
$ python group.py digestion --help
```

```bash
NAME
    group.py digestion

SYNOPSIS
    group.py digestion COMMAND

COMMANDS
    COMMAND is one of the following:

     run

     status
```

```bash
$ python group.py ingestion --help
```

```bash

NAME
    group.py ingestion

SYNOPSIS
    group.py ingestion COMMAND

COMMANDS
    COMMAND is one of the following:

     run
```


実行してみます。

```bash
$ python group.py run
Ingesting! Nom nom nom...
Burp!
  
$ python group.py ingestion run
Ingesting! Nom nom nom...

$ python group.py digestion run
Burp!

% python group.py digestion status
Satiated.
```


##### 公式ドキュメントについて補足：
> 公式ドキュメントでは `Pipeline` は `IngestionStage` と `DigestionStage` クラスの `run()` メソッドを呼び出すだけと表記されています。
> しかし、これではその結果を表示することができません。
>  fire は戻り値を表示するため、値をreturn するか、
> 明示的に`print()`する必要があります。



## プロパティーにアクセスする

これまで見てきたサンプルプログラムでは、何かの関数を実行しています。 
次のコードは、単にプロパティにアクセスするだけの例です。

[airports](https://github.com/trendct-data/airports.py) はアメリカの空港コードを保持するモジュールです。

```python:aireport_cli.py
from airports import airports

class Airport(object):
  def __init__(self, code):
    self.code = code
    self.name = dict(airports).get(self.code)
    self.city = self.name.split(',')[0] if self.name else None

if __name__ == '__main__':
  import fire
  fire.Fire(Airport)
```


```bash
$ python access_cli.py --help
```

```bash
NAME
    access_cli.py

SYNOPSIS
    access_cli.py --code=CODE

ARGUMENTS
    CODE
```


```bash
$ python access_cli.py --code=JSK
```

```bash
NAME
    access_cli.py --code=JFK

SYNOPSIS
    access_cli.py --code=JFK VALUE

VALUES
    VALUE is one of the following:

     city

     code

     name
```

ヘルプメッセージにあるように、プロパティーを与えるとその値が帰ってくるようになります。

```bash
$ python access_cli.py --code=JFK city
New York-New Jersey-Long Island

$ python access_cli.py --code=JFK name
New York-New Jersey-Long Island, NY-NJ-PA - John F. Kennedy International (JFK)
```

## オブジェクトのメソッドを呼び出せる
`fire.Fire()` は呼び出した結果に対して、引数で与えられたオブジェクトで実行できるメソッドをすべて実行することができます。

```bash
$ python access_cli.py --code=SFO name upper
SAN FRANCISCO-OAKLAND-FREEMONT, CA - SAN FRANCISCO INTERNATIONAL (SFO)
```

これは airports モジュールの `airports` は文字列をリストで保持しているだけですが、`str`型では `upper`メソッドが使えるからです。

どのようなメソッドがあるかは`--help` をオプションを与えると詳細を知ることができますが、簡単には `help` としても推察することができます。

```bash
% python access_cli.py --code=SFO name help
ERROR: Could not consume arg: help
Usage: access_cli.py --code=SFO name <command>
  available commands:    capitalize | casefold | center | count | encode |
                         endswith | expandtabs | find | format | format_map |
                         index | isalnum | isalpha | isascii | isdecimal |
                         isdigit | isidentifier | islower | isnumeric |
                         isprintable | isspace | istitle | isupper | join |
                         ljust | lower | lstrip | maketrans | partition |
                         replace | rfind | rindex | rjust | rpartition |
                         rsplit | rstrip | split | splitlines | startswith |
                         strip | swapcase | title | translate | upper | zfill

For detailed information on this command, run:
  access_cli.py --code=SFO name --help
```

このため、関数を数珠つなぎで呼ばれるように設定したい場合は、メソッドが自分自身を返すクラスを用意するだけです。

```python:canvas_cli.py
class BinaryCanvas(object):
  """A canvas with which to make binary art, one bit at a time."""

  def __init__(self, size=10):
    self.pixels = [[0] * size for _ in range(size)]
    self._size = size
    self._row = 0  # The row of the cursor.
    self._col = 0  # The column of the cursor.

  def __str__(self):
    return '\n'.join(' '.join(str(pixel) for pixel in row) for row in self.pixels)

  def show(self):
    print(self)
    return self

  def move(self, row, col):
    self._row = row % self._size
    self._col = col % self._size
    return self
  def on(self):
    return self.set(1)

  def off(self):
    return self.set(0)

  def set(self, value):
    self.pixels[self._row][self._col] = value
    return self

if __name__ == '__main__':
  import fire
  fire.Fire(BinaryCanvas)
```

```bash
% python canvas_cli.py move 3 3 on move 3 6 on move 6 3 on move 6 6 on move 7 4 on move 7 5 on
0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0
0 0 0 1 0 0 1 0 0 0
0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0
0 0 0 1 0 0 1 0 0 0
0 0 0 0 1 1 0 0 0 0
0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0
```


この例 `BinaryCanvas` の例では、0/1 の文字で構成されるキャンバスが表示されています。
これは、`__str__`メソッドの定義に従っています。
`__str__`メソッドが最終コンポーネントに存在する場合、オブジェクトはシリアル化されて表示されます。`__str__`メソッドがない場合は、代わりにオブジェクトのヘルプ画面が表示されます。

## 変数にアクセスする
プロパティーにアクセスするだけでその値を返すようなCLIアプリケーションが記述できるわけなのですので、次のように変数にアクセスしても同様にことができます。

```python:greeting.py
english = 'Hello World'
spanish = 'Hola Mundo'

import fire
fire.Fire()
```

ヘルプメッセージを見てみましょう。

```bash
$ python greeting.py --help
```

```bash
NAME
    greeting.py

SYNOPSIS
    greeting.py GROUP | VALUE

GROUPS
    GROUP is one of the following:

     fire
       The Python Fire module.

VALUES
    VALUE is one of the following:

     english

     spanish
```

変数名を引数として受け付けることがわかります。

```bash
$ python greeting.py english
Hello World

$ python greeting.py spanish
Hola Mundo
```


## 可変引数をとる関数
次のような可変引数をとるような関数の場合でも、fire はうまく処理してくれます。

```python:nargs_cli.py
def order_by_length(*items):
  """Orders items by length, breaking ties alphabetically."""
  sorted_items = sorted(items, key=lambda item: (len(str(item)), str(item)))
  return ' '.join(sorted_items)

if __name__ == '__main__':
  import fire
  fire.Fire(order_by_length)
```

```bash
$ python nargs_cli.py --help
```

```bash
NAME
    nargs_cli.py - Orders items by length, breaking ties alphabetically.

SYNOPSIS
    nargs_cli.py [ITEMS]...

DESCRIPTION
    Orders items by length, breaking ties alphabetically.

POSITIONAL ARGUMENTS
    ITEMS
```

```bash
$ python nargs_cli.py Beer Wine Sake
Beer Sake Wine
```


fire に与えたオブジェクトが持つメソッドはすべて呼び出せることは説明しましたが、この場合はコマンド引数とサブコマンド（メソッド）の区別をつけられません。こうしたときは、マイナス記号1つ(`-`) でコマンドライン引数とサブコマンドを区切ります。

```bash
$ python nargs_cli.py Beer Wine Sake - upper
BEER SAKE WINE
```

区切り文字を変えたい場合、fire に `--separator` オプションで与えます。

```bash
$ python nargs_cli.py Beer Wine Sake @ upper -- --separator=@
BEER SAKE WINE
```


この`--separator` のように、fire 自身が解釈するオプションがいくつかあります。コマンドラインでCLIアプリケーションのオプションや引数と区別するために、マイナス記号２つ(`--`) で区切ると、それ以後は fire が解釈するオプションとなります。


## パラメタの型
typer はコマンドラインに与えられた型を推定することができます。


```python:check_type.py
import fire
fire.Fire(lambda obj: type(obj).__name__)
```

```bash
$ python check_type.py 10
int
$ python check_type.py 10.0
float
$ python check_type.py hello
str
$ python check_type.py '(1,2)'
tuple
$ python check_type.py [1,2]
list
$ python check_type.py True
bool
$ python check_type.py {name:David}
dict
```

### パラメタに文字列を与えるときの注意点

コマンドラインをはじめにシェルがパースするために、
シングルクォートとダブルクォートを２重にする必要があります。


```bash
$ python check_type.py 10
int
$ python check_type.py '10'
int
$ python check_type.py "10"
int
$ python check_type.py '"10"'
str
$ python check_type.py "'10'"
str
$ python check_type.py \"10\"
str
```


### パラメタに辞書型のデータを使用するときの注意点

パラメタに辞書を使用する場合はシェルを意識する必要があります。

```bash
$ python check_type.py '{"name": "David Bieber"}'
dict
$ python check_type.py {"name":'"David Bieber"'}
dict
$ python check_type.py {"name":"David Bieber"} 
str
$ python check_type.py {"name": "David Bieber"} 
<error> # 複数のパラメタだとシェルが解釈してしまう
```


### パラメタにbool型のデータを使う

これまでの例で、パラメタにbool型を使うことができることは説明しました。
bool型では、その指示の方法には次のように複数の方法があります。

```bash
$ python check_type.py --obj=True
bool
$ python check_type.py --obj=False
bool
$ python check_type.py --obj
bool
$ python check_type.py --noobj
bool
```


## 会話形モード:REPL

スクリプトを呼び出すとき `--  --interactive` を与えると、
Python のREPL が起動します。
IPython がインストールされていれば、次のようにIPython のRPELが起動します。

Fireは与えられたコンテキストで使用されているすべてのモジュールと変数を含むを、すぐに使用できるようになっているため、デバッグなどで便利です。

```bash
$ python calculator_cli.py -- --interactive
Fire is starting a Python REPL with the following objects:
Modules: fire
Objects: Calculator, calculator.py, component, result, trace

Python 3.8.6 | packaged by conda-forge | (default, Nov 27 2020, 19:17:44)
Type 'copyright', 'credits' or 'license' for more information
IPython 7.19.0 -- An enhanced Interactive Python. Type '?' for help.

In [1]:
```



## プログラムをトレースする

Python プログラムがどうのように挙動しているか把握できるとデバッグが楽になります。こうしたときのために fire には `--trace` オプションがあります。
前述の `chain.py` をトレースしてみましょう。

```bash
$ python chain.py move 3 3 on move 3 6 on move 6 3 on move 6 6 on move 7 4 on move 7 5 on -- --trace
```

実行した結果です。

```bash
Fire trace:
1. Initial component
2. Instantiated class "BinaryCanvas" (chain.py:1)
3. Accessed property "move" (chain.py:17)
4. Called routine "move" (chain.py:17)
5. Accessed property "on" (chain.py:22)
6. Called routine "on" (chain.py:22)
7. Accessed property "move" (chain.py:17)
8. Called routine "move" (chain.py:17)
9. Accessed property "on" (chain.py:22)
10. Called routine "on" (chain.py:22)
11. Accessed property "move" (chain.py:17)
12. Called routine "move" (chain.py:17)
13. Accessed property "on" (chain.py:22)
14. Called routine "on" (chain.py:22)
15. Accessed property "move" (chain.py:17)
16. Called routine "move" (chain.py:17)
17. Accessed property "on" (chain.py:22)
18. Called routine "on" (chain.py:22)
19. Accessed property "move" (chain.py:17)
20. Called routine "move" (chain.py:17)
21. Accessed property "on" (chain.py:22)
22. Called routine "on" (chain.py:22)
23. Accessed property "move" (chain.py:17)
24. Called routine "move" (chain.py:17)
25. Accessed property "on" (chain.py:22)
```


## 入力補完スクリプトの生成

fire に `--completion` を与えると、Bashの入力補完スクリプトを生成します。
入力補完スクリプトをホームディレクトリに保存するには、次のようにします。

```bash
$ python chain.py -- --completion > $HOME/._chain.py
```

このあと、このファイルを読み込んでおきます。

```bash
$ source $HOME/._chain.py
```


ただし、対応しているシェルは Bash と Fish だけです。


## 所感
プログラム開発では、CLIアプリケーションのための実装は本来の目的ではないことが多く、煩雑で面倒なものです。
いずれは typer や click で実装するにしても、まずは、python-fire で簡易的にCLIを実装して、その時間を肝心のコアな部分の開発に集中できることは注目すべきことだと考えています。

既存コードをほぼそのままで、[invoke](http://www.pyinvoke.org/) を連想させるような、タスクランナーとしての利用もできるといった側面もあり興味深いです。

python-fire には、気づいたところでは次のような制限があると考えています。

* サブコマンド間でオプションを共有することは簡単にはできない。
* クラスを与えたときは、そすべてメソッドをCLIのサブコマンドとして扱う。


これを書いてから [爆速python-fire](https://qiita.com/KtheS/items/59d683992ee33de857e9) という記事があることを知ってしまった。orz.



## 参考
* [python-fire ソースコード](https://github.com/google/python-fire)
* [爆速python-fire](https://qiita.com/KtheS/items/59d683992ee33de857e9)