Rubyの StringScanner は個人的にかなり好きなモジュールで、Rubyでちょっとしたパーサなどを書くときに重宝しています。

一方、Pythonにはexperimentalながら re.Scanner というクラスがあります( >= 2.4 )。experimentalなのでマニュアルにはのっていませんが。この re.Scanner はかなりシンプルなんですが典型的な StringScanner の使い方の範疇では、こちらのほうがキレイに書けるような気がします。

re.Scanner の使い方

使い方は非常に簡単で

  • (regex, action) のリストを渡してScannerオブジェクトを作成

    • action は(scanner, string_matched) => stringな関数、Noneを返せば結果は無視される。
  • scanメソッドでスキャン。結果が配列で返ってくる

といった感じ。関数を渡すので、 StringScanner のようなwhileループを作る必要がなく、キレイにまとまります。

例:S式パーサ

re.Scanner は簡単、ということでS式パーサでも。トークナイズ+αな処理をするので、 action をインスタンスメソッドにして状態を保存することにします。

目標は

  • 数値(っぽいもの)、文字列、シンボルが使える
  • シンボルのみ、新たにクラスを定義して(unicodeのサブクラス)それにマップ。それ以外は組み込み型に。
  • パースエラーも分かりやすく
  • 結果はPythonのリストorオブジェクトとして返る

 1import re, sys
 2from unicodedata import east_asian_width
 3
 4try:
 5  from re import Scanner
 6except ImportError:
 7  from sre import Scanner
 8
 9class ParseError(StandardError): pass
10
11class Symbol(unicode):
12  def __repr__(self):
13    return "Symbol(%s)"%unicode.__repr__(self)
14
15class TokenProcessor(object):
16  PAREN = {"]":"[", ")":"("}
17  def __init__(self, value):
18    self.result = []
19    self.append = self.result.append
20    self.string = value
21    self.paren_stack = []
22    self.pos = 0
23
24  def __call__(self, name):
25    def _(*a):
26      self.before(*a)
27      return getattr(self, name)(*a)
28    return _
29
30  def before(self, scanner, s):
31    self.pos += len(s)
32    self.skip(scanner, s)
33
34  def error(self, scanner, s): self.raise_error("unknown token: %s"%s)
35
36  def skip_whitespaces(self, scanner, s): self.append(",")
37
38  def skip(self, scanner, s):
39    last = "".join(self.result[-2:])
40    if last in ["[,", ",,", ",]"]: 
41      self.result[-2:] = sorted(last, key=ord)[1]
42
43  def atom(self, scanner, s):
44    if s in ["(", "["]:
45      self.append("[")
46      self.paren_stack.append(s)
47    elif s in [")", "]"]:
48      if not self.paren_stack:
49        self.raise_error("missing opening parenthesis.")
50      if self.PAREN[s] != self.paren_stack.pop():
51        self.raise_error("missing closing parenthesis.")
52      self.append("]")
53    elif re.match(r"""^(".*)$""", s or ""):
54      self.append("u"+s)
55    elif re.match(r"""^((\-?\d[\de\.]+)|(\s*)|(.*"))$""", s or ""):
56      self.append(s)
57    else:
58      self.append("Symbol(u\"%s\")"%s)
59
60  def raise_error(self, msg="parse error", range=3):
61    lines = self.string.split("\n")
62    curline = self.string[:self.pos].count("\n")
63    linepos = self.pos - len("\n".join(lines[:curline]))
64    buf = ["\n"] 
65    for i in xrange(max(0, curline-range), curline+1):
66      buf.append("% 5d: %s"%(i+1, lines[i]))
67    width = 6 + sum(east_asian_width(c) == 'W' and 2 or 1 for c in lines[i])
68    buf.append("%s~"%(" "*width))
69    buf.append("line %d, %d: %s"%(curline+1,linepos, msg))
70    raise ParseError(("\n".join(buf)).encode(sys.stderr.encoding))
71
72def read_sexp(sexp):
73  processor = TokenProcessor(sexp)
74  scanner = Scanner([
75    (r"\s+", processor("skip_whitespaces")),
76    (r";[^\n]*\n", processor("skip")),
77    (r""""(?:[^"])*"|(\]|\[|\)|\(|[^\(\)\s]+)""", processor("atom")),
78    (r".*", processor("error"))
79  ], re.M)
80  scanner.scan(processor.string)
81  if processor.paren_stack:
82    processor.raise_error("missing closing parenthesis.")
83  result = eval("".join(processor.result).lstrip(","))
84  return (isinstance(result, tuple) and (result[0],0) or (result,0))[0]

こんな感じ。非常にシンプルな気がします。

1print read_sexp(u"""("ほげほげ"
2;comment
3  ;comment
4  (hogehoge 123) ;aaaaaaa
5  "hoge\\"aaaa"
6;comment
7;comment
8
9aaaa          b)""")

output:

1[u'\u307b\u3052\u307b\u3052', [Symbol(u'hogehoge'), 123], u'hoge"aaaa', Symbol(u'aaaa'), Symbol(u'b')]

エラーも一応。

