CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: python-fire の CLI 自動生成を試す

今回は Google が公開した python-fire というパッケージを試してみた。 python-fire では、クラスやモジュールを渡すことで、定義されている関数やメソッドを元に CLI を自動で生成してくれる。

ただし、一つ注意すべきなのは、できあがる CLI はそこまで親切な作りではない、という点だ。 実際にユーザに提供するような CLI を実装するときは、従来通り Click のようなフレームワークを使うことになるだろう。 では python-fire はどういったときに活躍するかというと、これは開発時のテストだと思う。 実装した内容をトライアンドエラーするための CLI という用途であれば python-fire は非常に強力なパッケージだと感じた。

今回使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.12.3
BuildVersion:   16D32
$ python --version
Python 3.6.0

インストール

インストールは Python のパッケージツールの pip を使ってできる。

$ pip install fire

もし pip がインストールされていないときは、あらかじめ入れておこう。

$ curl https://bootstrap.pypa.io/get-pip.py | sudo python

あと、これは完全に蛇足だけどシステムの Python 実行環境にそのまま入れるのはおすすめしない。 色んなパッケージを試すときは、システムからは独立した Python 仮想環境を作って入れた方が良い。 以下は、例えば virtualenv を使ったやり方。

$ sudo pip install virtualenv
$ mkdir -p venv
$ virtualenv venv

これで、最低限のパッケージの入った Python 仮想環境ができる。

(venv) $ pip list --format=columns
Package    Version
---------- -------
appdirs    1.4.3
packaging  16.8
pip        9.0.1
pyparsing  2.2.0
setuptools 34.3.1
six        1.10.0
wheel      0.29.0

python-fire をインストールすると、次のようなパッケージが入る。 意外と依存パッケージが多い。

(venv) $ pip install fire
(venv) $ pip list --format=columns
Package          Version
---------------- -------
appdirs          1.4.2
appnope          0.1.0
decorator        4.0.11
fire             0.1.0
ipython          5.3.0
ipython-genutils 0.1.0
packaging        16.8
pexpect          4.2.1
pickleshare      0.7.4
pip              9.0.1
prompt-toolkit   1.0.13
ptyprocess       0.5.1
Pygments         2.2.0
pyparsing        2.2.0
setuptools       34.3.1
simplegeneric    0.8.1
six              1.10.0
traitlets        4.3.2
wcwidth          0.1.7
wheel            0.29.0

基本的な使い方

まずは python-fire の基本的な使い方から見ていく。 とはいっても、その使い方は至ってシンプル。 例えば CLI を自動生成したいクラスがあるなら、それを fire.Fire() コマンドに渡すだけ。

次のコマンドを実行すると helloworld.py というファイルでサンプルコードが保存される。

$ cat << 'EOF' > helloworld.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-


class MyClass(object):

    def greet(self):
        print('Hello, World!')


if __name__ == '__main__':
    # fire をインポートする
    import fire
    # コマンドラインで呼び出したいクラスを指定する
    fire.Fire(MyClass)
EOF

上記のサンプルコードでは MyClass というクラスを python-fire に渡している。 このクラスには greet() というメソッドがあって、文字列をプリントする内容になっている。

上記を何の引数も渡さずに実行してみよう。

$ python helloworld.py
Type:        MyClass
String form: <__main__.MyClass object at 0x10bb64f28>
File:        ~/Documents/temporary/helloworld.py

Usage:       helloworld.py
             helloworld.py greet

すると Usage が出力されることがわかる。

上記の出力に沿ってオプションを渡してみよう。 具体的には、MyClass の持つメソッド名である greet を指定する。

$ python helloworld.py greet
Hello, World!

これだけで MyClass#greet() の処理が呼び出された。

関数・メソッドに引数を渡す

先ほどの例ではメソッドの呼び出しを試した。 ただ、そのメソッドには引数がなかった。 次は引数のあるメソッドを呼び出すときについて見てみる。

以下のコマンドを実行すると、引数のあるメソッドを定義したサンプルコードができる。

$ cat << 'EOF' > calculate.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-


class MyClass(object):

    def add(self, a, b=1):
        return a + b


if __name__ == '__main__':
    import fire
    fire.Fire(MyClass)
EOF

上記では MyClass#add() メソッドが引数として ab を受け取る。 そのうち b に関してはデフォルト引数が指定されている。

