jqfpyというコマンドを作りました。

github.com

はじまり

jqfpyというコマンドを作りました。作った理由は概ねこの記事に書いてあるとおりです。

むずかしさの段階

JSONを整形したいときには概ね以下の3段階くらいの状況があるきがします。

  1. かんたんなのでワンライナーで十分
  2. ちょっと複雑な記述でどうするか迷う
  3. 完全にワンライナーでは無理なのでスクリプトを書く

はじめはかんたんな整形でここではjqが楽です。そして2つ目を飛ばして最後の3段階目はワンライナーでは限界になり真面目に何らかのスクリプトを書いたほうが良いと言う感じです。そして飛ばした2つ目の部分がここでの焦点です。ぱっと見そんなに複雑ではないように思えるもののいざjqで書こうとすると思うようにかけず無駄に時間を消費してしまうという類の状況に結構悩まされていました。

特に、jqは最終的には記述が短く簡潔になるものの、その記述にたどり着くために苦労するみたいなことが結構ありました。

jqという別の言語をもう1つ覚える必要ある?

この時思ったのは、そもそも、jqというDSLを使うからだめで、自分の母国語(今の所個人的にはpythonになっている。そういうことにしておいてください。人によって変わると思います)で直接ワンライナーが書ければ捗るのでは?ということでした。

自分の母国語で記述することにしてしまえば、最初の段階ではある程度文字数が増えるものの、作業の複雑さに対しての学習難度のようなものの増加が穏やかになるんじゃないかな?なったらいいなーという感じです。pythonなら書けるので(ここでのpythonは自由に読み替えてください)。

jqfpyという名前

名前は jq for pythonista (jq for python) の略です。最初はjqの代替なのでqjにしようと思いましたが、jqの代替は他にも出てくる可能性が在るということで止めました。

けっこうてきとうです。

大雑把な解説

まず前提として、シェル上からワンライナーで使うために在るので、シェル的な意味で一行で書けることが望まれています。

その上で、コア部分は単純で、; を改行扱いにしてコードを生成しexecしているだけです。

なので例えば、x = f(); y = g(x); h(y) という記述があった場合には、以下の様なコードになるだけです。

x = f()
y = g(x)
h(y)

これでは入力も出力も表せられないので変換する関数を生成することにしました。入力はjsonをパースした辞書でも良かったのですが。その引数名をどうしようかなど考える内にただただ入力となるデータを取り出せる関数が1つあれば良いのではないかと思いました。

そのため、get()という関数がひとつだけ実行時のスコープで利用できます。またrubyやrustなどを考えてみるに、関数のボディの最終行を戻り値とするという規則はそれなりに広く受けいられそうなきがします。なのでこれを採用することにしてみました。

実際のコードでは上の x = get(); y = g(x); h(y) は以下の様な関数に変換されます。

def transform(get):
    x = get()
    y = g(x)
    return h(y)

get

もうすこし get() について話すと、get()dict.get()の亜種のようなものです。 ただ少し高機能です。

1つには、ネストしたアクセスが可能になっています。これはワンライナーを書く上で意外と添字アクセスとそのキーとなる文字列をクォートで括るのがめんどくさいと思ったからです。なので以下の様にかけます。

get("foo/bar/boo")

これと似たようなものです。

get()["foo"]["bar"]["boo"]

ただし実際には等価ではないです。途中でキーが見つからなかった場合に評価がそこで打ち切られてNoneが返ります。これはjqのドットによるアクセスも同様の結果になるので採用しました。

ただなんとなくの好みでドットによるアクセスではなく、json pointerに似た記法を採用しています。なのでキーが /api/foo などのJSONには、~1api~1foo でアクセスします。

もう1つ、トップレベルのデータへのアクセスと、辞書に対するアクセスを兼ねています。第二引数が辞書です。そして第二引数は省略可能で省略した場合にはトップレベルのデータということになっています。

なのでちょっと不格好ですが。以下は等価です。

get("foo/bar/boo")
get("bar/boo", get("foo"))
get("boo", get("bar", get("foo")))

もちろん変数も使えるのでこう書いても大丈夫です。

foo = get("foo"); bar = get("bar"); get("boo")

ちなみにこの変数を手軽に定義できて、手続き的に書けるというのが学習曲線が緩やに感じる点のひとつになるような気がします。 jqの文法は少し頭の使いかたを変えないとだめです。個人的には。

インストール方法

インストールはいつもどおりpipを使って以下の様にします。

pip install jqfpy

examples

幾つかの例を書いてみます。例えば以下のようなJSONからnameを抜き出す場合には以下のようにします。

1つの単純なJSON

00data.json

{
  "name": "foo",
  "age": 20
}

おおよそ、jqと同じような形で使えます。

