jqfpyのgetを強化してみた([],*,*[])

github.com

jqfpyのgetを強化してみた。具体的には、<attirubte>[],*,*[]という表記が増えた。

ショートカット用の機能が増えたので、学習コストは上がっていき(?)、当初の想定とは異なりどんどんjqに近づいていっているということがないわけではないのだけれど。

(当初の想定というのはこのあたりの話)

今回は、サンプルのJSONとしてelastic searchのsearch APIのレスポンスを使うことにする。

こういうもの。

{
    "took": 1,
    "timed_out": false,
    "_shards":{
        "total" : 1,
        "successful" : 1,
        "skipped" : 0,
        "failed" : 0
    },
    "hits":{
        "total" : 2,
        "max_score": 1.3862944,
        "hits" : [
            {
                "_index" : "twitter",
                "_type" : "tweet",
                "_id" : "0",
                "_score": 1.3862944,
                "_source" : {
                    "user" : "kimchy",
                    "message": "trying out Elasticsearch",
                    "date" : "2009-11-15T14:12:12",
                    "likes" : 0
                }
            },
            {
                "_index" : "twitter",
                "_type" : "tweet",
                "_id" : "1",
                "_score": 10.3862944,
                "_source" : {
                    "user" : "someone",
                    "message": "hmm",
                    "date" : "2009-11-15T14:12:12",
                    "likes" : 1000000000000000
                }
            }
        ]
    }
}

ちょっと長い。

もともとの機能は1つの要素のためのもの

もともとのget()は、ネストした辞書のアクセスを手軽に行えるためのもの。 例えば、1つ目のtweetのuserを取りに行くようなコードは以下のような感じ。

$ cat 00data.json | jqfpy 'get("hits/hits/0/_source/user")'
"kimchy"

数値として利用可能な文字列が渡された場合には数値として認識されるので1つ目の値などは取ることができる。

ただ、もとのデータに戻って見てみると複数ある。具体的には2つあるのだけれど。この2つの全部の名前を取ってこなくちゃいけない場合にこれでは困る。

$ cat 00data.json | jqfpy --squash 'L = get("hits/hits"); [get("_source/user", d=d) for d in L]'
"kimchy"
"someone"

そうは言っても、結局ただのpythonなのでリストになる部分を変数に束縛して、リスト内包表記などでどうにかなる話ではあるのだけれど。やっぱりめんどう。

新たに増えた機能は複数の要素のためのもの

そんなわけで複数の候補を全部取りたいみたいな部分の処理をそのまま書けるようにした。

$ cat 00data.json | jqfpy --squash 'get("hits/hits[]/_source/user")'
"kimchy"
"someone"

json pointerとは何だったのかみたいになってしまったけれど。まぁ便利なので。。 (少しだけ言い訳させてもらうと、既存のpythonの知識のみで書き下すことができてかつ便利なショートカットがあるものと簡潔な記法のみが存在するものとの違いを意識してほしい)

任意の何かを選択したいという場合もある

あともう一つ増えたのは*という記法。何か名前は忘れたけれど次の階層ではnameを持っているみたいなものを指定したいような場合がある。そのようなときに今までは少し困っていた。

例えば、先程の1つ目のユーザーの代わりに1つ目のメッセージを抽出したいような場合に、_source部分が思い浮かばなかったとする。そのようなときには以下の様に書けるようになった。

$ cat 00data.json | jqfpy 'get("hits/hits/0/*/message")'
"trying out Elasticsearch"

もちろん全部取る場合には以下の様に組み合わせれば良い。

$ cat 00data.json | jqfpy --squash -r 'get("hits/hits[]/*/message")'
trying out Elasticsearch
hmm

補足1

添字でのアクセスとの対称性を考えて以下のような記法を許すという形も良いのかもしれない(これはまだ考えているだけ)。

以下を同じものとするということ。

get("hits/hits[]/*/message")
get("hits/hits/[]/*/message")

補足2

実は任意のみたいに考えてしまうとちょっと問題がある。それは候補が1つに限らないということになってしまうということ。

これはあんまり嬉しくない。[]というような注釈がないにもかかわらずアクセスした結果返ってくる値の数が変わるというのは嬉しくない。例えば、members/*/name というキーに対して、members/xmembers/yもnameを保持していた場合には、その2つの内のどれかということ。

そんなわけで、任意の次のキーを持つ最初の要素ということにした。

ちなみに真面目にバックトラックで探索のようなことをしていないので次のキーしかみていない。(例えば、foo/*/bar/baz があったとしてbarが存在することは保証するけれど。そのbarがbazを持っているかは保証していない)