上記で用意したサンプルコードを先ほどと同じようにメソッド名だけ入れて実行してみよう。 この場合、必要な引数 a が入力されていないためトレースが表示される。

$ python calculate.py add
Fire trace:
1. Initial component
2. Instantiated class "MyClass" (calculate.py:5)
3. Accessed property "add" (calculate.py:7)
4. ('The function received no value for the required argument:', 'a')

Type:        method
String form: <bound method MyClass.add of <__main__.MyClass object at 0x109fba518>>
File:        ~/Documents/temporary/calculate.py
Line:        7

Usage:       calculate.py add A [B]
             calculate.py add --a A [--b B]

Usage を見ると、メソッド名である add に続いて引数を渡せば良いことがわかる。

上記にもとづいてメソッド名に続いて引数を渡してみよう。 それぞれ引数の ab に対応する。

$ python calculate.py add 1 2
3

引数の b はデフォルト引数が指定されているので省略できる。

$ python calculate.py add 1
2

引数に渡す内容の指定を順不定にしたいときは -- を使って引数名を指定してやる。

$ python calculate.py add --a=1 --b=2
3

コンストラクタに引数を渡す

先ほどまでの例では CLI を自動生成したいクラスのコンストラクタに引数がなかった。 現実には、何も引数を取らないコンストラクタの方が珍しいと思う。 そこで、次はコンストラクタに引数を取る場合を試してみる。

次のコマンドを実行するとコンストラクタに引数を取るサンプルコードができる。

$ cat << 'EOF' > constructor.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-


class MyClass(object):

    def __init__(self, msg):
        """コンストラクタに引数 msg を取る"""
        self.msg = msg

    def greet(self):
        print(self.msg)


if __name__ == '__main__':
    import fire
    fire.Fire(MyClass)
EOF

先ほどの例と同じように引数を何も指定せず実行してみよう。 するとコンストラクタの引数が指定されていないという内容でトレースが表示される。

$ python constructor.py
Fire trace:
1. Initial component
2. ('The function received no value for the required argument:', 'msg')

Type:        type
String form: <class '__main__.MyClass'>
File:        ~/Documents/temporary/constructor.py
Line:        5

Usage:       constructor.py MSG
             constructor.py --msg MSG

どうやらコンストラクタの引数をまずは指定する必要があるらしい。

上記の表示に沿って、コンストラクタの --msg 引数を指定してみよう。

$ python constructor.py --msg="Hello World"
Type:           MyClass
String form:    <__main__.MyClass object at 0x102b303c8>
File:           ~/Documents/temporary/constructor.py
Init docstring: コンストラクタに引数 msg を取る

Usage:          constructor.py --msg='Hello World' 
                constructor.py --msg='Hello World' greet
                constructor.py --msg='Hello World' msg

すると、今度は実行できるメソッド名が表示されるようになった。 メソッド greet() の下にある msg はインスタンスのメンバとして引数を保存しているため表示されているんだろう。

上記の表示に沿ってコンストラクタの引数を入力しながらメソッドを指定する。

$ python constructor.py --msg='Hello World' greet
Hello World

これでコンストラクタに引数を取るクラスであっても実行できるようになった。

関数から自動生成する

これまでの例ではクラスに対して CLI を自動生成する例だった。 次は関数に対して試してみることにしよう。

次のコマンドを実行すると関数を使うパターンのサンプルコードができる。

$ cat << 'EOF' > function.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-


def greet(msg='Hello, World!'):
    print(msg)


if __name__ == '__main__':
    import fire
    fire.Fire(greet)
EOF

とはいえ、やっていることはこれまでと変わらない。 渡すものがクラスの代わりに関数になっただけ。

今度も、特に何も指定せずまずは実行してみよう。

$ python function.py
Hello, World!

今回に関しては、これだけで事足りてしまった。

ちなみに、上記の場合はデフォルト引数が指定されていたので動いた。 もちろん、次のようにして引数を指定することもできる。

$ python function.py --msg="Hajimemashite Sekai"
Hajimemashite Sekai

ちなみに、ここで面白い挙動に気づいた。 引数を指定するときに内容にカンマが含まれていると、型が自動的にタプルになるようだ。

$ python function.py --msg="Hajimemashite, Sekai"
('Hajimemashite', 'Sekai')

型の自動判別

