LoginSignup
6
6

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-12-09

はじめに

オプション解析処理を行うモジュールを調べているうちに 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 は拡張モジュールなのでインストールする必要があります。

$ pip install fire

使用方法

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

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

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

hello_cli.py
from hello import hello
import fire

fire.Fire(hello)

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

$ python hello_cli.py --help

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

NAME
     hello_cli.py

SYNOPSIS
     hello_cli.py <flags>

FLAGS
     --name=NAME
$ python hello_cli.py
Hello World!

$ python hello_cli.py --name Jack
Hello Jack!

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

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

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

$ python -m fire hello

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

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

NAME
     hello

SYNOPSIS
     hello COMMAND

COMMANDS
     COMMAND is one of the following:

      hello
$ python -m fire hello hello --help
NAME
     hello hello

SYNOPSIS
     hello hello <flags>

FLAGS
     --name=NAME
$ 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 は対応できます。

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() を実行するだけです。

mymath_cli.py
from mymath import *
import fire 

fire.Fire()

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

$ python mymath_cli.py --help

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

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が読み取れるすべての関数をサブコマンドとして扱います。
1つの関数だけを対象にしたいのであれば、hello_cli.py と同様にfire.Fire() に対象の関数を引数で与えるだけです。

mymath_cli2.py
from mymath import squre, cube
import fire

fire.Fire(cube)

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

mymath_cli3.py
from mymath import squre, cube
import fire

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

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

$ python mymath_cli2.py --help
NAME
     mymath_cli2.py

SYNOPSIS
     mymath_cli2.py COMMAND

COMMANDS
     COMMAND is one of the following:

      Squire

      Cube

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

クラスを呼び出す

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

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

calculator.py
class Calculator(object):
     """A simple calculator class."""

     def double(self, number):
        return 2 * number

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

calculator_cli.py
from calculator import Calculator
import fire

fire.Fire(Calculator)

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

$ python calculator_cli.py
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メソッドの関数名がサブコマンド名となります。
サブコマンドのヘルプを表示してみましょう。

$ python calculator_cli.py double --help
NAME
     calculator_cli.py double

SYNOPSIS
     calculator_cli.py double NUMBER

POSITIONAL ARGUMENTS
     NUMBER

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

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

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 引数を取ることができますが、これをオプションとしてコマンドラインから与えることができます。

$ 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

サブコマンドのグループ化

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

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)

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

$ python group.py
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

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

$ python group.py digestion --help
NAME
    group.py digestion

SYNOPSIS
    group.py digestion COMMAND

COMMANDS
    COMMAND is one of the following:

     run

     status
$ python group.py ingestion --help

NAME
    group.py ingestion

SYNOPSIS
    group.py ingestion COMMAND

COMMANDS
    COMMAND is one of the following:

     run

実行してみます。

$ 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.
公式ドキュメントについて補足:

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

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

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

airports はアメリカの空港コードを保持するモジュールです。

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)
$ python access_cli.py --help
NAME
    access_cli.py

SYNOPSIS
    access_cli.py --code=CODE

ARGUMENTS
    CODE
$ python access_cli.py --code=JSK
NAME
    access_cli.py --code=JFK

SYNOPSIS
    access_cli.py --code=JFK VALUE

VALUES
    VALUE is one of the following:

     city

     code

     name

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

$ 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() は呼び出した結果に対して、引数で与えられたオブジェクトで実行できるメソッドをすべて実行することができます。

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

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

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

% 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

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

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)
% 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アプリケーションが記述できるわけなのですので、次のように変数にアクセスしても同様にことができます。

greeting.py
english = 'Hello World'
spanish = 'Hola Mundo'

import fire
fire.Fire()

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

$ python greeting.py --help
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

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

$ python greeting.py english
Hello World

$ python greeting.py spanish
Hola Mundo

可変引数をとる関数

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

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)
$ python nargs_cli.py --help
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
$ python nargs_cli.py Beer Wine Sake
Beer Sake Wine

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

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

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

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

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

パラメタの型

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

check_type.py
import fire
fire.Fire(lambda obj: type(obj).__name__)
$ 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

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

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

$ 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

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

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

$ 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型では、その指示の方法には次のように複数の方法があります。

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

$ 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 をトレースしてみましょう。

$ 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

実行した結果です。

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の入力補完スクリプトを生成します。
入力補完スクリプトをホームディレクトリに保存するには、次のようにします。

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

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

$ source $HOME/._chain.py

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

所感

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

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

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

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

これを書いてから 爆速python-fire という記事があることを知ってしまった。orz.

参考

6
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
6