handofcatsで関数をコマンド化したときに戻り値をデフォルトで表示してくれるようにした

github.com

handofcatsで関数をコマンド化したときに戻り値をデフォルトで表示してくれるようにした。

どうして?

今までは以下の様な関数をコマンド化したときに何も表示されなかった。

00hello.py

from handofcats import as_command


@as_command
def hello(*, name="world") -> str:
    return f"hello, {name}"

戻り値は捨てられるので、当然といえば当然なのだけれど。かなしい。 というか、間違えてしまったのじゃないかと一瞬戸惑うことがあった。

以前はこう。

# 3.1.0 より前
$ python 00hello.py

何も表示されない。悲しい。

3.1.0からは、この戻り値の扱いを変えた。

$ python 00hello.py
hello, world

デフォルトはほぼほぼprint。

戻り値の扱いの詳細

実際にはprintではなくNoneではなかったらprintという形になる。こういうイメージ。

def cont(v: t.Any) -> t.Any:
    if v is None:
        return None
    print(v)
    return v

というわけで、リストなどを返す関数をコマンドとして実行すると以下の様な形になる。

$ python 01nums.py
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

01nums.py

from handofcats import as_command


@as_command
def nums() -> list:
    return list(range(10))

jqfpyと組み合わせると、ちょっといい感じに扱えるかもしれない。とはいえこれはJSONではない。

$ python 01nums.py | jqfpy --squash
0
1
2
3
4
5
6
7
8
9

出力方法を変えたい場合

もちろん、デフォルトの出力方法が気に食わない場合があるかもしれない。その場合は出力方法を変えられる。 実際には、出力方法ではなく、継続を変えるみたいな感覚で名前がcont (continuation) になっている1

それを使って、今度は本当にdictをJSON化して出力してみる。

02custom-output.py

python 02custom-output.py
[
  {
    "no": 0,
    "name": "藤本 直子",
    "address": "滋賀県渋谷区津久戸町20丁目16番12号"
  },
  {
    "no": 1,
    "name": "山本 花子",
    "address": "徳島県福生市豊町35丁目23番20号"
  },
  {
    "no": 2,
    "name": "小泉 幹",
    "address": "岡山県足立区西川28丁目11番20号 パーク無栗屋784"
  },
  {
    "no": 3,
    "name": "山本 亮介",
    "address": "福岡県西多摩郡日の出町津久戸町41丁目1番20号"
  },
  {
    "no": 4,
    "name": "山口 翔太",
    "address": "佐賀県香取郡神崎町猿楽町16丁目26番5号"
  }
]

02custom-output.py

from handofcats import as_command, Config
import json


@as_command(
    config=Config(cont=lambda x: print(json.dumps(x, indent=2, ensure_ascii=False)))
)
def nums() -> list:
    import faker

    faker.Faker.seed(0)
    fake = faker.Faker("ja_JP")
    return [{"no": i, "name": fake.name(), "address": fake.address()} for i in range(5)]

configにcontを渡して出力方法が変更できる。

今度は完全にJSONなのでjqfpyに渡してちょっとだけ違った形に整形することもできる。1行ごとに改行を挟んだJSON(LDJSON)にしたりするとgrepで雑にフィルタリングができて便利なことがある。

$ python 02custom-output.py | jqfpy --squash -c
{"no": 0, "name": "藤本 直子", "address": "滋賀県渋谷区津久戸町20丁目16番12号"}
{"no": 1, "name": "山本 花子", "address": "徳島県福生市豊町35丁目23番20号"}
{"no": 2, "name": "小泉 幹", "address": "岡山県足立区西川28丁目11番20号 パーク無栗屋784"}
{"no": 3, "name": "山本 亮介", "address": "福岡県西多摩郡日の出町津久戸町41丁目1番20号"}
{"no": 4, "name": "山口 翔太", "address": "佐賀県香取郡神崎町猿楽町16丁目26番5号"}