任意の何かを全部欲しいという場合もある

もちろん任意の何かを全部欲しいという場合もある。例えば以下のようなJSONがあったとして(実はjqfpyのreadmeにあるものと同じもの)、

{
  "apps": {
    "foo": {
      "use": true
    },
    "bar": {
      "use": true
    },
    "boo": {
      "use": true
    },
    "bee": {
      "use": false
    }
  }
}

あんまり良い例ではないけれど、useの値がほしいとする。そのような時に*だけではちょっと困る。

$ cat 02data.json | jqfpy 'get("apps/*/use")'
true

全部を取り出したい。そういうときには*[]をつかう。ちょっとキモい見た目だけれど。

$ cat 02data.json | jqfpy --squash 'get("apps/*[]/use")'
true
true
true
false

おまけ

とはいえ、上の例のようにuseだけ見ようということはあんまりないだろうし名前もほしいとなることが多い。それなら普通にpythonを書いたほうが良いのでは?みたいな話も一方であります(拡張されたget()の利用は計画的に)。

$ cat 02data.json | jqfpy --squash -r '["{k}:{v[use]}".format(k=k,v=v) for k, v in get("apps").items() if "use" in v]'
foo:True
bar:True
boo:True
bee:False

あと、お試しでltsvでの出力もサポートしたので、key-vaueを一行でまとめて出力されてほしいみたいな場合にそちらを使っても良いのかも。

$ cat 02data.json | jqfpy --squash -o ltsv '[{"name": k, "use": v["use"]} for k, v in get("apps").items() if "use" in v]'
name:foo    use:True
name:bar    use:True
name:boo    use:True
name:bee    use:False

jsからpythonに翻訳する過程で気づいた__new__の使いみち(追記:ダメだった)

github.com

http://pod.hatenablog.com/entry/2017/09/14/020740気づいたのだけれど。 jsのちょっとした記述をpythonに直す時に、今まであまり使わなかった__new__()が使える箇所があるかもしれないと思った。

具体的な話

例えば、以下の様なコードがあるとする。これはnpmで使われているsemverのコードを簡略化したものなのだけれど。これをpythonのコードに翻訳したい。

function Range(range, loose) {
  if (range instanceof Range) {
    if (range.loose === loose) {
      return range; // b.
    } else {
      return new Range(range.range, loose)
    }
  }

  if (!(this instanceof Range))
    return new Range(range, loose); // c.

  // d.
  this.loose = loose;
  this.raw = range;
  // do something
}

上のコードでやっていることは以下の様なこと

  • a. Rangeの引数には,rangeとlooseが与えられる
  • b. rangeがRangeのインスタンスで使いまわせそうだったら、rangeをそのまま返す
  • c. 関数として呼ばれた場合にも、適切にオブジェクトが作られるようにする
  • d. 通常のコンストラクタとしての利用

b.flyweight的な感じだし。c.はjs固有の事情。

js固有の事情のおさらい

そういえば、と思いだしたけれど。pythonではクラスはオブジェクトを生成するファクトリー関数とみなすことができて、オブジェクトの生成は単にクラスを関数の実行と同様に呼び出すだけだけれど。jsでは関数をオブジェクトのコンストラクターとして利用する場合と通常の通りに関数として呼び出す場合の2種類の方法がある。

newについては以下の様な形。これはd.の経路を辿り、thisはrange object。

const r = new Range(">=1.2.0", true);

一方で関数呼び出しのように読んでしまった場合には、callerはglobalになる。これはc.の経路をたどる。thisはRangeのインスタンスではないので。

const r = Range(">=1.2.0", true);

オブジェクトを使いまわしたいときは、b.の経路をたどる。あんまり最近見ない気がするけれど。

const r = Range(Range(">=1.2.0", true), true);

過去の対応

過去の対応、というか現時点でのpythonでは、関数とクラスに分けていた。以下のような感じ。

class Range:
    def __init__(self, range_, loose):
        self.range = range_
        self.loose = loose

def make_range(range_, loose):
    if isinstance(range_, Range):
        if range_.loose == loose:
            return ranse_
        else:
            Range(range_.range, loose)
    return Range(range_, loose)

js固有のコードは要らないので消している。__init__()の段階で既にオブジェクトのインスタンスが生成済みなので困るということでmake_range()という関数を作り、常にこの関数を経由してオブジェクトを生成するように書いていた。悩ましいのはそれを強制する方法が全く存在しないこと。

実のところ

実のところ、オブジェクトの生成前のフックというのは、__new__()そのものなのでこれを使ってあげれば良い。 こうかけば良かったことに気づいた。

