ぽよメモ

レガシーシステム考古学専攻

Pythonの新しいCLIアプリケーションフレームワークuroborosを公開した

はじめに

Pythonの全てのCLIアプリケーションフレームワークに習熟してるわけではないです.
自分が必要だと思ったものから優先的に実装しています.こうした方がいいとかあれば,リポジトリのIssueにお願いします.

背景

2019年現在,ソフトウェア系の研究をする人が避けて通れない言語がPythonと言っても過言では無くなってきました(炎上).実験でスクリプトを回すとき,パラメータ変更のために毎回ソースコードを書き換えるなんていう真似をしている人はおそらく居ないと思いますが,皆様どのようにCLIアプリケーションを構築されていますでしょうか.
Pythonの有名なCLIアプリケーションフレームワークはいくつかあります.僕の個人的な感想を併記します.

  • Click
    • ○:関数を簡単にコマンドにできるのは便利
    • ×:デコレータ地獄になっているコードを読み解きたいという気持ちには全くならない
  • Cliff
    • ○:OpenStackのサポートがあって将来的なサポートも期待できそう
    • ×:setuptoolsの力を借りないとサブコマンドを使えなくて嬉しくない
  • Python Fire
    • ○:Googleのサポートがあるので将来的なサポートも期待できそう
    • ×:これ使いやすいですか?
  • Cement
    • ×:本家ドキュメントのGetting startedを読んでこんなに使い方が分からないとは思わなかった
  • Plac
    • ×:もはやPythonを書いていない

正直どれもこれも学習コストが高いか,可読性を犠牲にしており,それなら自分でargparseのラッパーを書いた方がええわ!となりました.世間のPythonistaたちはどうしてるんでしょうか…
そもそもPythonargparseは非常に高機能で,CLIアプリケーション構築に必要な機能はほとんど網羅しているかと思います.インタラクティブシェル的な流行りの機能は難しいですが,そんな要件,普通に存在しますか?僕は幸い出会ったことがありません.というわけで,僕自身の機能に対するニーズはargparseで十分に満たされており,後はどれだけ快適に書けるか,というところだけです.長くなりましたがこういう経緯で新しく実装することにしました.

方針

  • 学習コストをできるだけ低く抑えること
  • argparseの使用感を損なわずにコマンドを構造的に書けるようにすること
  • 標準的な機能を利用する上では追加の依存を必要としないこと
  • Python3.5以上のみをサポートすること
    • もうこの世にPython2を使っているのはgcloudコマンドしか居ないため

気持ちだけGoのCLIアプリフレームワークspf13/cobraを参考にしました.

uroboros

GitHubで公開しています.現在v0.1.0です.

github.com

名前の由来

f:id:pudding_info:20190713220911p:plain
無限や不老不死の象徴として描かれる自分の尻尾を食べる蛇、ウロボロスの輪のイラスト from いらすとや

ヘビっぽい単語で使えそうなのを探していたとき,ouroborosウロボロス)は使われていましたがWikipediaを見るとuroborosでも良さそうだったのでこれにしました.
ちなみにウロボロスは自分の尾を飲み込む蛇で終わり始まりが無いこと,不老不死,循環性,無限性の象徴ですが,このフレームワークにおいてサブコマンドは再帰的に構築され,ループしているとエラーを吐くので全然名前を表していないなと作ってから思いました.

インストール

pipでインストールするだけです.今のところサードパーティーの依存パッケージはありません.

$ pip install uroboros

使い方

覚える必要があるのはargparseの使い方と,uroboros.Commanduroboros.Optionだけです.しかも大抵の要件ではuroboros.Commandだけでも十分でしょう.uroborosではuroboros.Commandを継承して作成したコマンドクラスをノードとするN分木を作るようなイメージでCLIツールを構築していきます.

# main.py
from uroboros import Command
from uroboros import ExitStatus

class RootCommand(Command):
    """アプリケーションのルートコマンド"""
    # アプリケーションのルートコマンド.
    name = 'sample'
    # アプリケーションの説明
    long_description = 'This is a sample command using uroboros'

    version = "v0.0.0"

    def build_option(self, parser):
        """
        引数の追加.
        argparse.ArgumentParserのインスタンスが引数として渡される.
        """
        parser.add_argument('--version',
                            action='store_true',
                            default=False,
                            help='Print version')
        # 追加した後にparserを返す
        return parser

    def run(self, args):
        """
        このコマンドが指定されたときに実行する内容.
        argsはargparse.Namespaceオブジェクトで引数をパースした結果.
        """
        if args.version:
            print("{name} v{version}".format(
                name=self.name, version=self.version))
        else:
            # このコマンドのヘルプテキストを出力するヘルパー関数
            self.print_help()
        # 返り値はExit Statusになる.
        # `uroboros.ExitStatus`を使っても良いし,単にintでも良い.
        return ExitStatus.SUCCESS


