Python: S式パーサライブラリを作りました

前のエントリー で簡易S式パーサを re.Scanner で作ったのですが、まぁ個人的にまとめておいたほうが後々使えるだろう、ということでライブラリにまとめました。ダンパもついているので、S式の読み込みの他、PythonオブジェクトをS式で出力することができます。

実装には引き続き re.Scanner を活用しています。おかげで短い行数でキレイにかけているのではないかと。

ダウンロード

simplesexp.py

ソースはこんな感じ(テストのぞく)。

import re, sys
from unicodedata import east_asian_width

try:
  from re import Scanner
except ImportError:
  from sre import Scanner

class ParseError(StandardError): pass

class Ident(unicode):
  def __repr__(self):
    return "Ident(%s)"%unicode.__repr__(self)

class Symbol(unicode):
  def __repr__(self):
    return "Symbol(%s)"%unicode.__repr__(self)

class Pair(list): 
  def __repr__(self):
    return "Pair(%s)"%list.__repr__(self)

class Token(object):
  def __init__(self, value, pos):
    self.value = value
    self.pos   = pos
  def __repr__(self):
    return repr(self.value)

class Binding(object):
  def __init__(self, dct):
    self.dct = dict(((k, k.__class__), v) for k,v in dct.iteritems())
  __contains__ = lambda self, key: (key, key.__class__) in self.dct
  __getitem__  = lambda self,key:  self.dct[(key, key.__class__)]

default_binding = {"#t":True, "true":True, "#f":False, "false":False, "nil":None, "dict":Ident(u'alist->hash-table')}

class Reader(object):
  PAREN = {"]":"[", ")":"("}
  def __init__(self, binding=None, symbol_marker="'", use_dict=True):
    self.binding = binding or default_binding
    self.symbol_marker = symbol_marker
    self.use_dict = use_dict

  def read(self, value):
    self.result = []
    self.paren_stack = []
    self.source = value
    self.pos = 0
    self.scanner = Scanner([
      (r"\s+", self("skip")),
      (r";[^\n]*\n", self("skip")),
      (r""""(((?<=\\)")|[^"])*((?<!\\)")""", self("str")),
      (r"(\(|\[)", self("open")),
      (r"(\)|\])", self("close")),
      (r"(([\d]+|(((\d+)?\.[\d]+)|([\d]+\.)))e[\+\-]?[\d]+)|(((\d+)?\.[\d]+)|([\d]+\.))", self("number")),
      (r"\-?((0x[\da-f]+)|(0[0-7]+)|([1-9][\d]*)|0)[l]?", self("number")),
      (r"""%s([^\(\[\)\]\s"]+)"""%self.symbol_marker, self("symbol")),
      (r"""([^\(\[\)\]\s"]+)""", self("ident")),
      (r"""".*""", self("unterm_str")),
      (r".*", self("unknown_token"))
    ], re.M|re.S|re.I)
    self.scanner.scan(self.source)
    if self.paren_stack:
      self.raise_error("missing closing parenthesis.")
    return self.parse(self.result)

  def append(self, v):
    self.last().append(Token(v, self.pos))

  def __call__(self, name):
    def _(scanner, s):
      self.pos += len(s)
      return getattr(self, name)(s)
    return _

  def unknown_token(self,s): self.raise_error("unknown token: %s"%s)
  def skip(self, _): pass
  def open(self, s):
      new_lst = []
      self.last().append(new_lst)
      self.paren_stack.append([s, new_lst])
  def close(self, s):
      if not self.paren_stack:
        self.raise_error("missing opening parenthesis.")
      if self.PAREN[s] != self.paren_stack.pop()[0]:
        self.raise_error("missing closing parenthesis.")
  def str(self, s): self.append(eval('u""'+s+'""'))
  def unterm_str(self, s): self.raise_error("unterminated string literal.")
  def number(self, s): self.append(eval(s))
  def symbol(self, s): self.append(Symbol(s[1:]))
  def ident(self, s): 
    if s in self.binding:
      self.append(self.binding[s])
    else:
      self.append(Ident(s))

  def last(self):
    if self.paren_stack:
      return self.paren_stack[-1][1]
    else:
      return self.result

  def parse(self, rs):
    def is_ident(value, expected):
      return getattr(value,"value", None) == Ident(expected)
    def is_pair(rs):
      return getattr(rs, "__len__", lambda :0)()==3 and is_ident(rs[1], u".")

    if isinstance(rs, list):
      if not len(rs):
        return []
      elif self.use_dict and is_ident(rs[0], u"alist->hash-table"):
        if len(rs) != 2:
          self.raise_error("alist->hash-table: expected 1 arguments, got %d."%(len(rs)-1), rs[0].pos)
        if not all(is_pair(a) for a in rs[1]):
          self.raise_error("alist->hash-table: aruguments must be alist", rs[0].pos)
        return dict((self.parse(i[0]), self.parse(i[2])) for i in rs[1])
      elif len(rs)!=3 and any(is_ident(t, u".") for t in rs):
        self.raise_error('illegal use of "."', rs[0].pos)
      elif is_pair(rs):
        parsed = self.parse(rs[2])
        if not isinstance(rs[2], list):
          return Pair([rs[0].value, parsed])
        if isinstance(parsed, Pair):
          return Pair([rs[0].value, parsed])
        elif isinstance(parsed, list):
          return [rs[0].value]+parsed
        else:
          return [rs[0].value, parsed]
      else:
        return map(self.parse, rs)
    else:
      return rs.value

  def raise_error(self, msg="parse error", pos=None, range=3):
    pos = pos or self.pos
    lines = self.source.split("\n")
    curline = self.source[:pos].count("\n")
    linepos = pos - len("\n".join(lines[:curline]))
    buf = ["\n"]
    for i in xrange(max(0, curline-range), curline+1):
      buf.append("% 5d: %s"%(i+1, lines[i]))
    width = 7 + sum(east_asian_width(c) == 'W' and 2 or 1 for c in unicode(lines[i]))
    buf.append("%s~"%(" "*width))
    buf.append("line %d, %d: %s"%(curline+1,linepos, msg))
    raise ParseError(("\n".join(buf)).encode(sys.stderr.encoding))

