pyparsingで独自スクリプトを読み込んで、構文にあわせた処理をする


# pyparsingで独自スクリプトを読み込んで、構文にあわせた処理をする

この記事はMac OS X High SierraにインストールしたPython 2.7とpyparsing 2.2.0を使っています。

## 概要

例題として、2種類の命令`variable`と`dump`からなる独自言語(DSLとかミニ言語とかスクリプトとか)を考えます。それぞれ次のような構文だとします。

`variable name style args`

`dump id group-id style timestep file args`

この`variable`と`dump`は命令コマンドで、その後に続く引数はそれぞれあるルールをもつ(例えばnameは文字、styleはあらかじめ定義されたキーワードのどれか、idとtimestepは数値、argsは1つ以上の配列)とします。

このルールに従って記述された独自言語コードを読み込んで、構文が正しいかチェックするとともに、もし正しい構文が読み込まれた場合、それぞれの命令コマンド`variable`と`dump`に対して、別々の処理を行うパーサを作ります。

pyparsingを使います。ifは使わないでやります。複数の命令についてif分岐をしないで処理するための考え方は
、http://www.ptmcg.com/geo/python/confs/pyCon2006_pres2.html に書いてあります。

## コードと解説

コードと出力結果と説明を順番にしていきます。

#### pythonコード

```python:pyparsing_action.py
import pyparsing as pp
import sys
import io
from termcolor import colored, cprint

data = '''\
variable INT equal 100
variable DEC equal 10.0
variable EXP equal 100e5
variable ARY index 1 2 3
dump 1 group atom 1000 output arg1 arg2 arg3
variable ERR equal hoge
'''

class Command(object):
	# common parser elements
	number = pp.Regex(r"[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?")

	def __init__(self, *args):
		self._action(*args)

	def _action(self, *args):
		cprint ('Do actions in the superclass of {1} with args: {0}'.format(str(args), type(self).__name__), 'green', attrs=['bold'])

	@staticmethod
	def syntax():
		pass

class VariableCommand(Command):
	def __init__(self, quals):
		super(VariableCommand,self).__init__(quals[1], quals[2], quals[3:])

	def _action(self, name, style, args):
		super(VariableCommand,self)._action(name, style, args)
		cprint ('Do actions in {1} with args: {0}'.format(str(args), type(self).__name__), 'green')
		return

	@staticmethod
	def syntax():
		# parsers for command matching
		cmd = pp.Keyword("variable").setResultsName("cmd")
		name = pp.Word(pp.printables).setResultsName("name")
		style = pp.oneOf("equal index").setResultsName("style")
		args = pp.OneOrMore(VariableCommand.number).setResultsName("args")
		return (cmd + name + style + args)


class DumpCommand(Command):
	def __init__(self, quals):
		super(DumpCommand,self).__init__(quals[1], quals[2], quals[3], quals[4], quals[5], quals[6:])

	def _action(self, id, groupid, style, timestep, file, args):
		super(DumpCommand,self)._action(args)
		cprint ('Do actions in {1} with args: {0}'.format(str(args), type(self).__name__), 'green')
		return

	@staticmethod
	def syntax():
		# parsers for command matching
		cmd = pp.Keyword("dump").setResultsName("cmd")
		id = (DumpCommand.number).setResultsName("ID")
		groupid = pp.Word(pp.printables).setResultsName("group-ID")
		style = pp.oneOf("atom atom/vtk image local custom mesh/stl mesh/vtk").setResultsName("style")
		timestep = (DumpCommand.number).setResultsName("timestep")
		file = pp.Word(pp.printables).setResultsName("file")
		args = pp.OneOrMore(pp.Word(pp.printables)).setResultsName("args")
		return (cmd + id + groupid + style + timestep + file + args)

def makeCommandParseAction(cls):
	def cmdParseAction(original_string, location, tokens):
		cls(tokens)
		return tokens
		#$return cls(t)
	return cmdParseAction

def setCommandSyntax():
	variableCommand = VariableCommand.syntax()
	variableCommand.setParseAction(makeCommandParseAction(VariableCommand))
	dumpCommand = DumpCommand.syntax()
	dumpCommand.setParseAction(makeCommandParseAction(DumpCommand))
	return (dumpCommand | variableCommand)

'''
main
'''

syntax = setCommandSyntax()

for i, line in enumerate(data.splitlines(True)):
	line_no_comment = line.partition('#')[0].rstrip()
	print "Parsing line " + str(i) + ": " + repr(line_no_comment)
	try:
		result = syntax.parseString(line_no_comment)
		cprint(result.items(),'cyan')
	except pp.ParseException as e:
		cprint("matching failed", 'red')
		#cprint("no match with error: " + str(e), 'red')

sys.exit()
```

#### 出力結果

