関数をCLIとして使えるようにするラッパーのhandofcatsをアップデートした
関数をCLIとして使えるようにするラッパーのhandofcatsをアップデートした。
けっこう日常的に使っているものほどバグが放置される傾向にある気がする。 たぶん便利さを求めたものに関しては作品感のようなものを感じる度合いが薄いのかもしれない。 愛がない(個人的な気持ちです)。
とはいえ幾つか気になっていたバグは存在していて、せっかくだったので新幹線での移動の最中に修正していた。
handofcats?
そのまえにたぶん知っている人はいないと想うのでhandofcatsの紹介。
installは以下のようにpipで。
$ pip install handofcats
CLIとして実行
例えば以下の様な形で関数を書いてあげるCLIのコマンドになる。
from handofcats import as_command @as_command def hello(name: str) -> None: print(f"hello {name}")
こういう感じに。
$ python 00hello.py -h usage: 00hello.py [-h] [--expose] [--inplace] [--typed] name positional arguments: name optional arguments: -h, --help show this help message and exit --expose --inplace --typed
--expose
でhandofcatsを取り除く
ついでに--expose
でhandofcatsへの依存を取り除ける。この辺の話は以下の記事に詳しく書いた。
こういう感じに変換される。--inplace
を付けると元のファイルが上書きされる。
$ python 00hello.py --expose | tee 01hello.py
出力されたファイル (01hello.py)
def hello(name: str) -> None: print(f"hello {name}") def main(argv=None): import argparse parser = argparse.ArgumentParser(description=None) parser.print_usage = parser.print_help parser.add_argument('name') args = parser.parse_args(argv) hello(**vars(args)) if __name__ == '__main__': main()
はい。
更新内容について
具体的には以下の様な変更を加えた。
- typing_extensions.Literalのサポート
- "main"という名前を利用した場合に名前が衝突してしまうのを修正
from __future__ import annotations
を使っている時にエラーになっていたのを修正- '_'を含んだ引数名のものがpositional argumentsだった時に
foo_bar
がfoo-bar
になっていたのを修正 --typed
というオプション付きでコード生成をしたときに型が付いた状態で出力してくれる様に変更
typing_extensions.Literalのサポート
以前もchoicesという形でサポートしていたのだけれど(正確に言えばtyping.NewType + choices)。literal typesで記述する様に変更した。
以前はこういうコードだったのが。
02jsonfmt.py
import typing as t from handofcats import as_command DumpFormat = t.NewType("DumpFormat", str) DumpFormat.choices = ["json", "csv"] @as_command def run(*, input_format: DumpFormat, output_format: DumpFormat = "json") -> None: pass
こういう形でLiteralTypeで良くなった。もちろん一度変数に代入して使わなくても、引数のところで直接リテラルとして使っても良い。
03jsonfmt.py
import typing_extensions as tx from handofcats import as_command DumpFormat = tx.Literal["json", "csv"] @as_command def run(*, input_format: DumpFormat, output_format: DumpFormat = "json") -> None: pass
それぞれ以下の様なヘルプメッセージになる。
$ python 03jsonfmt.py -h usage: 03jsonfmt.py [-h] [--expose] [--inplace] [--typed] --input-format {json,csv} [--output-format {json,csv}] optional arguments: -h, --help show this help message and exit --expose --inplace --typed --input-format {json,csv} --output-format {json,csv} (default: 'json')
ちなみにオプション部分は以下の様な形に変換される。
parser.add_argument('--input-format', required=True, choices=['json', 'csv']) parser.add_argument('--output-format', required=False, default='json', choices=['json',
この対応をしている時にpython3.7以降を推奨したくなった。
"main"という名前を利用した場合に名前が衝突してしまうのを修正
これは以前までは生成される関数が"main"で固定だったので、as_command()
をmain()
に対して使うと名前が衝突してしまっていた。main()
がある場合にはMain()
を生成するようにした。
すこしキモいけれどyapfなどはgoと同じ命名規則で書かれているのでMainは許される気がした。
04main.py
from handofcats import as_command @as_command def main(): pass
コレがこう。
def main(): pass def Main(argv=None): import argparse parser = argparse.ArgumentParser(description=None) parser.print_usage = parser.print_help args = parser.parse_args(argv) main(**vars(args)) if __name__ == '__main__': Main()
from __future__ import annotations
を使っている時にエラーになっていたのを修正
そもそもfrom __future__ import annotations
が何かというと型ヒントの部分を先に読むようにloaderに指定する機能。詳しくはPEP563に。
例えばこういう定義が可能に成る。
from __future__ import annotations class A: b: B class B: pass
from __future__ import annotations
を使わない場合には以下の様にダブルクォートで囲ってあげる必要がある。
class A: b: "B" class B: pass
これはふつうにバグっていたので直した。具体的にはinspect.getfullargspec()で取り出した型の値がfrom __future__ import annotations
のときには全部文字列で返ってきていた。
こういう形を期待。
def run(filename: str) -> None: pass inspect.getfullargspec(run) # => {'return': None, 'filename': <class 'str'>}
しかし返ってくるのはこう。
from __future__ import annotations import inspect def run(filename: str) -> None: pass inspect.getfullargspec(run).annotations # => {'return': 'None', 'filename': 'str'}
'_'を含んだ引数名のものがpositional argumentsだった時にfoo_bar
がfoo-bar
になっていたのを修正
これは気づいていながら直していなかったもの。
例えばfile_name
みたいな名前があった場合に、これをフラグとして扱うなら--file-name
が正しい。この場合の定義は add_argument("--file-name")
。一方で通常のコマンドライン引数として扱うなら[file_name]
である必要があった。この場合の定義はadd_argument("file_name")
。
一括で file_name
などを file-name
などにしていたのでエラーになっていた。
日々の生活では運用でカバーしていて、ちょくちょく手抜きでfilename
などのような名前に変えて利用していた。これも直した。
例えば以下の様な関数の定義があるとする。これをCLIとして扱いたい。 05name.py
def run(ouput_name: str, *, output_format: str) -> None: pass
ちなみに python -m handofcats --expose
を使えば as_command()
でwrapしていないものでも実行できるし--expose
も使える。
python -m handofcats 05name.py:run --expose
出力結果。
def run(ouput_name: str, *, output_format: str) -> None: pass def main(argv=None): import argparse parser = argparse.ArgumentParser(description=None) parser.print_usage = parser.print_help parser.add_argument('ouput_name') parser.add_argument('--output-format', required=True) args = parser.parse_args(argv) run(**vars(args)) if __name__ == '__main__': main()
はい。
--typed
というオプション付きでコード生成をしたときに型が付いた状態で出力してくれる様に変更
これは生成された結果のdiffを見てもらえばわかりやすそう。
$ handofcats --expose 05name.py:run > 06untyped.py $ handofcats --expose --typed 05name.py:run > 07typed.py $ diff -u 06untyped.py 07typed.py
diff
--- 06untyped.py 2019-11-11 21:53:21.319744454 +0900 +++ 07typed.py 2019-11-11 21:53:24.319759271 +0900 @@ -1,7 +1,12 @@ def run(ouput_name: str, *, output_format: str) -> None: pass -def main(argv=None): + + +from typing import Optional, List # noqa: E402 + + +def main(argv: Optional[List[str]] = None) -> None: import argparse parser = argparse.ArgumentParser(description=None) parser.print_usage = parser.print_help
mypyのエラーを防ぐために parser.print_usage = parser.print_help
も type: ignore
くらい付けてあげても良かったかもしれない。
最後に
やる気があったら以下の様なことがしたい
- readmeをまともにする
- サブコマンドの対応
- 名前を変える (いっそのことnekonoteの方が良いのでは?でも猫の手?猫ノート?と困惑する気もする)