yapps (Yet Another Python Parser System) のへろわるど

lrparsing と同じく、すぐに使おうと思ってるとかではなくて。

Python Parsing Tools で紹介されてるものたちって、歴史が長いものが多いもんで、訪れてみるとドキュメントが読みずらい、というだけで敬遠してしまいたくなるものが多いんだよね。 yapps もまさにソレ。13 年前には既に誕生してたことがわかる。Python 2.3 なんてのがみえた。

てわけで yappsPython Parsing Tools での紹介文は:

Produces recursive-descent parsers, as a human would write. Designed to be easy to use rather than powerful or fast. Better suited for small parsing tasks like email addresses, simple configuration scripts, etc.

たまに英語を読めないバカがこの文章を「わかった気になって」、「速度をウリにしてます!!!」なんて言うこともあるかもしれん(別のプロジェクトでの実話)が無論真逆である。「速度や完全性度外視で安直に使えまっせ」てことを主張している。

なんにしても4年前の更新が最後、しかもその更新は、ちまっと python3 対応を施しただけ、というものなので、「活発じゃない」ことだけははっきりしているけれど、これを「枯れてるってことかもね」と期待してみることが悪くなかろう、と思うので、入り口だけでも遊んでみようかと。

これまでワタシが試してきたものは全て LR 系(LALR, LR(1))だったが、yapps は LL(1)。また、yapps の「パーサジェネレータ」は、ply, rply, pyparsing, lrparsing 等が「パーサオブジェクト」を生成したのとは違って、(yapps.runtime 依存の) python スクリプトを生成する。…ということが やたらに読みずらい公式ドキュメント から30分くらいガン見しててようやくわかった。

つまりこの「Python スクリプトを静的に生成するジェネレータ」というアプローチもええな、と思ってな。

えれぇ読みにくかったよ。上述の通りで、「yapps を使った開発」はこういう流れになる:

  1. .g ファイルを書く
  2. yapps に作った .g ファイルをかますと「パーサスクリプト(モジュール)」が生成される
  3. 作られたパーサスクリプト(モジュール)を使って喜ぶ

.g ファイルはアタシが今使ってる emacs で「Antlr.Java/l」だと認識された。やたらに読みずらい公式ドキュメント で真っ先に見つかる(すぐに使える完全な)例がこれ:

(Pygments も ANTLR を知っている)
 1 parser Lisp:
 2     ignore:      '\\s+'
 3     token NUM:   '[0-9]+'
 4     token ID:    '[-+*/!@%^&=.a-zA-Z0-9_]+' 
 5     token STR:   '"([^\\"]+|\\\\.)*"'
 6 
 7     rule expr:   ID     {{ return ('id', ID) }}
 8                | STR    {{ return ('str', eval(STR)) }}
 9                | NUM    {{ return ('num', atoi(NUM)) }}
10                | list   {{ return list }}
11     rule list: "\\("    {{ result = [] }} 
12                ( expr   {{ result.append(expr) }}
13                )*  
14                "\\)"    {{ return result }}

atoi? と当然のごとく引っかかる。うーん、{{ ~ }} 部分はアクションで python コードを書いてるように見えるのだがなぁ、ライブラリ関数が提供されてんの? と疑問を感じながら、「2. yapps に作った .g ファイルをかま」してみる:

上の定義を lisp.g として保存したとして
1 [me@host: ~]$ # 1. yapps は実行スクリプトを(yapps2 の名前で)インストールしてくれる
2 [me@host: ~]$ # 2. Windows の MSYS から起動している
3 [me@host: ~]$ yapps2.exe lisp.g
4 [me@host: ~]$ # 3. 無論モジュールもインストールされるので以下でもいい:
5 [me@host: ~]$ python
6 ...
7 >>> import yapps
8 >>> yapps.generate("lisp.g")

これにより lisp.py が生成される:

