Pythonで簡単CLIツール作成。Python Fireを試してみた

手元で実行するちょっとしたCLIツールを作成したくなること、ありますよね。今回はPythonでCLIツール作成をする際に便利そうなライブラリ、Python Fireを試してみました。
2019.12.23

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは。サービスグループの武田です。

手元で実行するちょっとしたCLIツールを作成したくなること、ありますよね。いくつか選択肢はありますが、シェルスクリプトで作る人。Node.jsで作る人。Goで作る人。そしてPythonで作る人。今回はPythonでCLIツール作成をする際に便利そうなライブラリ、Python Fireを試してみました。

Python Fireとは

Python FireはPythonで定義した関数やメソッドをCLIで簡単に呼び出せるようにするライブラリです。とても多機能なので今回はすべてを紹介することはできませんが、興味を持たれた方はぜひドキュメントなども見てみてください。

google/python-fire: Python Fire is a library for automatically generating command line interfaces (CLIs) from absolutely any Python object.

環境

次のような環境で検証しています。

$ sw_vers
ProductName:	Mac OS X
ProductVersion:	10.14.6
BuildVersion:	18G2022

$ python3 -V
Python 3.7.0

$ pipenv --version
pipenv, version 2018.11.26

やってみた

今回は引数に渡したURLをいい感じにエンコード/デコードするツールを作成してみます。使用イメージは次のようになります。

$ pipenv run python cli.py encode 'https://ja.wikipedia.org/wiki/日本語'
https://ja.wikipedia.org/wiki/%E6%97%A5%E6%9C%AC%E8%AA%9E

$ pipenv run python cli.py encode --username 'takeda.takashi@example.com' --password '++foo//bar==buz??' 'https://example.com/mypage'
https://takeda%2Etakashi%40example%2Ecom:%2B%2Bfoo%2F%2Fbar%3D%3Dbuz%3F%3F@example.com/mypage

$ pipenv run python cli.py decode 'https://ja.wikipedia.org/wiki/%E6%97%A5%E6%9C%AC%E8%AA%9E'
https://ja.wikipedia.org/wiki/日本語

文字コードの指定など作り込むとたいへんになってしまいますので、渡したURLをシンプルにUTF-8でエンコード/デコードします。また追加機能として、ベーシック認証のユーザー名とパスワードを指定できるオプションを追加してみます。

まずは基本的な動作を確認するために、プロジェクトを作成してメッセージを表示するだけの機能を実装してみましょう。pipenvで環境を作成し、fireパッケージをインストールします。

$ cd /path/to/
$ mkdir example-fire && cd $_
$ mkdir .venv
$ pipenv install fire
Creating a virtualenv for this project…
Pipfile: /path/to/example-fire/Pipfile
Using /usr/local/opt/python/bin/python3.7 (3.7.0) to create virtualenv…

✔ Successfully created virtual environment!
Virtualenv location: /path/to/example-fire/.venv
Creating a Pipfile for this project…
Installing fire…
Adding fire to Pipfile's [packages]…
✔ Installation Succeeded
Pipfile.lock not found, creating…
Locking [dev-packages] dependencies…
Locking [packages] dependencies…
✔ Success!
Updated Pipfile.lock (0f41c9)!
Installing dependencies from Pipfile.lock (0f41c9)…
  ?   ▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉ 3/3 — 00:00:00
To activate this project's virtualenv, run pipenv shell.
Alternatively, run a command inside the virtualenv with pipenv run.

それでは機能を実装してみます。「Hello」だけを表示するシンプルなもので、文字列を関数の戻り値として返せばOKです。

cli.py

import fire

def hello():
    return 'Hello'

if __name__ == '__main__':
    fire.Fire(hello)

実行してみます。

$ pipenv run python cli.py
Hello

動きました!一番素朴な実装は関数を定義し、fire.Fire()に渡すだけです。これだけでCLIとしてその関数を実行できます。

今後の拡張を踏まえクラスにしてみます。hello関数をCliクラスのメソッドに変更し、fire.Fireに渡すものもCliクラスオブジェクトに変わっています。