root = RootCommand()

if __name__ == "__main__":
    # 実行時は単にそのコマンドのexecuteを呼ぶだけ
    exit_code = root.execute()
    exit(exit_code)

これが基本的なコマンド一つの実装になります.ポイントは

  • argparse.ArgumentParserインスタンスが渡されるので自由にオプションを追加する
  • 実行時には引数がargparseによってパースされ,argparse.Namespaceインスタンスが渡されるので,自由にオプションを読み取って実行する
  • 返り値はそのまま終了ステータスになるので,intまたはuroboros.ExitStatus*1を返す

です.試しにヘルプを表示するとこうなります.

$ python main.py -h
usage: sample [-h] [--version]

This is a sample command using uroboros

optional arguments:
  -h, --help  show this help message and exit
  --version   Print version
# ちゃんとバージョン表示できる
$ python main.py --version
sample v0.0.0

ではサブコマンドを追加してみます.

# main.py
from uroboros import Command
from uroboros import ExitStatus

class RootCommand(Command):
    ...

class HelloCommand(Command):
    # サブコマンド名
    name = 'hello'
    # サブコマンド一覧を表示したときの短いメッセージ
    short_description = 'Hello world!'
    long_description = 'Print "Hello world!" to stdout'

    def run(self, args):
        print(self.short_description)
        return ExitStatus.SUCCESS

root = RootCommand()
root.add_command(HelloCommand())

...

これでhelloサブコマンドが使えるようになります.

# short descriptionはここで表示される
$ python main.py -h 
usage: sample [-h] [--version] {hello} ...

This is a sample command using uroboros

optional arguments:
  -h, --help  show this help message and exit
  --version   Print version

Sub commands:
  {hello}
    hello     Hello world!
# long descriptionはこっちで表示される
$ python main.py hello -h
usage: sample hello [-h]

Print "Hello world!" to stdout

optional arguments:
  -h, --help  show this help message and exit
# Hello worldしてみる
$ python main.py hello
Hello world!

更に複数のモジュールから複雑なコマンドを生成するサンプルはこちらにあります. これをargparseで実装する場合,(色々と省略すると)以下の様になります.

import argparse

def print_version(parser, args):
    if args.version:
        print("sample v0.0.0")
    else:
        parser.print_help()

def hello(parser, args):
    print("Hello world!")

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--version',
                        action='store_true',
                        default=False,
                        help='Print version')
    parser.set_defaults(func=print_version)
    sub_parser = parser.add_subparsers()
    hello_parser = sub_parser.add_parser('hello')
    hello_parser.set_defaults(func=hello)

    args = parser.parse_args()
    args.func(parser, args)

if __name__ == '__main__':
    main()

細かい動作が違いますが概ねこういう感じです.規模が大きくなってきたときにどうなるかは考えるまでも無いでしょう.更にサブコマンドの下にサブコマンドを…等をし始めると,あっという間にスパゲティコードの誕生です.
uroborosでは内部でadd_subparser()を使っており,実際の挙動としては上記のような挙動を全てラップしているような感じになります.

オプション引数の共有

いくつかのサブコマンドで同じオプション引数を使い回したいという需要はあると考えています*2.これを容易に実現する仕組みがuroboros.Optionです.コードの全体像はリポジトリのexampleに任せ,要点だけ説明します.このexampleでは指定されたディレクトリ内のファイルを一覧表示するfilesコマンド, ディレクトリを一覧表示するdirsコマンドを実装しています.
まずuroboros.Optionを継承したオプションクラスを作成します. uroboros.Commandと同様にbuild_optionメソッドで引数を追加できます.

from pathlib import Path
from uroboros import Option, Command

class CommonOption(Option):

    def build_option(self, parser):
        # 共通するオプションを追加する
        parser.add_argument('path', type=Path, help="Path to show")
        parser.add_argument('-a', '--absolute', default=False,
                            action='store_true',
                            help='Show absolute path')
        return parser