class Dumper(object):
  def __init__(self, binding=None ,symbol_marker="'"):
    binding = binding or default_binding
    self.binding = Binding(dict(zip(binding.values(), binding)))
    self.symbol_marker = symbol_marker

  def dump(self, obj):
    result = self.to_sexp(obj, [])
    if isinstance(result, list) and len(result) and result[0]=="(":
      result = result[1:-1]
    return u" ".join(result)

  def to_sexp(self, obj, result):
    ap = result.append
    tos = lambda v: self.to_sexp(v, result)
    if isinstance(obj, Pair):
      ap("(")
      tos(obj[0])
      ap(" . ")
      tos(obj[1])
      ap(")")
    elif isinstance(obj, (tuple, list)):
      ap("(")
      map(tos, obj)
      ap(")")
    else:
      if isinstance(obj, dict):
        ap("( alist->hash-table ")
        tos([(k, Ident(u"."), v) for k,v in obj.items()])
        ap(" ) ")
      elif obj in self.binding:
        ap(unicode(Ident(self.binding[obj])))
      elif isinstance(obj, Symbol):
        ap(u"'%s"%unicode(obj))
      elif isinstance(obj, (Ident,int, float, long)):
        ap(unicode(obj))
      else:
        s = unicode(repr(obj)).decode("unicode_escape")
        m = re.match(r"""^[u|r]?["|'](.*)["|']$""", s, re.M|re.S)
        if m:
          s = m.group(1)
        ap("\"%s\""%s.replace('"','\\"').replace("\\'","'"))
    return result

dumper = Dumper()
read = Reader().read
dump = dumper.dump

概要

特徴は

  • 辞書を定義できる
  • ドット対に対応
  • 識別子に対して、任意のバインディングを指定できる
  • わりとちゃんとエラー表示される
  • シンボル表記、数値表記(python表記)に対応

といった当たりでしょうか。具体的にはテストコードを見てもらうと分かるかと。

(あああ hoge->fuga123 (1 . (2 . 3)) "hoge\\"hoge" ;comment2 
foo "aaa" #t <= 'foo 
"hogehoge
foo
" (5 . (6 .()))
)
(dict (
  ("いいい" .
    (alist->hash-table (
      ("a-1" . "vvv")
      ("a-2" . (
        hoge foo bar 
      ))
    )))
))
(10 1L -45 010 0x10 -10 -0x10 3.14 10. .001 1e100 3.14e-10 0e0)
; comment3 ()(

""")

という感じのS式が

[
  [Ident(u'あああ'), Ident(u'hoge->fuga123'), Pair([1, Pair([2, 3])]), u'hoge"hoge',
  Ident(u'foo'), u'aaa', True, Ident(u'<='), Symbol(u'foo'),
  u'hogehoge\nfoo\n', [5,6]],
  {u'いいい': 
    {u'a-1': u'vvv', 
    u'a-2': [Ident(u'hoge'), Ident(u'foo'), Ident(u'bar')]}},
  [10, 1L, -45, 010, 0x10, -10, -0x10, 3.14, 10., .001, 1e100, 3.14e-10, 0e0]
]

となります。 IdentSymbol はunicodeのサブクラス、 Pair はリストのサブクラスになっているので、違和感なく使えると思います。

また、 alist->hash-table で辞書が作れます。デフォルトで dictalist->hash-table をバインドしてますので、 dict でも辞書が作れます。この機能はオンオフ切り替えも可能です。

その他、 #tTrue などSchemeっぽくデフォルトバインディングが用意してあります。もちろん、バインディングは変更可能ですのでCLっぽくもできます。


と、こんな感じです。一番便利なのはやっぱり辞書ですかねえ。なので、YAML,JSONで書いてる設定ファイルをS式で置き換え・・・なんてことができるかもしれません。

comments powered by Disqus