```
Parsing line 0: 'variable INT equal 100'
Do actions in the superclass of VariableCommand with args: ('INT', 'equal', ['100'])
Do actions in VariableCommand with args: ['100']
[('style', 'equal'), ('cmd', 'variable'), ('name', 'INT'), ('args', (['100'], {}))]
Parsing line 1: 'variable DEC equal 10.0'
Do actions in the superclass of VariableCommand with args: ('DEC', 'equal', ['10.0'])
Do actions in VariableCommand with args: ['10.0']
[('style', 'equal'), ('cmd', 'variable'), ('name', 'DEC'), ('args', (['10.0'], {}))]
Parsing line 2: 'variable EXP equal 100e5'
Do actions in the superclass of VariableCommand with args: ('EXP', 'equal', ['100e5'])
Do actions in VariableCommand with args: ['100e5']
[('style', 'equal'), ('cmd', 'variable'), ('name', 'EXP'), ('args', (['100e5'], {}))]
Parsing line 3: 'variable ARY index 1 2 3'
Do actions in the superclass of VariableCommand with args: ('ARY', 'index', ['1', '2', '3'])
Do actions in VariableCommand with args: ['1', '2', '3']
[('style', 'index'), ('cmd', 'variable'), ('name', 'ARY'), ('args', (['1', '2', '3'], {}))]
Parsing line 4: 'dump 1 group atom 1000 output arg1 arg2 arg3'
Do actions in the superclass of DumpCommand with args: (['arg1', 'arg2', 'arg3'],)
Do actions in DumpCommand with args: ['arg1', 'arg2', 'arg3']
[('style', 'atom'), ('args', (['arg1', 'arg2', 'arg3'], {})), ('group-ID', 'group'), ('cmd', 'dump'), ('N', '1000'), ('file', 'output'), ('ID', '1')]
Parsing line 5: 'variable ERR equal hoge'
matching failed
```

#### 解説

まず、`Command`クラスを親にもつ`VariableCommand`クラスと`DumpCommand`クラスを作ります。この中に構文の定義を返す`syntax()`とコードと、マッチした時に実行させる`_action()`というメソッドを作っておきます。

関数`setCommandSyntax()`のなかの

`variableCommand = VariableCommand.syntax()`

で`syntax()`をクラスメソッドとして呼び出して、構文解析器であるpyparsingオブジェクトを作ります。この構文にマッチした時に実行したいことはpyparsingの`setParseAction`を使って決めていきます。このソースでは、

```
variableCommand.setParseAction(makeCommandParseAction(VariableCommand))
```

としています。つまり、`variableCommand=VariableCommand.syntax()`の構文にマッチした場合、`makeCommandParseAction(VariableCommand))`を実行せよ、ということです。`setParseAction`のmanualを読むと

```
Parse action fn is a callable method with 0-3 arguments, called as C{fn(s,loc,toks)},
        C{fn(loc,toks)}, C{fn(toks)}, or just C{fn()}, where:
         - s   = the original string being parsed (see note below)
         - loc = the location of the matching substring
         - toks = a list of the matched tokens, packaged as a C{L{ParseResults}} object
```

と書いてあるように、3つ以下の引数？を処理の中で渡してあげることができます。
`makeCommandParseAction`にまず`VariableCommand`が渡されると、ネストされたメソッドである`cmdParseAction`が返されます。`cmdParseAction`は3つの引数をとるメソッドになっているので、`setParseAction`の`s`, `loc`, `toks`がそれぞれ渡されます。`cmdParseAction`では、まず`cls`に入った`VariableCommand`が`tokens`で初期化されますので、`__init__`にある`_action()`が実行されます。

つまり、`VariableCommand.syntax()`の構文にマッチしたときに実行しておきたいことを`_action()`のなかに書いておけばよい、ということになります。最後は`tokens`を返すようにします。

以上のことは、もう一つのコマンド`dump`についても同じです。

こんな感じで構文の定義とマッチした時の実行内容を`setCommandSyntax()`にまとめて書いていき、それぞれの構文定義をandしたものを戻すようにしておきます。

` return (dumpCommand | variableCommand)`

で、mainのところ

`syntax = setCommandSyntax()`

として、構文定義を`syntax`という変数に放り込んでおきます。

そのあと、ソースコードを一行ずつ読み込んだ変数`line_no_comment`に対してpyparsingの`parseString`メソッドを実行すると、構文が解析させるとともに、もしマッチした時は先ほどの`setParseAction`が実行されることになります。

`result = syntax.parseString(line_no_comment)`

もし、例えば`compute`という新しい命令も解析したくなったら、
- `Command`の子クラスとして`computeCommand`を作る。
- `computeCommand.syntax()`に構文定義をする。
- `computeCommand._action()`に実行したいことを書いとく。
- `setCommandSyntax()`に付け足す。

をすればよくて、main側は何も変更する必要がありません。

## おまけ　

pyparsingでは、`Token`を組み合わせて構文を作っていく。基本的な`Token`は、決まり文句を表す`Literal`と、あるルール(アルファベット`alphas`とか数字`nums`とかエスケープを除くASCII文字`printables`とか便利なやつがいくつか用意されている)に従う`Word`、正規表現にマッチする字句`Regex`の3種類。他は、


| Token             |      使い方                      |
|:------------------|:-------------------------------|
| Literal        | 決まり文句　　　　　　　　　　　     |
| Empty          | 何にでもマッチする                 |
| Keyword        | 完全に一致する決まり文句            |
| CharsNotIn     | ここで指定した文句を取り除いた字句群  |
| CloseMatch     | n個のミスマッチを許容するLiteral    | 
| NoMatch        | 絶対マッチしない                  |
| White          | pyparsingでは普通空白を無視するが、これを使うと、ある特定の位置に現れる空白にマッチ                  |
| Regex          | 正規表現                  |
| Word           | あるルールに従う文句       |

これらの組み合わせていくために
- `MatchFirst` 和なんだけど順序つき
- `OneOf` Literalの和
- `OneOrMore` 1回以上の繰り返し
- などなど

が用意されている。

```python:pyparsing_example1.py
cmd = pp.Literal("def")
some_word = pp.Word(pp.printables)
number = pp.Regex(r"[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?").setParseAction(lambda t: float(t[0]))
```

## 参考
http://www.ptmcg.com/geo/python/confs/pyCon2006_pres2.html
http://infohost.nmt.edu/~shipman/soft/pyparsing/web/index.html