class RootCommand(Command):
    ...

# 共通するオプションをクラス変数`options`に設定する
class DirsCommand(Command):
    name = 'dirs'
    options = [CommonOption()]
    ...

class FilesCommand(Command):
    name = 'files'
    options = [CommonOption()]
    ...

root = RootCommand()
root.add_command(DirsCommand())
root.add_command(FilesCommand())

...

これだけでdirsサブコマンドとfilesサブコマンドでpathという位置引数と--absoluteというオプション引数が使えるようになります.
このOptionの良いところは単にくくりだして記述量を減らせるだけでなく,値のバリデーションをまとめることが出来る点にあります.

...

class CommonOption(Option):

    def build_option(self, parser):
        ...

    # 実行時にCommandの`run`より先に呼ばれる
    def validate(self, args):
        # 返すのはExceptionのリスト.バリデーション違反がない場合,空のリストを返す.
        errors = []
        # 指定したパスが存在しない場合エラーにする
        if not args.path.exists():
            errors.append(Exception("'{}' does not exists.".format(args.path)))
        return errors

...

これで例えば存在しないパスを指定すると,filesサブコマンドでもdirsサブコマンドでも共通して存在しないパスを弾くことが出来ます.

$ python main.py files /does/not/exists
'/does/not/exists' does not exists.
$ python main.py dirs /does/not/exists
'/does/not/exists' does not exists.

注意点

良い意味でも悪い意味でのargparseのラッパーに過ぎないため,コマンドライン引数のパースは完全にargparse任せになっています.このため,現状ではサブコマンド以下の引数の名前と,親コマンドで使用している引数の名前がコンフリクトすると最後に設定したオプションで上書きされてしまいます.例えば

$ python main.py root -d fuga command1 -d poyo

のような仕様にした場合,最終的には後者の-d poyoで上書きされてしまいます.これは

  1. 親コマンドの引数のパースをする
  2. サブコマンド以下の引数だけ抽出
  3. サブコマンドの引数のパースをする

の順で解決することでうまく行くはずですが,引数へのアクセシビリティが下がるため避けています.
つまり,今はargs.hogeargs.fugaでアクセスできるコマンドライン変数に対しサブコマンドごとに名前空間を区切ると,args.subcommand.hogeみたいなアクセスの仕方になってしまい,親コマンドからの相対的な位置に依存することになり,せっかく全てのコマンドが個別でexecute()できる仕様が崩壊してしまいます.現状のコマンドの引数のみにフォーカスして渡すことも出来ますが,上位コマンドのオプション引数を解決できなくなってしまいます.
これについては今後の展望で述べるHook機能を使うことで一部解消できるはずです.各コマンドのオプションは各コマンドごとにパースし,必要な処理はHookで行うことで,最終的に実行されるCommandのrunメソッドでは常にグローバルな名前空間にアクセスする必要がなくなる(親コマンドのオプション引数はその親コマンドのHookで解消する)ためです.が,逆に上位コマンドのオプションにはアクセスできなくなるため,上手い仕組みが必要だと考えています.

今後の展望

  • 各コマンドにHookを用意する
    • 例えばルートコマンドにHookを仕掛けてアプリケーション全体でのloggerの設定をするなどに使う
  • バリデーションエラーハンドラーのサポート
    • 現状ではエラーが発生したらそのままlogger.error()して終了してしまう.
    • バリデーションからの回復を可能にするなどがあると便利そう.
  • add_commandで複数のCommandインスタンスを受け取れるようにする
    • すぐできるが需要があるのか分からない
  • オプションをファイルから読み取り/ファイルへ保存
    • json以外の形式のサポートはサードパーティーの依存パッケージを追加することになるので,やるとしてもオプション扱い.
    • あまり上手い形式を思いついていない

他にも要望や改善等あれば是非お願いします.

まとめ

argparseについての知識を活かしたまま,よりCLIアプリケーションを構築しやすくするためのフレームワークとしてuroborosを作成・公開しました.
これにより,サブコマンドの数の増加・そのネスト数の増加に伴って複雑化しがちなCLIアプリケーションを比較的簡単に構築することができるようになりました.

何かフィードバック等あれば遠慮無く頂けると幸いです.よろしくお願いします.

*1:IntEnumで定義されているので実質intです

*2:少なくとも僕にはありました