jinja2の設計

これはjinja2の設計の紹介であり、壮大な"hello, world"の実装と見ることもできます。非常に簡略化したjinja2(=MY_jinja2)を使って、おおよその構造をソースコードで示したい(あまり日本語では説明できない)のですが、そのためにはせめてjinja2と同じように動作することを確かめた方が良いでしょう。

#!/usr/bin/env python2.6
import unittest
from test import test_support
import MY_jinja2
import jinja2
class APITests(unittest.TestCase):  
  def test_from_data(self):
    env = self.MODULE.Environment()
    tmpl = env.from_string('hello, world')
    self.assertEqual('hello, world', tmpl.render())
class API_jinja2Tests(APITests):
  MODULE = jinja2
class API_MY_jinja2Tests(APITests):
  MODULE = MY_jinja2
if __name__ == '__main__':
  test_support.run_unittest(API_jinja2Tests, API_MY_jinja2Tests)

これは、"hello, world"を返すだけのものなので、あまり説得力の無いテストですが、ここでは気にしないことにします。import MY_jinja2はMY_jinja2ディレクトリにある__init__.pyを呼んでます。この中身はこのようになっています。

  • MY_jinja2/__init__.py
from MY_jinja2.environment import Template, Environment
from MY_jinja2.compiler import CodeGenerator
from MY_jinja2 import nodes, lexer, parser

そして、テストコードはEnvironmentオブジェクトを生成して、from_stringを読んでいます。このクラスは__init__.pyを見る限り、environment.pyに書かれています。

  • MY_jinja2/environment.py
class Template(object):
  @classmethod
  def from_code(cls, code):
    tpl = object.__new__(cls)
    namespace = {}
    exec code in namespace
    tpl.render_func = namespace['root']
    return tpl
  def render(self, *args, **kwargs):
    dic = dict(*args, **kwargs)
    return ''.join(self.render_func(dic))
from MY_jinja2 import lexer, parser, compiler
class Environment:
  @classmethod
  def from_string(cls, source):
    tokens = lexer.Lexer.tokenize(source)
    stream = lexer.TokenStream(tokens)
    node = parser.Parser.parse(stream)
    generator = compiler.CodeGenerator()
    generator.visit(node)
    return Template.from_code(generator.get_code())

メソッドfrom_stringを見ると、ここに全ての処理の流れが書かれています(jinja2はパーサークラスを置き換えなど、カスタマイズする目的のため、ずいぶん違った印象を受けますが、本質的には同じです。)。lexer.Lexerが文字列(basestring)をトークンに変換し、parser.ParserがAstNodeに変換します。AstNodeVisitorであるcompiler.CodeGeneratorがnodeを辿りながらcode(中身はpython code)を生成。このcodeからTemplateを生成します。この順番に、ソースコードを見ていきます。

  • MY_jinja2/lexer.py
import operator
TOKEN_NAME = 'name'
TOKEN_DATA = 'data'
TOKEN_INITIAL = 'initial'
TOKEN_EOF = 'eof'
class Token(tuple):
  type, value = (property(operator.itemgetter(x)) for x in range(2))
  def __new__(cls, type, value):
    return tuple.__new__(cls, (type, value))
class TokenStream:
  def __init__(self, iter_tokens):
    self._next = iter(iter_tokens).next
    self.current = Token(TOKEN_INITIAL, '')
    next(self)
  def __nonzero__(self):
    return self.current.type is not TOKEN_EOF
  def next(self):
    token = self.current
    if token.type is not TOKEN_EOF:
      try:
        self.current = self._next()
      except StopIteration:
        self.current = Token(TOKEN_EOF, '')
    return token
import re
class Lexer:
  @classmethod
  def tokenize(cls, source):
    regex = re.compile('(.+)', re.M| re.S)
    while 1:
      m = regex.match(source)
      yield Token(TOKEN_DATA, m.group())
      if len(source) <= m.end():
        break

Lexer.tokenizeの実装はひどいものがありますが、まだTOKEN_DATAしか扱えないだけで、正規表現を使ってルールを追加していけば、本物に近づきます。

  • MY_jinja2/nodes.py
class Template:
  def __init__(self, body):
    self.body = body
class TemplateData:
  def __init__(self, data):
    self.data = data
class Output:
  def __init__(self, node):
    self.node = node

jinja2では、nodeは全てNodeを継承しています。複雑な式を扱うようになって、Nodeの種類が増えて、共通のメソッドなどが必要になったら、そのように書き換えるべきです。

  • MY_jinja2/parser.py
from MY_jinja2 import nodes
from MY_jinja2.lexer import TOKEN_DATA
class Parser:
  @classmethod
  def parse(cls, stream):
    body = []
    while stream:
      token = next(stream)
      assert(TOKEN_DATA == token.type)
      data = nodes.TemplateData(token.value)
      body.append(nodes.Output(data))
    return nodes.Template(body)

Parser.parseも、すでにデータが来ることを知っています。これも拡張が必要です。

  • My_jinja2/compiler.py
import StringIO
class CodeGenerator:
  def __init__(self):
    self.is_first = True
    self.indent_level = 0
    self.stream = StringIO.StringIO()
  def get_code(self):
    return self.stream.getvalue()
  def indent(self):
    self.indent_level += 1
  def outdent(self):
    self.indent_level -= 1
  def visit_TemplateData(self, node):
    self.stream.write(repr(unicode(node.data)))
  def visit_Output(self, node):
    if not self.is_first:
      self.stream.write('\n')
    self.stream.write('  ' *self.indent_level)
    self.stream.write('yield ')
    self.visit_TemplateData(node.node)
    self.is_first = False
  def visit_Template(self, node):
    self.stream.write('def root(dic):\n')
    self.indent()
    for child in node.body:
      self.visit_Output(child)
    self.outdent()
  visit = visit_Template

nodeに合わせてstreamフィールドにcodeを書き込んでいくように書きます。jinja2はvisitはvisit_(クラス名)を実行する(visitorパターン)ように書いていますが、まだそのようになっていません。