$ cat 00data.json | jqfpy 'get("name")'
"foo"
$ cat 00data.json | jqfpy -r 'get("name")'
"foo"
$ jqfpy -r 'get("name")' 00data.json
"foo"

複数のJSONが繋がった場合

jqと同様に複数のJSONが繋がった場合の入力にも対応しています。不揃いであっても大丈夫です。

01data.json

{
  "name": "foo",
  "age": 20
}

{
  "name": "bar",  "age": 10
}

{  "name": "boo",  "age": 11
}

-c と一緒に使うことでコンパクトな出力になります。

$ jqfpy -c 'get()' 01data.json
{"name": "foo", "age": 20}
{"name": "bar", "age": 10}
{"name": "boo", "age": 11}

年齢順でソートしてみます。ただし複数のJSONが繋がったものはarrayではないので、--slurpオプションが必要になります。 それ以外はは普通のpythonのコードなのでsorted()でソートする感じです。このあたりはvalidなpythonのコードを書かなければ行けないのでちょっと長くなります。

$ jqfpy --slurp 'sorted(get(), key=lambda x: int(x["age"]))' 01data.json
[
  {
    "name": "bar",
    "age": 10
  },
  {
    "name": "boo",
    "age": 11
  },
  {
    "name": "foo",
    "age": 20
  }
]

もとのファイルと同様に1つネストを外したい場合には、--squashをつけます。これはjqにはないです(jqは通常の文法の中にこの機能を持っています)。

$ jqfpy -c --squash --slurp 'sorted(get(), key=lambda x: int(x["age"]))' 01data.json
{"name": "bar", "age": 10}
{"name": "boo", "age": 11}
{"name": "foo", "age": 20}

フィルタリング

フィルタリングをしてみましょう。このあたりになるとただのpythonワンライナーです。

偶数だけ取り出してみます。ただのリスト内包表記です。

$ jqfpy -c --squash --slurp 'L = [x for x in get() if int(x["age"]) % 2 == 0]; sorted(L, key=lambda x: int(x["age"]))' 01data.json
{"name": "bar", "age": 10}
{"name": "foo", "age": 20}

これは以下の様なコードになっています。--show-code-onlyというオプションをつけると実行コード自体を出力してくれます。

$ jqfpy --show-code-only -c --squash --slurp 'L = [x for x in get() if int(x["age"]) % 2 == 0]; sorted(L, key=lambda x: int(x["age"]))' 01data.json
def _transform(get):
    L = [x for x in get() if int(x["age"]) % 2 == 0]
    return sorted(L, key=lambda x: int(x["age"]))

ふつうのpythonコードですね。

エラーメッセージ

ここで少しだけ間違ってみましょう。エラーメッセージは以下の様になります。なんとなくエラーの箇所が把握できる程度には情報があります。たぶん。

jqfpy --squash --slurp 'sorted(get(), key=lambda x: int(x["ag"]))' 01data.json
code:
----------------------------------------
def _transform(get):
    L = [x for x in get() if int(x["age"]) % 2 == 0]
    return sorted(L, key=lambda x: int(x["ag"]))
----------------------------------------

error:
Traceback (most recent call last):
  File "/home/podhmo/my/bin/jqfpy", line 11, in <module>
    load_entry_point('jqfpy', 'console_scripts', 'jqfpy')()
  File "/home/podhmo/my/jqfpy/jqfpy/__main__.py", line 102, in main
    r = jqfpy.transform(transform_fn, d)
  File "/home/podhmo/my/jqfpy/jqfpy/__init__.py", line 54, in transform
    return fn(Getter(d).get)
  File "<string>", line 3, in _transform
  File "<string>", line 3, in <lambda>
KeyError: 'ag'

‘age'をtypoしています。

正規表現でフィルタリング

正規表現でフィルタリングしたいとおもいます。 このあたりになるとjqの場合にはどのような関数を呼ぶかを思い出せずドキュメントを見に行く必要が出ると思うのですが。 こちらはただのpythonのこーどなので、通常のpythonのコードと同じにreモジュールをimportして使えば良いです。

ワンライナーですが。

$ alias QJ="jqfpy -c --squash --slurp"
$ QJ 'import re; rx = re.compile("o{2}"); [x for x in get() if rx.search(x["name"])]' 01data.json
{"name": "foo", "age": 20}
{"name": "boo", "age": 11}

ちなみにインデントが必要なコードは非推奨です。

バッファリング

デフォルトでバッファリングが無効です。

$ tail -f app.log | jqfpy 'get("msg")' | grep foo
# 同じ意味
$ tail -f app.log | jqfpy --unbuffered 'get("msg")' | grep foo

クローズされたりするまで後段のプロセスに出力しないみたいな状況にはならないです。