CLIの半分を静かに失っていたジェネレーター
このサイトのコマンドリファレンスは自動生成されている。ジェネレーターの最初のバージョンは、53個のサブコマンドが欠落したまま、きれいにソートされた、完全に説得力のあるJSONファイルを生成し——そしてエラーを出さなかった。
Chimeraのコマンドラインインターフェースには109個の呼び出しがある。ルートコマンドが56個、11個のグループにまたがるサブコマンドが53個だ。ドキュメントはそのうちのおよそ17個についてハッピーパスをカバーしており、それは人間の手で書かれたものだ。これは「はじめに」ガイドを書く上では正しいやり方だが、網羅的なリファレンスを維持する上では間違ったやり方でもある。手でコピーしたフラグの一覧が正しいのは、それが書かれた日ただ一度きりだ。
だからこのサイトのリファレンスは、CLI自体から生成されている。デスクトップアプリのTypeScriptの型がAPIスキーマから生成されているのと同じやり方で、CIには同じ「ドリフトゲート」が組み込まれている——再生成し、コミット済みのコピーと差分があれば失敗させる、という仕組みだ。
バグ
最初のバージョンは、コマンドツリーをこのように歩いていた。
root = typer.main.get_command(cli_app)
if not isinstance(root, click.Group):
raise TypeError("the Chimera CLI is expected to be a command group")
このチェックは失敗した。失敗したのは走査ではなく、チェックそのものだ。typer.main.get_command() は、インストールされている click.Group のインスタンスではないオブジェクトを返していた。なぜならTyper 0.27は、typer._click という名前空間の下にClickの独自コピーをベンダリングしているからだ。TyperGroup は typer._click.core.Command を継承しており、自分のコードがインポートしている click は、まったく別のクラスオブジェクトなのだ。
例外を投げたのは、単なる幸運だった。その一つ前のバージョンは、代わりに一見もっともらしいことをしていた——click.Group でないものはすべて末端のコマンドとして扱う、というやり方だ。そしてそちらは例外を投げなかった。整った形式の、ソート済みで決定的なJSONファイルが生成され、そこには56個のコマンドが記述されていたが、すべてのグループが単一のエントリに平坦化され、53個のサブコマンドはすべて消えていた。
なぜそれが最悪の失敗なのか
クラッシュするジェネレーターは、失敗したことを教えてくれる。もっともらしい出力を生成するジェネレーターは何も教えてくれず、それより下流にあるものすべてがその沈黙を引き継いでしまう。リファレンスページは美しくレンダリングされていただろう。chimera kanban はヘルプテキスト付きで、サブコマンドなしのコマンドとして一覧に載っていただろう。それは、サブコマンドを持たないコマンドの記述として、まったく違和感なく読める。検索エンジンもそれをインデックスしていただろう。そのページを見ている誰にも、欠落は検出できなかったはずだ。なぜなら、欠落した状態は、完全な状態のページとまったく同じように見えるからだ。
この失敗は、見ることではなく数えることによって発見された。リファレンスは109個の呼び出しを示すべきところ、56個しか示していなかった。
修正、そしてそれより長く生き続けるテスト
ダンパーは今ではダックタイピングを使うようになった。オブジェクトが何のクラスであるかを問うのではなく、commands というマッピングを持っているかどうかを問うのだ。これなら、ベンダリングされたClickにも、Typerのアップグレードにも、Clickのメジャーバージョンの変更にも耐えられる。なぜなら「サブコマンドを持っている」というのはそのモノ自体の性質であり、「click.Group である」というのはインポートグラフの性質にすぎないからだ。
修正そのものよりも有用なのは、テストの形だ。わかりやすいアサーションは、ダンプが成功したことを検証するというものだ。
def test_dump_works():
assert build() # passes with 53 subcommands missing
この失敗を検出するアサーションは、何が見つかったかについてのものだ。
def test_finds_the_groups_and_not_just_the_leaves():
groups = [c for c in build()["commands"] if "commands" in c]
assert len(groups) >= 10
ジェネレーターが実行されたことだけを主張するテストは、ジェネレーターが間違っている間も通り続けてしまうテストだ。書く価値があるのは、それが見つけるべきだったものを主張するテストである。
参照先
- ダンパーとそのテスト:
chimera/cli/schema_dump.pyとtests/test_cli_schema_dump.py - Typerがベンダリングしている Click について、自分でその仕組みを確認したい場合:
typer/_click
https://chimeraagent.space