先ほどの例を元に、どういった内容を引数に指定すると、どの型と判別するのか調べてみた。

次のサンプルコードでは受け取った引数の型を出力する。

$ cat << 'EOF' > argtype.py 
#!/usr/bin/env python
# -*- coding: utf-8 -*-


def print_type(obj):
    print(type(obj))


if __name__ == '__main__':
    import fire
    fire.Fire(print_type)
EOF

色んな内容を渡してみた結果が次の通り。 いきなりタプルになったのは驚いたけど、文字列を指定したいなら '' で囲むのがセオリーなようだ。

$ python argtype.py --obj="foo"
<class 'str'>

$ python argtype.py --obj="1"  
<class 'int'>
$ python argtype.py --obj="1.0"
<class 'float'>
$ python argtype.py --obj="'1'"
<class 'str'>

$ python argtype.py --obj="1,2"
<class 'tuple'>
$ python argtype.py --obj="'1,2'"
<class 'str'>

$ python argtype.py --obj="[1, 2]"
<class 'list'>
$ python argtype.py --obj="{1, 2}"
<class 'set'>
$ python argtype.py --obj="{1: 2}"
<class 'dict'>

モジュールから自動生成する

次は、おそらく一番使う場面が多そうなやり方。 モジュールをそのまま指定して CLI を自動生成してしまうやり方。

次のコマンドを実行するとモジュールをそのまま指定するサンプルコードができる。 この場合は、特に何も指定せず fire.Fire() をインスタンス化すれば良い。

$ cat << 'EOF' > module.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

class MyClass(object):

    def greet(self):
        print('Hello, World!')


def greet(msg='Hello, World!'):
    print(msg)


def add(a, b):
    return a + b


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

モジュールには MyClass クラスや greet() および add() 関数が定義されている。

次も、特に何も指定せずまずは実行してみよう。 ただ、これだけだと何が指定できるのかよく分からない出力になる。

$ python module.py
MyClass: <class '__main__.MyClass'>
greet:   <function greet at 0x10e53ee18>
add:     <function add at 0x10e67d378>
fire:    <module 'fire' from '/Users/amedama/.virtualenvs/fire/lib/python3.6/site-packages/fire/__init__.py'>

そこで --help オプションを指定してみよう。 間にある -- は python-fire 自体に渡す引数と、自動生成した CLI に渡す引数を区別するために使う。