class Range:
    def __new__(cls, range_, loose):
        if isinstance(range_, Range):
            if range_.loose == loose:
                return range_
            else:
                return Range(range_.range, loose)
        return super().__new__(cls)

    def __init__(self, range_, loose):
        self.range = range_
        self.loose = loose

オブジェクトの生成方法が1つだけになるのでこちらのほうが良さそう。

追記:この方法はダメです

この方法はダメです。 なぜダメかと言うと、まず、__new__()でオブジェクトが生成されたあと自分自身のクラスと同じクラスのオブジェクトが返された場合に必ず__init__()が呼ばれます。これがまず無駄だし気持ち悪い。その上、self.rangeがRangeオブジェクトになってしまいます。

そして、渡されたrangeがRangeオブジェクトだった場合のところでも、結局、__init__()で渡されたrangeが使われてしまうので、やっぱりself.rangeがRangeオブジェクトになってしまいます。

追記2:ムキになって対応しようとしてみた

ムキになって対応してみようとした結果。デコレーターはisinstanceを壊すからだめだし。メタクラスでどうにかできることはわかっているけれど。どう考えてもオーバースペックな感じ。

class RangeMeta(type):
    def __call__(cls, range_, loose):
        if isinstance(range_, cls):
            if range_.loose == loose:
                return range_
            else:
                return cls(range_.range, loose)
        return super().__call__(range_, loose)


class Range(metaclass=RangeMeta):
    def __init__(self, range_, loose):
        self.range = range_
        self.loose = loose


r = Range(">=1.2.0", True)
print(r.range)
r2 = Range(r, True)
print(r2.range)
r3 = Range(r, False)
print(r3.range)
print(isinstance(r3, Range))

python-semver久しぶりに更新した

github.com

昔に作っていたnpmのsemverのforkを更新した(昔とは3年前のこと)

そもそもひとつだけテストずっとコケていたとか、ciもなかったという感じだったので、travisの設定をするところからスタートだった。 結局何が違うのかといえば、どこかのタイミングでx.x.x-xという形式のものがx.x.xという形式に改められたらしい。 それに追随できていなかったので色々おかしかったという感じだった。

別言語の実装をポートするときには、しっかりとどのバージョンを参照したのかなどメモしておかなくてはいけませんね(してませんでした)。

あと、pythonだと普通こう書かないだろうなという部分を、もとの記述に合わせているところがあったりするので、時折不格好に見えるコードが出てくる。そのあたりは仕方がないな-という気持ちになったりした。

あと、ふとテストを走らせてみて、このパッケージはテストが1000個以上あるのですごい(ほとんどもとの実装のテストのコピーなのだけれど)。久しぶりすぎたのでpytestの使いかたをわすれてしまっていた。

あと、英語がすぐに出てこなくてところどころコミュニケーション弱者みたいな気分になったりした。

jqfpyでyamlを実験的にサポートしてみた

github.com

jqfpyでyamlを実験的にサポートしてみた。実験的という言葉がついている理由は完全にサポートしているというわけじゃないということ。 具体的には、連続したjsonに対応した入力をサポートしていない(後で詳しく)。

yaml対応版のインストール

yaml対応版のインストールのインストールは以下の様にする。

$ pip install 'jqfpy[yaml]'

動作例

PyYAMLがインストールしていれば普通に動く。ファイルの拡張子を見て良い感じに扱ってくれる。特にフォーマットを指定していない場合にはjsonで出力される。

00data.yaml

$ jqfpy 00data.yaml
{
  "person": {
    "name": "foo",
    "age": 20,
    "skills": [
      "x",
      "y",
      "z"
    ]
  }
}

$ jqfpy -r '"{p[name]}({p[age]})".format(p=get("person"))' 00data.yaml
foo(20)

入っていない場合には以下の様なエラーが出る。

$ jqfpy 00data.yaml
yaml module is not found. please install via 
  pip install 'jqfpy[yaml]'

フォーマットの指定

-o, --output-formatyamlを指定すると出力もyamlになる。

$ jqfpy -o yaml 00data.yaml
person:
  name: foo
  age: 20
  skills:
  - x
  - y
  - z

$ jqfpy -c -o yaml 00data.yaml
{person: {name: foo, age: 20, skills: [x, y, z]}}

catからパイプで繋げられたときなど、ファイルのフォーマットがファイル名から取得できない場合もある。そのような場合には、-i, --input-format を使う。

$ cat 00data.yaml | jqfpy -r -i yaml 'get("person/name")'
foo

連続したデータ

残念ながら完全なサポートではなく連続したデータなどに対応していない。 例えば、jsonで言う以下の様な出力を例に取る。