lisp.py
 1 # Begin -- grammar generated by Yapps
 2 from __future__ import print_function
 3 import sys, re
 4 from yapps import runtime
 5 
 6 class LispScanner(runtime.Scanner):
 7     patterns = [
 8         ('"\\\\)"', re.compile('\\)')),
 9         ('"\\\\("', re.compile('\\(')),
10         ('\\s+', re.compile('\\s+')),
11         ('NUM', re.compile('[0-9]+')),
12         ('ID', re.compile('[-+*/!@%^&=.a-zA-Z0-9_]+')),
13         ('STR', re.compile('"([^\\"]+|\\\\.)*"')),
14     ]
15     def __init__(self, str,*args,**kw):
16         runtime.Scanner.__init__(self,None,{'\\s+':None,},str,*args,**kw)
17 
18 class Lisp(runtime.Parser):
19     Context = runtime.Context
20     def expr(self, _parent=None):
21         _context = self.Context(_parent, self._scanner, 'expr', [])
22         _token = self._peek('ID', 'STR', 'NUM', '"\\\\("', context=_context)
23         if _token == 'ID':
24             ID = self._scan('ID', context=_context)
25             return ('id', ID)
26         elif _token == 'STR':
27             STR = self._scan('STR', context=_context)
28             return ('str', eval(STR))
29         elif _token == 'NUM':
30             NUM = self._scan('NUM', context=_context)
31             return ('num', atoi(NUM))
32         else: # == '"\\\\("'
33             list = self.list(_context)
34             return list
35 
36     def list(self, _parent=None):
37         _context = self.Context(_parent, self._scanner, 'list', [])
38         self._scan('"\\\\("', context=_context)
39         result = []
40         while self._peek( context=_context) != '"\\\\)"':
41             expr = self.expr(_context)
42             result.append(expr)
43         self._scan('"\\\\)"', context=_context)
44         return result
45 
46 
47 def parse(rule, text):
48     P = Lisp(LispScanner(text))
49     return runtime.wrap_error_reporter(P, rule)
50 
51 if __name__ == '__main__':
52     from sys import argv, stdin
53     if len(argv) >= 2:
54         if len(argv) >= 3:
55             f = open(argv[2],'r')
56         else:
57             f = stdin
58         print(parse(argv[1], f.read()))
59     else: print ('Args:  <rule> [<filename>]', file=sys.stderr)
60 # End -- grammar generated by Yapps

うん、動くはずない。うーん、Python 2.3 とかには atoi なんてあったっけか? 使ってはいたけどさすがに昔過ぎて記憶にない。無論 int に直せばいいだけだが、こういう静的ジェネレータの場合、「生成後の Python コード」の方を直すのもアリなのはまぁありがたいことかもね。一応「生成後の Python コードも保守可能(可読性が高い)」ことがウリなんですと。おっさる通りね。

ともあれ atoi 問題を解決して…、「うん、if __name__ == '__main__': でのメインスクリプトとしても動作させられるのね」:

1 [me@host: ~]$ py -3 lisp.py
2 Args:  <rule> [<filename>]

うーん、rule て何渡せばいいの、ともう一度「lisp.g」をガン見して…。あ、こういうことか:

1 [me@host: ~]$ echo 1 | py -3 lisp.py exor
2 ('num', 1)
3 [me@host: ~]$ echo '(* (+ 1 3) (- 10 5))' | py -3 lisp.py expr
4 [('id', '*'), [('id', '+'), ('num', 1), ('num', 3)], [('id', '-'), ('num', 10), ('num', 5)]]

なるほど。今の例の場合は lisp.g には rule expr:rule list: が定義されてるのでメインスクリプトに渡すのは exprlist

ドキュメント以外はいい感じね。なお上の atoi 問題だが、伝統的な Unix Lex/Yacc と同じノリでこんな風に .g ファイルを書けるらしい:

https://github.com/smurfix/yapps/blob/master/examples/calc.g
 1 globalvars = {}       # We will store the calculator's variables here
 2 
 3 def lookup(map, name):
 4     for x,v in map:  
 5         if x == name: return v
 6     if not globalvars.has_key(name): print 'Undefined (defaulting to 0):', name
 7     return globalvars.get(name, 0)
 8 
 9 def stack_input(scanner,ign):