$ python module.py -- --help
Type:        dict
String form: {'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <_frozen_importlib_e <...> ule 'fire' from '/Users/amedama/.virtualenvs/fire/lib/python3.6/site-packages/fire/__init__.py'>}
Length:      13

Usage:       module.py 
             module.py MyClass
             module.py greet
             module.py add
             module.py fire

すると、実行できる内容が出力されるようになった。

関数に関しては、これまでと特に変わらないやり方で実行できる。

$ python module.py greet
Hello, World!
$ python module.py add 1 2
3

インスタンスメソッドについては、クラス名とメソッド名を続けて入力すれば良い。

$ python module.py MyClass greet
Hello, World!

インタラクティブシェルに入る

最初に依存パッケージの中に ipython があったことに気づいていたかもしれない。 ipython は高機能な Python の REPL で python-fire の引数に --interactive を指定すると、それが起動するようになっている。 起動した ipython では特にインポートなどをすることなくモジュールの中で定義されているクラスなどを使うことができる。

$ python helloworld.py -- --interactive
Fire is starting a Python REPL with the following objects:
Modules: fire
Objects: MyClass, component, helloworld.py, result, trace

Python 3.6.0 (default, Feb 25 2017, 20:17:10)
Type "copyright", "credits" or "license" for more information.

IPython 5.3.0 -- An enhanced Interactive Python.
?         -> Introduction and overview of IPython's features.
%quickref -> Quick reference.
help      -> Python's own help system.
object?   -> Details about 'object', use 'object??' for extra details.

In [1]: MyClass
Out[1]: __main__.MyClass

In [2]: MyClass().greet()
Hello, World!

In [3]: exit()

通常通り ipython を指定しただけでは、こうはならない。 インポートしていないオブジェクトを使おうとするとエラーになる。

$ ipython
Python 3.6.0 (default, Feb 25 2017, 20:17:10)
Type "copyright", "credits" or "license" for more information.

IPython 5.3.0 -- An enhanced Interactive Python.
?         -> Introduction and overview of IPython's features.
%quickref -> Quick reference.
help      -> Python's own help system.
object?   -> Details about 'object', use 'object??' for extra details.

In [1]: MyClass
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-1-d1eb688265f8> in <module>()
----> 1 MyClass

NameError: name 'MyClass' is not defined

本来であれば次のようにしてインポートしてやる必要がある。 python-fire 経由で起動すると、この手間をはぶくことができるようだ。

In [2]: from helloworld import MyClass

In [3]: MyClass
Out[3]: helloworld.MyClass

呼び出しをトレースする

これまでの例でもエラーになったときに何度か表示されていたけど、呼び出し内容をトレースすることもできる。 これには python-fire の引数として --trace オプションを指定する。

$ python helloworld.py greet -- --trace
Fire trace:
1. Initial component
2. Instantiated class "MyClass" (helloworld.py:5)
3. Accessed property "greet" (helloworld.py:7)

上記のように各ステップでどういったことが実行されているのかが表示される。

補完スクリプトを出力する

python-fire では bash 用の補完スクリプトを出力することもできる。 それには、次のようにして --completion を python-fire の引数に渡す。

$ python helloworld.py -- --completion
# bash completion support for helloworld.py
# DO NOT EDIT.
# This script is autogenerated by fire/completion.py.

_complete-helloworldpy()
{
  local start cur opts
  COMPREPLY=()
  start="${COMP_WORDS[@]:0:COMP_CWORD}"
  cur="${COMP_WORDS[COMP_CWORD]}"

  opts=""


  if [[ "$start" == "helloworld.py" ]] ; then
    opts="greet"
  fi

  if [[ "$start" == "helloworld.py greet" ]] ; then
    opts="--self"
  fi

  COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
  return 0
}

complete -F _complete-helloworldpy helloworld.py

ここで出力される内容を bash の設定ファイルに入れて読み込んでやるとシェルで補完が効くようになる。

$ python helloworld.py -- --completion >> ~/.bashrc
$ source ~/.bashrc

ただし、出力される補完スクリプトはパスが通っている場所で、かつスクリプトに実行権限がついていることを前提になっている。 そのため、上記のように python コマンド経由で実行する限りでは補完が効かない。 もし使うとしたら、こんな感じにしないとダメそう。

$ chmod +x helloworld.py
$ sudo mv helloworld.py /usr/local/bin/

ちなみに bashcompinit を有効にして試してみたけど zsh では動かなかった。

詳細を出力する

これまで見てきたように --help オプションを指定すると詳しい使い方が出力された。 次のように --verbose オプションを指定すると、特殊メソッドやアトリビュートを含むさらに詳しい内容が出力される。

$ python helloworld.py -- --verbose
Type:        MyClass
String form: <__main__.MyClass object at 0x1055cc4a8>
File:        ~/Documents/temporary/helloworld.py

Usage:       helloworld.py
             helloworld.py __class__
             helloworld.py __delattr__
             helloworld.py __dict__
             helloworld.py __dir__
             helloworld.py __doc__
             helloworld.py __eq__
             helloworld.py __format__
             helloworld.py __ge__
             helloworld.py __getattribute__
             helloworld.py __gt__
             helloworld.py __hash__
             helloworld.py __init__
             helloworld.py __init_subclass__
             helloworld.py __le__
             helloworld.py __lt__
             helloworld.py __module__
             helloworld.py __ne__
             helloworld.py __new__
             helloworld.py __reduce__
             helloworld.py __reduce_ex__
             helloworld.py __repr__
             helloworld.py __setattr__
             helloworld.py __sizeof__
             helloworld.py __str__
             helloworld.py __subclasshook__
             helloworld.py __weakref__
             helloworld.py greet

まとめ

今回は Google の作った CLI を自動生成するパッケージである python-fire を試してみた。 生成される CLI は割りと雑だけど、開発者が使う分には全く問題ないレベルだと思う。 これまでは挙動を軽く試すのにもスクリプトを毎回書き換えたり ipython を起動したりと面倒が多かった。 一つ一つの作業にかかる時間は短くとも、長い目で見れば多くの時間を浪費していることだろう。 そんなとき python-fire を使えば、スクリプトに数行を追加するだけでその手間をはぶくことができるのは大きいと感じた。

いじょう。