value at given key path which follows dotted-path notation、的な

見出しでピンとくる人はいる?

Lark などいくつか日常的に使えそうなパーサジェネレータを見つけたので、いっそ「今自分が取り組んでいるもの以外の、本気で日常で使える実践的な例」なんぞ欲しいな、なんて考えていた。

いっぱいありそうな気はするけれど、「取り組む問題によっては毎日苦痛で毎日欲しくなる道具」というが一つある。それが記事タイトルにしたのと似たニーズ。すなわち「辞書やオブジェクトに対する xpath のようなリッチなクエリ」。

ほんと常日頃から思っている。入力が XML だとするだろ? で、XML 処理は基本的に苦痛なので、「いつかは辞書にしてしまいたい」というわけだが、辞書にしてしまったが最後、今度は XPath を失ってしまうわけな。lxml なら「本物の XPath」が使えるわけでしょう。XML そのものが苦痛でも、XPath は大活躍させたい、でも XML は苦痛だ、との闘い、毎日。

今日一日手を動かさずにずっとこればっか考えてたんだけど、まずはこういうことなのね:

  • XPath の「思想」は汎用的と言える。
  • しかしながら XPath は「XML 相手であることが前提」の固有の表現が多い。
  • 例えば要素と属性。

なので「XPath パーサ」を何かから抜き出してきて辞書相手にしても実はほとんど嬉しくない。無論「辞書相手にする」方には、こちらはこちらで固有の事情があり、例えば「キーは文字列とは限らない」がまさにそう。Python であることを前提にすれば、リストのスライスなんかも評価したいし。

そんなわけで「辞書探索のためのリッチなクエリ式」として、XPath そのものは使えない。これは YAML 相手の YPath でも多分同じ、かな、おそらく。

てわけで、そのクエリの構文を自作して実現するとしたらどんなだろうか、とずっと考えてた、と。これはもうだいたい(クエリを評価して結果を返す実装も込みで)頭の中だけで固まった。けど今回は雑に紹介するに留める。こんな感じにしたい:

1 dquery(d, '**/[`"akey"`]/[`1:-1`]/`"xyz" in k.lower()`/..')

スラッシュが XPath のそれと同じなのはいいだろう。「..」は親ノード。「**」は任意の深さ潜る。「[]」は辞書・リストの「[]」そのもの(リストならスライスも出来るとして)、「``」は「[]」内にあるならそのまま「[]」内に展開し、そうでないものは「filter(lambda k, v: {}, node.items())」の「{}」部分に埋めて評価する。そんな感じ。

これの実現もだいたい頭の中では出来上がり、「あとは書くだけ」と思ってる。難解な実現になりそうだなぁと色々考えてたら案外そうでもなさそうだと。まぁ詳細は出来てから言うわ。やれば丸一日かかっちゃうかもしれんし、あっという間に終わるかもしれん、思わぬことが潜んでないとも限らんし、アタシはそそっかしいのでミスが多くてな。あと書く段になって、頭の中だけで書いたコードを忘れてるなんてのもありがちだし。

ちぅわけで「オレの dpath」は今のところ絵に描いた餅なんだけれど。

それ以前に、「ないの?」な話。これがまぁなんつーか、「探しにくい」のな。少なくとも PyPI から探そうとしても、検索キーワードが思いつかないがために「それっぽいのが大量にヒットし、どれも期待のものとは違う」わけね。

はっきりと同じ動機に基くものを一つだけ見つけた。keypath。誰でも思いつくレベルの、まぁこういっちゃ失礼だが「しょーもない」ヤツ:

 1 def value_at_keypath(obj, keypath):
 2   """
 3   Returns value at given key path which follows dotted-path notation.
 4 
 5     >>> x = dict(a=1, b=2, c=dict(d=3, e=4, f=[2,dict(x='foo', y='bar'),5]))
 6     >>> assert value_at_keypath(x, 'a') == 1
 7     >>> assert value_at_keypath(x, 'b') == 2
 8     >>> assert value_at_keypath(x, 'c.d') == 3
 9     >>> assert value_at_keypath(x, 'c.e') == 4
10     >>> assert value_at_keypath(x, 'c.f.0') == 2
11     >>> assert value_at_keypath(x, 'c.f.-1') == 5
12     >>> assert value_at_keypath(x, 'c.f.1.y') == 'bar'
13 
14   """
15   for part in keypath.split('.'):
16     if isinstance(obj, dict):
17       obj = obj.get(part, {})
18     elif type(obj) in [tuple, list]:
19       obj = obj[int(part)]
20     else:
21       obj = getattr(obj, part, {})
22   return obj
23 
24 
25 def set_value_at_keypath(obj, keypath, val):
26   """
27   Sets value at given key path which follows dotted-path notation.
28 
29   Each part of the keypath must already exist in the target value
30   along the path.
31 
32     >>> x = dict(a=1, b=2, c=dict(d=3, e=4, f=[2,dict(x='foo', y='bar'),5]))
33     >>> assert set_value_at_keypath(x, 'a', 2)
34     >>> assert value_at_keypath(x, 'a') == 2
35     >>> assert set_value_at_keypath(x, 'c.f.-1', 6)
36     >>> assert value_at_keypath(x, 'c.f.-1') == 6
37   """
38   parts = keypath.split('.')
39   for part in parts[:-1]:
40     if isinstance(obj, dict):
41       obj = obj[part]
42     elif type(obj) in [tuple, list]:
43       obj = obj[int(part)]
44     else:
45       obj = getattr(obj, part)
46   last_part = parts[-1]
47   if isinstance(obj, dict):
48     obj[last_part] = val
49   elif type(obj) in [tuple, list]:
50     obj[int(last_part)] = val
51   else:
52     setattr(obj, last_part, val)
53   return True

気持ちはわかる、し、実際これだけでも「嬉しいと思うケースがないではない」。木が深くなるほど「悪くない」。

けれどこういったものが欲しくなるのはこういう完全一致検索ではなく「複数結果が考えられる検索」の方。自力で木を再帰的にトラバースしない限り得られないような検索を「一言で」表現したいのだ。だってそもそもこういうことじゃないか:

1 x["c"]["f"][-1]

つまり完全一致なら元からそんなもんはいらないのである。すなわち、「文字列を評価してダイナミックに」ということでない限りは、keypath はかえってストレスが増すばかりでちっとも嬉しくないのであった。

そんなわけで、「いいもんありそうな気はするけれど、探すの大変過ぎるし」てことだし、「せっかく Lark 見つけたので」ちぅわけで。「オレの dpath」、いつお披露目するかは無論未定だけど、記事の内容はあくまでも「Lark の実践的な例」というノリにするつもり。