関数をCLIとして使えるようにするラッパーのhandofcatsをアップデートした

github.com

関数を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_barfoo-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_barfoo-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_helptype: ignore くらい付けてあげても良かったかもしれない。

最後に

やる気があったら以下の様なことがしたい

  • readmeをまともにする
  • サブコマンドの対応
  • 名前を変える (いっそのことnekonoteの方が良いのでは?でも猫の手?猫ノート?と困惑する気もする)

prestringに複数ファイルの出力機能を付けた

github.com

prestringに複数ファイルの出力機能を付けた。prestring.output:outputを使う。 正確には以前からあったモジュールだったのだけれど、あまりにも貧弱かつ使いにくかったので書き直した。

コード

使いかたの話。例えば以下のスクリプトは、指定したディレクトリ(dst)に3つのファイルを出力する。

  • projects/x.txt
  • projects/y.txt
  • projects/z.py
import sys
from prestring.python import Module
from prestring.output import output, cleanup_all  # noqa

dst = sys.argv[1]
with output(root=dst) as fs:
    with fs.open("projects/x.txt", "w") as wf:
        print("hello x", file=wf)
        print("bye x", file=wf)

    with fs.open("projects/y.txt", "w") as wf:
        print("hello y", file=wf)
        print("bye y", file=wf)

    with fs.open("projects/z.py", "w", opener=Module) as m:
        with m.def_("hello"):
            m.stmt("print('hello')")

コンテキストマネージャで取れる部分はファイル入出力のtyping.IO[str]的なものが返ってくる。ちなみに"wb"などには対応していない ("r"で取り出してもseekが必要だったりするかもしれない)。

openerに別の関数を渡すことで返ってくる値を変えられる。prestringのサブモジュールなのでもちろんprestring.python:Moduleなどが使えて欲しい(使いかたなどはこの記事では説明しないけれどふんいきで)。

あとしれっとpathlib.Pathオブジェクトにも対応している(はず)。

実行

こういう感じに。ディレクトリも自動で作ってくれる。

$ python src/main.py dst
[D] create  dst/projects
[F] create  dst/projects/x.txt
[F] create  dst/projects/y.txt
[F] create  dst/projects/z.py

ちなみにもう一回実行したときには何も出力が出ない。VERBOSE=1を付けると親切なメッセージを表示してくれる。

$ python src/main.py dst
$ VERBOSE=1 python src/main.py dst
[F] no change   dst/projects/x.txt
[F] no change   dst/projects/y.txt
[F] no change   dst/projects/z.py

dry-run

実際のファイル出力を避けて実行したいこともある。あるいは変更の前後の差分を確認するときに1ファイルだけの比較で済むと便利なことも多い。CONSOLE=1という環境変数をつけて実行するとどのファイルが出力されるかだけを教えてくれる(このあたり環境変数を乱用しているけれど、prestring.output:outputのオプションに直接bool値を渡してあげても良い)。

$ CONSOLE=1 python src/main.py dst
[F] update  dst/projects/x.txt
[F] update  dst/projects/y.txt
[F] update  dst/projects/z.py

VERBOSE=1 付きだと出力内容の全てをターミナル上に出力してくれる。

$ VERBOSE=1 CONSOLE=1 python src/00/main.py dst/00/create
# dst/00/create/projects/x.txt
----------------------------------------
  hello x
  bye x


# dst/00/create/projects/y.txt
----------------------------------------
  hello y
  bye y


# dst/00/create/projects/z.py
----------------------------------------
  def hello():
      print('hello')

prestring.naming

よくあるsnake_case,camelCase,kebab-case用の関数を用意しているので何かの折には便利かもしれない。

$ python -q
>>> from prestring.naming import *
>>> snakecase("fooBar")
'foo_bar'
>>> snakecase("foo-bar")
'foo_bar'
>>> camelcase("foo-bar")
'fooBar'
>>> kebabcase(camelcase("foo-bar"))
'foo-bar'

特定のディレクトリ名やファイル名はkebab-caseでみたいなことがあったり、設定ファイルではcamelCaseで、ソースコード上ではsnake_caseみたいなことがあったりはするので。

さいごに

prestring.outputを更新した。複数出力自体汎用的な機能なのでパッケージを分けても良いかなと思ったけれど。良いパッケージ名が思いつかないのでとりあえずこのままprestringの中に。

やろうと思えばscaffoldのようなスクリプトを1ファイルに収められて便利。かもしれない。