10     """Grab more input"""
11     scanner.stack_input(raw_input(">?> "))
12 
13 %%
14 parser Calculator:
15     ignore:    "[ \r\t\n]+"
16     ignore:    "[?]"         {{ stack_input }}
17 
18     token END: "$"
19     token NUM: "[0-9]+"
20     token VAR: "[a-zA-Z_]+"
21 
22     # Each line can either be an expression or an assignment statement
23     rule goal:   expr<<[]>> END            {{ print '=', expr }}
24                                            {{ return expr }}
25                | "set" VAR expr<<[]>> END  {{ globalvars[VAR] = expr }}
26                                            {{ print VAR, '=', expr }}
27                                            {{ return expr }}
28 
29     # An expression is the sum and difference of factors
30     rule expr<<V>>:   factor<<V>>         {{ n = factor }}
31                      ( "[+]" factor<<V>>  {{ n = n+factor }}
32                      |  "-"  factor<<V>>  {{ n = n-factor }}
33                      )*                   {{ return n }}
34 
35     # A factor is the product and division of terms
36     rule factor<<V>>: term<<V>>           {{ v = term }}
37                      ( "[*]" term<<V>>    {{ v = v*term }}
38                      |  "/"  term<<V>>    {{ v = v/term }}
39                      )*                   {{ return v }}
40 
41     # A term is a number, variable, or an expression surrounded by parentheses
42     rule term<<V>>:   
43                  NUM                      {{ return int(NUM) }}
44                | VAR                      {{ return lookup(V, VAR) }}
45                | "\\(" expr "\\)"         {{ return expr }}
46                | "let" VAR "=" expr<<V>>  {{ V = [(VAR, expr)] + V }}
47                  "in" expr<<V>>           {{ return expr }}
48 %%
49 if __name__=='__main__':
50     print 'Welcome to the calculator sample for Yapps 2.'
51     print '  Enter either "<expression>" or "set <var> <expression>",'
52     print '  or just press return to exit.  An expression can have'
53     print '  local variables:  let x = expr in expr'
54     # We could have put this loop into the parser, by making the
55     # `goal' rule use (expr | set var expr)*, but by putting the
56     # loop into Python code, we can make it interactive (i.e., enter
57     # one expression, get the result, enter another expression, etc.)
58     while 1:
59         try: s = raw_input('>>> ')
60         except EOFError: break
61         if not s.strip(): break
62         parse('goal', s)
63     print 'Bye.'

つまりさっきの例だとプリアンブル部分に atoi を定義しておく、みたいなことは、「本来なら」出来る、「ことになっている」。ただ実際やってみると「Python 3 対応」でやらかしてしまっている。(苦労しないと)まともに生成出来ない。from __future__ import print_function の挿入位置がダメなのだ。from __future__ import ... は必ず最初になければならないのに。ポストアンブル部分に書けば from __future__ import ... 問題はないのであるが今度は自動生成の if __name__=='__main__': は消える。まぁこれは lex/yacc と同じノリだね:

atoi を是が非でも使おうとする lisp.g 修正版
 1 #
 2 
 3 %%
 4 parser Lisp:
 5     ignore:      '\\s+'
 6     token NUM:   '[0-9]+'
 7     token ID:    '[-+*/!@%^&=.a-zA-Z0-9_]+' 
 8     token STR:   '"([^\\"]+|\\\\.)*"'
 9 
10     rule expr:   ID     {{ return ('id', ID) }}
11                | STR    {{ return ('str', eval(STR)) }}
12                | NUM    {{ return ('num', atoi(NUM)) }}
13                | list   {{ return list }}
14     rule list: "\\("    {{ result = [] }} 
15                ( expr   {{ result.append(expr) }}
16                )*  
17                "\\)"    {{ return result }}
18 %%
19 def atoi(s):
20     return int(s)
21 
22 if __name__ == '__main__':
23     from sys import argv, stdin
24     if len(argv) >= 2:
25         if len(argv) >= 3:
26             f = open(argv[2],'r')
27         else:
28             f = stdin
29         print(parse(argv[1], f.read()))
30     else: print ('Args:  <rule> [<filename>]', file=sys.stderr)

微妙にいやらしいのが、

  • 書くならプリアンブルとポストアンブルは両方必要
  • プリアンブルがいらなくても空行が必須

てことだが、まぁなんつーか、こんなんではプリアンブルは全然実用にならん。

てわけで軽い評価。

まぁなんつーか、こうまで騙しながら無理やり使うほど抜群に「ええもんだ」てもんではないものの、「オレ様だけに必要な日常タスク」の足しに使うには結構気軽に使えて確かにいいような気がした。