cli.py

import fire

class Cli(object):

    def hello(self):
        return 'Hello'

if __name__ == '__main__':
    fire.Fire(Cli)

クラスを渡した場合は、メソッド名をサブコマンドとして指定する書式になります。実行してみます。

$ pipenv run python cli.py hello
Hello

めっちゃ簡単ですね!

なんとなくわかってきたところで本題のエンコード/デコードの機能を実装してみましょう。

cli.py

import fire
import urllib.parse

class Cli(object):
    
    def encode(self, url):
        u = urllib.parse.urlparse(url)
        
        path = urllib.parse.quote(u.path)
        u = u._replace(path=path)

        query = urllib.parse.urlencode(urllib.parse.parse_qsl(u.query))
        u = u._replace(query=query)

        return u.geturl()

    def decode(self, url):
        return urllib.parse.unquote(url)

if __name__ == '__main__':
    fire.Fire(Cli)

実行してみます。

$ pipenv run python cli.py encode 'https://ja.wikipedia.org/wiki/日本語'
https://ja.wikipedia.org/wiki/%E6%97%A5%E6%9C%AC%E8%AA%9E

$ pipenv run python cli.py decode 'https://ja.wikipedia.org/wiki/%E6%97%A5%E6%9C%AC%E8%AA%9E'
https://ja.wikipedia.org/wiki/日本語

いい感じです!CLIでパラメーターが欲しい場合はメソッドに引数を追加するだけで簡単に受け取れます。urllib.parse.quoteでURLエンコードが可能なのですが、単純に引数を渡してしまうとパラメーターの?=xxxの部分もエスケープされてしまいます。いい感じにするため、一度parseしてからそれぞれの部分をエンコードするようにしました。

それでは最後にベーシック認証のユーザー名とパスワードを指定できるオプションを追加します。オプションも難しいことを考えずに、メソッドに引数を追加するだけです。必須ではないのでデフォルト値を指定し、仮引数名はCLIのオプションで使いたい名前と同じにします。

cli.py

import fire
import urllib.parse

trans = str.maketrans({'.': '%2E'})

class Cli(object):
    
    def encode(self, url, username='', password=''):
        userinfo = None

        if username:
            if password:
                username = urllib.parse.quote(username, safe='').translate(trans)
                password = urllib.parse.quote(password, safe='').translate(trans)
                userinfo = f'{username}:{password}'
            else:
                raise TypeError('Both username and password must be specified.')

        u = urllib.parse.urlparse(url)
        if userinfo:
            u = u._replace(netloc=f'{userinfo}@{u.netloc}')
        
        path = urllib.parse.quote(u.path)
        u = u._replace(path=path)

        query = urllib.parse.urlencode(urllib.parse.parse_qsl(u.query))
        u = u._replace(query=query)

        return u.geturl()

    def decode(self, url):
        return urllib.parse.unquote(url)

if __name__ == '__main__':
    fire.Fire(Cli)

実行してみます。

$ pipenv run python cli.py encode --username 'takeda.takashi@example.com' --password '++foo//bar==buz??' https://example.com/mypage
https://takeda%2Etakashi%40example%2Ecom:%2B%2Bfoo%2F%2Fbar%3D%3Dbuz%3F%3F@example.com/mypage

とってもいい感じですね!urllib.parse.quote_.-~の4文字および/をエスケープしません(/safeのデフォルト引数で指定されている)。nginxとChromeでしか動作確認をしていませんが、少なくとも./の2文字はエスケープしないとベーシック認証が正しく動作しませんでした。そのためsafe引数とtranslateメソッドでエスケープされるようにしています。

まとめ

CLIツール作成のうえで面倒なのは複数モードやオプションのパースなどですが、そこが省力化できるのは素敵ですね。最初のリリース(v0.1.0)は2017年ということで、当時試してみた方もいると思われますが、今年の7月にv0.2.0がリリースされるなど開発は続けられています。使い勝手がどの程度変わっているのかは知りませんが、一度やってみた方もこれからの方もぜひ試してみてください。