01data.json

{
  "person": {
    "name": "foo",
    "age": 20,
    "skills": [
      "x",
      "y",
      "z"
    ]
  }
}
{
  "person": {
    "name": "bar",
    "age": 10,
    "skills": []
  }
}

これをそのままyamlに直すと以下の様になる。

01data.yaml

person:
  name: foo
  age: 20
  skills:
    - x
    - y
    - z
person:
  name: bar
  age: 10
  skills: []

このyamlが2つのデータになれば良いはずなのだけれど。yamlのパーサーの問題でちょっと対応方法が思いつかなかった。現在の所は1つだけのデータと認識されてしまう。

$ jqfpy 01data.yaml
{
  "person": {
    "name": "bar",
    "age": 10,
    "skills": []
  }`
}

ちょっとだけあがいてみていて、yamlの仕様的に---がドキュメントの区切りとして定義されているので出力のときにはこれを利用するようにしている。こちらは2つのデータとして認識される。

$ jqfpy -o yaml 01data.json
person:
  age: 20
  name: foo
  skills:
  - x
  - y
  - z
---
person:
  age: 10
  name: bar
  skills: []

$ jqfpy -o yaml 01data.json | jqfpy -i yaml -c --force-stdin
{"person": {"age": 20, "name": "foo", "skills": ["x", "y", "z"]}}
{"person": {"age": 10, "name": "bar", "skills": []}}

ちょっとだけ困ったこと

ちょっとだけ困ったことが実は存在していて。標準入力から入力がなければヘルプを表示と言うようにしているのだけれど。パイプで繋いでいった状態でちょっと処理が重たいときなどには誤動作してしまうことがある。ヘルプを表示してしまう。

これに対して標準入力を見ることを強制するために --force-stdin というオプションを設けている。これがちょっと微妙。

ちなみに標準入力の有無はselectでみている。このあたりの待機時間を調整すれば良いのかもという話ではあるのだけれど。それもそれで微妙な感じ。

def is_fd_alive(fd):
    if os.name == 'nt':
        return not os.isatty(fd.fileno())
    import select
    return bool(select.select([fd], [], [], 0.07)[0])

追記:

以下が動かないのはちょっとありえないので。ヘルプメッセージを表示する機能消した(0.3.2)。

(sleep 1; cat 01data.json ) | jqfpy

ヘルプメッセージが見たい場合には明示的に jqfpy -h と入力する。

数値の合計を取るのにJSONとみなせる

以下のような数値が行毎に出力されたファイルがあり。この合計を出す時に

00data.txt

1
2
3
4
5
6
7
8
9
10

(複数の列がある中でcutなどで合計を求めたい列だけを取り出してからのsumの場合もある)

以前までは真面目にpythonワンライナーを書いていた。他の方法を調べる気も起きなかったので。ちょっと長いし面倒。

$ 00data.txt | python -c 'import sys; print(sum(float(line.strip()) for line in sys.stdin))'
55

JSON

数値もJSONなのでJSONとして扱えば楽だった。jqfpyで楽に書ける。 --slurp を付けてあげればlistになるので sum() が使える。

$ cat 00data.txt | jqfpy --slurp 'sum(get())'
55

もちろん、jqにもそのようなオペレーターはあるはずで。調べてみたらaddがあった。

$ cat 00data.txt | jq --slurp 'add'
55

jqfpyを使ってswagger spec中の$refを取り出す

github.com

かんたんなjqの処理はjqfpyでどうにかなるということがわかったのでもう少し複雑な処理をしてみることにした。今度はswagger中の$refを抽出してみる。 このswagger中の$refを取り出すことは意外と大変な気がしている。

そもそもjqだけでやるのは辛いし。せっかくpythonなので便利なライブラリをimportしてそれで終わらせる。

題材にするのはこのswaggerファイル

$ curl https://raw.githubusercontent.com/everett-toews/a-restful-adventure/gh-pages/design/hypermedia-based/swagger.json > swagger.json
$ cat swagger.json | jqfpy --squash -r 'import dictknife.walkers as w; itr = w.DictWalker(["$ref"]).iterate(get()); [d["$ref"] for _, d in itr]'
#/definitions/Characters
#/definitions/Character
#/definitions/Error
#/definitions/Character
#/definitions/Error
#/definitions/Character
#/definitions/Error
#/definitions/Error
#/definitions/CharacterLocation
#/definitions/Error
#/definitions/Error
#/definitions/Dungeons
#/definitions/Dungeon
#/definitions/Error
#/definitions/Room
#/definitions/Error
#/definitions/Error
#/definitions/Link
#/definitions/Character
#/definitions/Link
#/definitions/Link
#/definitions/Link
#/definitions/Link
#/definitions/Dungeon
#/definitions/Link
#/definitions/Link

sortしたりuniqueにしたりはpythonでやっても良いけれど。パイプでつなげてしまっても良い。

$ cat swagger.json | jqfpy --squash -r 'import dictknife.walkers as w; itr = w.DictWalker(["$ref"]).iterate(get()); [d["$ref"] for _, d in itr]' | sort -u
#/definitions/Character
#/definitions/CharacterLocation
#/definitions/Characters
#/definitions/Dungeon
#/definitions/Dungeons
#/definitions/Error
#/definitions/Link
#/definitions/Room

内部的な話

今回のコードは以下の様な感じになっている。

def _transform(get):
    import dictknife.walkers as w
    itr = w.DictWalker(["$ref"]).iterate(get())
    return [d["$ref"] for _, d in itr]

jqfpy自体はライブラリを呼び出しているだけ。こういうことができるあたりがpythonにして良いという点の1つ。

bufferedがデフォルトに

昨日の記事ではunbufferedがデフォルトということにしていたけれど。 unbufferedの場合には、長めの入力を途中で切ることがあり、それがちょうどJSONとしてvalidだったときに失敗してしまうということが起きたりしてしまった。 そんなわけでちょっとunbufferedをデフォルトにすると困る。なのでbufferedをでふぉるとにすることにした。これはjqと同じ。

以下みたいな形で --unbuffered をつける必要があるようになった。

$ tail -f <log file> | jqfpy --unbuffered '<code>' | grep <pattern>

おまけ

ついでに、どこで$refが使われているかも抽出してみましょう。ちょっと複雑になりますがこれもまだワンライナーでできる範囲なきがします。

$ cat swagger.json | jqfpy 'from collections import defaultdict; import dictknife.walkers as w; D = defaultdict(list); itr = w.DictWalker(["$ref"]).iterate(get()); [D[d["$ref"]].append("/".join([p.replace("/", "~1") for p in path[:-1]])) for path, d in itr]; D'
{
  "#/definitions/Characters": [
    "paths/~1characters/get/responses/200/schema"
  ],
  "#/definitions/Character": [
    "paths/~1characters/post/responses/201/schema",
    "paths/~1characters~1{character_id}/get/responses/200/schema",
    "paths/~1characters~1{character_id}/put/parameters/[]/schema",
    "definitions/Characters/properties/characters/items"
  ],
  "#/definitions/Error": [
    "paths/~1characters/post/responses/default/schema",
    "paths/~1characters~1{character_id}/get/responses/default/schema",
    "paths/~1characters~1{character_id}/put/responses/default/schema",
    "paths/~1characters~1{character_id}/delete/responses/default/schema",
    "paths/~1characters~1{character_id}~1location/get/responses/default/schema",
    "paths/~1characters~1{character_id}~1location/put/responses/default/schema",
    "paths/~1dungeons~1{dungeon_id}/get/responses/default/schema",
    "paths/~1dungeons~1{dungeon_id}~1rooms~1{room_id}/get/responses/400/schema",
    "paths/~1dungeons~1{dungeon_id}~1rooms~1{room_id}/get/responses/default/schema"
  ],
  "#/definitions/CharacterLocation": [
    "paths/~1characters~1{character_id}~1location/get/responses/200/schema"
  ],
  "#/definitions/Dungeons": [
    "paths/~1dungeons/get/responses/200/schema"
  ],
  "#/definitions/Dungeon": [
    "paths/~1dungeons~1{dungeon_id}/get/responses/200/schema",
    "definitions/Dungeons/properties/dungeons/items"
  ],
  "#/definitions/Room": [
    "paths/~1dungeons~1{dungeon_id}~1rooms~1{room_id}/get/responses/200/schema"
  ],
  "#/definitions/Link": [
    "definitions/Character/properties/links/items",
    "definitions/Characters/properties/links/items",
    "definitions/CharacterLocation",
    "definitions/Room/properties/links/items",
    "definitions/Dungeon/properties/links/items",
    "definitions/Dungeons/properties/links/items",
    "definitions/Error/properties/link"
  ]
}

実際に実行されるのは以下の様なコード。

def _transform(get):
    from collections import defaultdict
    import dictknife.walkers as w
    D = defaultdict(list)
    itr = w.DictWalker(["$ref"]).iterate(get())
    [D[d["$ref"]].append("/".join([p.replace("/", "~1") for p in path[:-1]])) for path, d in itr]
    return D

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

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