1print read_sexp(u"""(
2aaaa
3bbbb (ccc ddd) )
4(eee 
5ああああああ""")

output:

1__main__.ParseError:
2
3    2: aaaa
4    3: bbbb (ccc ddd) )
5    4: (eee
6    5: ああああああ
7                  ~
8line 5, 7: missing closing parenthesis.

エラー表示もいい感じ。フォントにもよりますが(等幅なら大丈夫)、一応文字幅を考慮して ~ をエラー箇所に出すようにしています。HTML上だと日本語はずれちゃうかもだけど。

というわけで

Pythonでトークナイズするときにはかなり便利なんじゃないかと思いました。


前のエントリー でGAEにおけるトランザクションの問題は一応解決をみた。

その後、GAEで開発を続けた結果、やはりGAEにブログを移行するのは断念した。理由としては

  • os.listdir などで不審な挙動が見られた
  • スキーマを変更したときが非常に面倒
  • インポート、エクスポートの難しさ

が主なところ。

os.listdir については、web.pyでテンプレートを検索するときに os.listdir を使っているのだけど、どうも動きが怪しい。正しくファイルリストが帰ってきたり帰ってこなかったりするのだ。何回も本番にアップして試したところ、 os.listdir("hoge");os.listdir("hoge") というように同じ内容で2回連続で呼び出すとなぜか確実にファイルリストが帰ってくる、という・・・これはちょっと・・・

スキーマの変更に関しては、まんま。変更したとき、それを本番に反映させるのがめんどくさい。

インポートエクスポートも負荷を考えるとしんどい。現在もWEBはレンタルサーバで運用しているのでわざわざGAEにもっていく旨みもない。

ということでせっかくブログをつくったのに移行をやめたのである。


しかし、せっかく作ったのにもったいない。ということで今度はGAE用につくったブログを普通にMySQLを使うようにポーティングした。今回はそれに試験的に移行してみたのだ。

なぜWordpressからわざわざ独自ブログソフトウェアに移行したのか。理由は前のエントリーにも書いたとおりだけど

  • Wordpressのコードが気に食わない(OSSとしてバランスをとっている、というのはあるんですが)

  • 使用しているテンプレートや、プラグインも含めると出力されるHTMLが汚い。

  • ついている機能の半分以上は使っていない。ブログライトユーザの俺には機能が多すぎる。

    • 自分の使う機能が固まった。それさえあれば俺には十分とわかり始めた。
  • セキュリティ的にもWordpressはターゲットになっていて微妙。

以上のようなところをふまえ

  • 明快なコード。
  • キレイなXHTML。
  • 自分が使う機能だけ実装。
  • 堅牢なコード。

ということを心がけた。またレンタルサーバで動かすということで

  • なるべく静的HTMLとしてキャッシュしてmod_rewriteで飛ばす
  • 見た目や外部連携に関する動的な部分はなるべくクライアントサイドで

ということにも気を配った。

このブログは3年目に入るのだけど、やはりWEBの世界というのは流れが速いもので大分変化があった。その中で、外部連携はほぼJSONPで行えるようになったのでサーバサイドでやらなくても良くなった。今回実装したブログでは各SBMのブクマ数を表示しているけど、これも全てJSONPでクライアントサイドで実現している。それに対し、旧ブログ(Wordpress)ではサーバサイドで定期的に取得していた。

という感じで出来上がったのがこのブログだ。もうひとつ、実はこのブログ、XREAでしかもPython2.5で動かしている。あれ、XREAでPython2.5使えたっけ、というあなた。実は簡単に使えちゃうワザがあるんですよ。それについては、また。


あまりテストもせず試験的に移行してみたのでおかしい部分もあるかと思いますが、そこはおいおい。パーマリンクはそのままになっていると思います。基本的にURLは変更していません。また、CGIで動かしていますので、負荷が高ければ前のに戻す予定です。


前のエントリー で書いたように、GoogleAppEngineのトランザクションは使い勝手が悪い。しかし、GAEにブログを移行しようと思うとこれは乗り越えなければならない。

GAEでトランザクションを行う条件を簡単にまとめると

  • db.run_in_transactionにトランザクションとして実行したい関数を渡すがその関数内では
  • 同じエンティティグループに属しているモデルで
  • get, put, deleteのみ

しか実行できない。なのでトランザクションのページに紹介されているような

 1from google.appengine.ext import db
 2
 3class Accumulator(db.Model):
 4  counter = db.IntegerProperty()
 5
 6def increment_counter(key, amount):
 7  obj = db.get(key)
 8  obj.counter += amount
 9  obj.put()
10
11q = db.GqlQuery("SELECT * FROM Accumulator")
12acc = q.get()
13
14db.run_in_transaction(increment_counter, acc.key(), 5)

先にGQLを発行しておいて、 run_in_transaction に渡す関数ではGQLの結果として取得した key を引数にとる、という回りくどいコードになる。コレは書きづらい。

これも前のエントリーに書いたように、今回作ったブログアプリでは月別アーカイブやタグアーカイブの記事数をエントリのCRUD時に操作している。

エントリ作成時なら

1@classmethod
2def create(cls, *a, **k):
3  obj = super(Entry, cls).create(*a, **k)
4  TagCount.inc(obj.status, *obj.tags)
5  MonthCount.inc(obj.status, obj.created_month)
6  return obj

というような感じだ( status は公開、とか非公開とかが入る)。当然、この create メソッドでは TagCount , MonthCountobj について一貫性が保たれなければならない。しかし、 TagCount.inc では対象のタグを検索して、なければ作成し put する、という操作を行う。「対象のタグを検索して」というトランザクション内で許可されていない操作が入っているのだ。

さて、こういうときはとりあえず?力技で乗り切ろう。かなり強引だが、下のようなコードで乗り切ってみた。一応、サーバにアップロードして動作することは確認している。

1class RootModel(db.Model):
2  id = db.IntegerProperty(default=1)
3  def __call__(self, k):
4    v = k or {}
5    v.update(parent=with_parent)
6    return v
7with_parent = RootModel.get_or_insert("id", id=1)

まず、全てのエンティティを同一エンティティグループに所属させることにする。そのため、全てのエンティティの親となるエンティティを作成する。

次は強引さの根源のようなクラスだ。

 1class Transaction(object):
 2  MAGIC_NAME = "__magic__lst__"
 3  SEARCH_MAX = 12
 4  def __init__(self, f):
 5    self.f = f
 6
 7  def execute(self):
 8    __magic__lst__ = []
 9    result = self.f()
10    def commit():
11      for dbop in __magic__lst__:
12        dbop()
13      return True
14    committed = db.run_in_transaction(commit)
15    if committed is None:
16      raise db.Rollback()
17    return result

これだけでは分からないと思うが、 execute 内で run_in_transaction が実行されている。その引数には commitcommit の中では __magic__lst__ というリスト内の関数を実行しているようだが、 __magic__lst__ に要素が追加されている形跡がない。

そして、全てのモデルの親となる基本モデルを定義する。

 1class BaseModel(db.Model):
 2  @classmethod
 3  def create(cls, *a, **k):
 4    obj = cls(*a, **with_parent(k))
 5    obj.put()
 6    return obj
 7
 8  def _with_transaction(self, name):
 9    i = 1
10    f = sys._getframe(i)
11    while f and i < Transaction.SEARCH_MAX:
12      if Transaction.MAGIC_NAME in f.f_locals:
13        f.f_locals[Transaction.MAGIC_NAME].append( \
14          lambda : getattr(super(BaseModel, self), name)())
15        return
16      i += 1
17      f = sys._getframe(i)
18    return getattr(super(BaseModel, self),name)()
19
20  def put(self):
21    return self._with_transaction("put")
22
23  def delete(self):
24    return self._with_transaction("delete")
25
26  def update(self, **k):
27    for prop in self.properties().values():
28      if prop.name in k:
29        prop.__set__(self, k[prop.name])
30    self.put()
31
32  @classmethod
33  def get_by_id(cls, id):
34    return super(BaseModel, cls).get_by_id(id, parent=with_parent)

この基本クラスではまず、 create というメソッドを定義し、エンティティはすべてこのメソッドを通して作成するようにしている。 create では必ず親エンティティとして with_parent を指定する。これにより全てのエンティティは同一エンティティグループに属することになる。

  • 同じエンティティグループに属しているモデルで

という条件はクリアしたことになる。次は

  • get, put, deleteのみ

という条件だ。これは _with_transaction というメソッドでクリアしている。 putdelete をオーバーライドして _with_transaction を呼び出すようにしている。さて、この _with_transaction ではフレームをさかのぼって、 __magic__lst__ という名前のローカル変数が存在するフレームがないか探索する。そのフレームが存在した場合、一連のトランザクション内での実行とみなし、 __magic__lst__put , delete を行うthunkを登録し、終了する。存在しない場合は通常の実行ということで、その場で put , delete を行う。つまり、トランザクション内で実行されたことを検出して put , delete の評価を遅延させるのだ。

さきほどの create

1@classmethod
2def create(cls, *a, **k):
3  def _():
4    obj = super(Entry, cls).create(*a, **k)
5    TagCount.inc(obj.status, *obj.tags)
6    MonthCount.inc(obj.status, obj.created_month)
7    return obj
8  return Transaction(_).execute()

と若干のコード挿入のみできちんと一貫性が保証されるようになる。     *********   というわけで、かなり強引な気がするけどトランザクションの問題は一応クリアされた。めんどくさければHTTPリクエストを受けてから、レスポンスを返すまでを関数でラップし、 Transaction.execute すれば大丈夫だ。

ここまでやってしまったので、やっぱり今のブログはGAEに移行しようかなあ、と思い始めたり。デザインも今と似ているけどやっぱ一から自分でマークアップしたから好みだし、なによりXHTMLとしてValidだ。機能的にはあと、スパム対策さえあれば大丈夫。

実はOpenIDも自分でサーバ立ててたりするので、それもGAEで実装しなおしたいなあとか色々やりたいことは尽きない。GAEでの開発はこのモデルの問題さえつぶしてしまえばかなり楽だし、夢がひろがるなあ。