はい。

handofcatsコマンドから

as_commandデコレーターを付けないソースコードに対して、いきなりhandofcatsコマンドを利用して関数をコマンドであるかのように呼ぶ場合には--contというオプションで渡せる。関数の指定は<module name>:<function name>。モジュール名の部分は物理的なファイル名でも良い。

$ python -m handofcats 03with-handofcats.py people --cont=03with-handofcats.py:_output
[
  {
    "no": 0,
    "name": "藤本 直子",
    "address": "滋賀県渋谷区津久戸町20丁目16番12号"
  },
  {
    "no": 1,
    "name": "山本 花子",
    "address": "徳島県福生市豊町35丁目23番20号"
  },
  {
    "no": 2,
    "name": "小泉 幹",
    "address": "岡山県足立区西川28丁目11番20号 パーク無栗屋784"
  },
  {
    "no": 3,
    "name": "山本 亮介",
    "address": "福岡県西多摩郡日の出町津久戸町41丁目1番20号"
  },
  {
    "no": 4,
    "name": "山口 翔太",
    "address": "佐賀県香取郡神崎町猿楽町16丁目26番5号"
  }
]

03with-handofcats.py

def _output(x):
    import json

    print(json.dumps(x, indent=2, ensure_ascii=False))


def people() -> list:
    import faker

    faker.Faker.seed(0)
    fake = faker.Faker("ja_JP")
    return [{"no": i, "name": fake.name(), "address": fake.address()} for i in range(5)]

--expose時にcont部分のコードは生成される?

今の所は--exposeでhandofcats非依存のコードを生成したときにはcont部分のコードは生成されない。argparse化されるなら全部いじれるんだから、単に関数を実行してる部分に追加すれば良いんじゃないか?という思いが芽生えたのと、もし仮にこれを実装した場合にはlambdaなどで渡されるcontの扱いが地味に面倒そうだなーと思ったため。

ついでの変更点

ついでの変更点をここでメモしておく。--expose のときのオプションに --untyped というオプションがあったのだけれどこれを消した。そして --simple というオプションを追加した。

--siimpleオプションを付加して呼び出すと、型の指定の部分と共にargparse関連のちょっとだけ冗長に見えるコードを消した形で出力されるようになる。

例えばバグを再現するコードの例を書くときには時々シンプルな表現が欲しくなった。このようなときに綺麗なヘルプメッセージのためのコードなどは邪魔でしかない。一方で--untypedというオプションを個別に指定したい気持ちになることはなかった。そんなわけで --simple に統合した。

生成されるコードの差分はこのような感じ。

--- output.full.py   2020-02-08 20:46:14.000000000 +0900
+++ output.simple.py  2020-02-08 20:46:14.000000000 +0900
@@ -1,13 +1,13 @@
-import typing as t
+
 
 def hello(*, name="world") -> str:
     return f"hello, {name}"
 
 
-def main(argv: t.Optional[t.List[str]] = None) -> t.Any:
+def main(argv=None):
     import argparse
 
-    parser = argparse.ArgumentParser(prog=hello.__name__, description=hello.__doc__, formatter_class=type('_HelpFormatter', (argparse.ArgumentDefaultsHelpFormatter, argparse.RawTextHelpFormatter), {}))
+    parser = argparse.ArgumentParser(prog=hello.__name__, description=hello.__doc__)
     parser.print_usage = parser.print_help  # type: ignore
     parser.add_argument('--name', required=False, default='world', help='-')
     args = parser.parse_args(argv)

↑のdiffは以下で生成。

$ python 00*.py --expose  > output.full.py
$ python 00*.py --expose --simple> output.simple.py
$ diff -u output.full.py output.simple.py > a.diff

はい。

gist

gistは以下

https://gist.github.com/podhmo/9437d27a3c35213b2293a50511b5647e


  1. 本当に継続か?というと。。どうだろ。。