Mustache ist eine einfache Templates-Engine, die für verschiedene Programmiersprachen (auch Python) verfügbar ist. Dies ist ein kleines Tutorial, welches zeigt, wie man eine solche Template-Engine selber baut.
Ich beginne mit dem "kanonischen" Beispiel von der Mustache-Webseite:
Code: Alles auswählen
Hello {{name}}
You have just won ${{value}}!
{{#in_ca}}
Well, ${{taxed_value}}, after taxes.
{{/in_ca}}
Code: Alles auswählen
{ "name": "Chris", "value": 10000, "taxed_value": 6000, "in_ca": True }
Code: Alles auswählen
Hello Chris
You have just won $10000!
Well, $6000, after taxes.
Ein *Template* wird immer in einem *Kontext* dargestellt. Innerhalb einer Sektion verschiebt sich der Kontext auf Objekt, für das die Sektion dargestellt wird. Wenn dort kein Wert zu einem Marker oder einer weiteren Sektion gefunden wird, wird der äußere Kontext konsultiert.
Ein Beispiel macht dies am einfachsten klar.
Das Template:
Code: Alles auswählen
{{#links}}
<{{tag}}><a href="{{url}}">{{title}}</a></{{tag}}>
{{/links}}
Code: Alles auswählen
{
tag: "li",
links: [
{"title": "Google", "url": "url": "http://www.google.com/"},
{"title": "Yahoo", "url": "url": "http://www.yahoo.com/"}
]
}
Code: Alles auswählen
<li><a href="http://www.google.com/">Google</a></li>
<li><a href="http://www.yahoo.com/">Yahoo</a></li>
Ich möchte das ganze testgetrieben "bottom-up" entwickeln. Ich beginne mit einem Objekt, welches den Kontext repräsentiert (`Context`), der Werte zu Namen ermittelt. Danach implementiere ich die Objekte, die ein Template repräsentieren und darstellen können (`Text`, `Marker`, `Section`). Schließlich stelle ich den Parser vor, der aus einem String die Objektrepräsentation erzeugt. Die Klasse `Template` fasst dann alles zusammen.
Der Kontext
-----------
So sieht mein Rahmen für den ersten Test aus:
Code: Alles auswählen
import unittest
class ContextTest(unittest.TestCase):
pass
if __name__ == '__main__':
unittest.main()
Code: Alles auswählen
def test_simple_access(self):
context = Context({"a": 1, "b": [2]})
self.assertEquals(1, context.get("a"))
self.assertEquals([2], context.get("b"))
self.assertEquals(None, context.get("c"))
Code: Alles auswählen
class Context:
def __init__(self, value, outer=None):
self.value, self.outer = value, outer
def get(self, name):
try:
return self.value[name]
except KeyError:
return None
Code: Alles auswählen
def test_inherited_access(self):
context = Context({"b": [2]}, Context({"a": 1}))
self.assertEquals(1, context.get("a"))
self.assertEquals([2], context.get("b"))
self.assertEquals(None, context.get("c"))
Code: Alles auswählen
def get(self, name):
try:
return self.value[name]
except KeyError:
pass
return self.outer and self.outer.get(name)
Code: Alles auswählen
def test_attr_access(self):
class C:
a = "1"
b = None
context = Context(C())
self.assertEquals("1", context.get("a"))
self.assertEquals(None, context.get("b"))
self.assertEquals(None, context.get("c"))
Code: Alles auswählen
def get(self, name):
if hasattr(self.value, name):
return getattr(self.value, name)
if hasattr(self.value, "__getitem__"):
try:
return self.value[name]
except KeyError:
pass
return self.outer and self.outer.get(name)
Code: Alles auswählen
def test_dict_access(self):
context = Context({"clear": 1})
self.assertEquals(1, context.get("clear"))
Code: Alles auswählen
def get(self, name):
if hasattr(self.value, "__getitem__"):
try:
return self.value[name]
except KeyError:
pass
elif hasattr(self.value, name):
return getattr(self.value, name)
return self.outer and self.outer.get(name)
Code: Alles auswählen
def test_dot_access(self):
context = Context("42")
self.assertEquals("42", context.get("."))
Code: Alles auswählen
def get(self, name):
if name == '.': return self.value
...
Code: Alles auswählen
def test_inherited_acccess_non_dict(self):
self.assertEquals(1, Context(True, Context({"a": 1})).get("a"))
Für Sektionen brauche ich nun die Möglichkeit, einen Iterator zu einem Namen zu bekommen. Gibt es keinen passenden Wert, soll ein leerer Iterator die Antwort sein. Ich möchte diese Funktion ebenfalls im Kontext implementieren.
Zunächst ein Bündel von Tests, die ich jetzt nicht alle einzeln implementieren möchte, sondern für die ich dann die fertige `get_iter`-Methode vorstellen werde:
Code: Alles auswählen
def test_iter_access(self):
context = Context({"a": [1, 2], "b": True}, Context({"c": [], "d": "abc", "e": ""}))
self.assertEquals([1, 2], list(context.get_iter("a")))
self.assertEquals([True], list(context.get_iter("b")))
self.assertEquals([], list(context.get_iter("c")))
self.assertEquals(["abc"], list(context.get_iter("d")))
self.assertEquals([], list(context.get_iter("e")))
self.assertEquals([], list(context.get_iter("f")))
Code: Alles auswählen
def get_iter(self, name):
value = self.get(name)
if not isinstance(value, str):
try:
return iter(value)
except TypeError:
pass
return iter([value] if value else [])
Code: Alles auswählen
def test_dot_iter_access(self):
self.assertEquals(["abc"], list(Context("abc").get_iter(".")))
self.assertEquals(["a"], list(Context({"a": 2}).get_iter(".")))
Die Knoten
----------
Als nächstes werde ich drei Klassen `Text`, `Marker` und `Section` definieren, mit denen ich ein Template repräsentiere. Jede Klasse definiert eine Methode `render`, die einen String liefert. Übergeben wird ein Kontext. Sektionen haben dabei weitere Text-, Marker- oder Sektion-Objekte als Kinder und bilden so eine rekursive Datenstruktur.
Es soll mit `Text` beginnen, der zur Darstellung von statischem Text dient:
Code: Alles auswählen
class NodeTest(unittest.TestCase):
def test_text(self):
self.assertEquals("", Text("").render(Context({})))
self.assertEquals("abc", Text("abc").render(Context({})))
Code: Alles auswählen
class Text:
def __init__(self, text):
self.text = text
def render(self, context):
return self.text
Code: Alles auswählen
def test_marker(self):
self.assertEquals("abc", Marker("x").render(Context({"x": "abc"})))
self.assertEquals("0", Marker("x").render(Context({"x": 0})))
self.assertEquals("", Marker("x").render(Context({"x": None})))
self.assertEquals("", Marker("x").render(Context({})))
Code: Alles auswählen
class Marker:
def __init__(self, name):
self.name = name
def render(self, context):
value = context.get(self.name)
return str(value) if value is not None else ""
Code: Alles auswählen
def test_section(self):
section = Section("a", Marker("b"), Text("!"))
context = Context({"a": [{"b": "x"}, {"b": "y"}]})
self.assertEquals("x!y!", section.render(context))
self.assertEquals("", section.render(Context({})))
self.assertEquals("0!", section.render(Context({"a": True, "b": 0})))
self.assertEquals("", section.render(Context({"a": False, "b": 0})))
Code: Alles auswählen
class Section:
def __init__(self, name, *nodes):
self.name, self.nodes = name, nodes or []
def append(self, node):
self.nodes.append(node)
def render(self, context):
s = ""
for value in context.get_iter(self.name):
s += self.render_nodes(Context(value, context))
return s
def render_nodes(self, context):
return "".join(node.render(context) for node in self.nodes)
Code: Alles auswählen
def test_section_with_dot(self):
context = Context({"a": [1, 2, 3]})
self.assertEquals("$1$2$3", Section("a", Text("$"), Marker(".")).render(context))
Das Template
------------
Die Klasse `Template` definiert meinen Parser und bietet dem Anwender auch eine Methode `render`, der ein Objekt als Kontext übergeben wird, um das Template dann darzustellen.
Templates möchte ich mit einem regulären Ausdruck in Text, Marker und Sektionsanfang bzw. -ende zerlegen und dann in die entsprechenden Objekte umwandeln. Das ist im Prinzip einfach -- wäre da nicht das Problem, dass ich zusätzliche Leerzeichen und Zeilen um Sektionsanfang und -ende entfernen muss, damit das kanonische Beispiel von oben funktioniert.
Ich definiere dazu die folgende Regel: Ein Sektionsanfang, der alleine in einer Zeile steht, verschluckt alle Leerzeichen vor und hinter dem Tag sowie den nächsten Zeilenumbruch. Folgt dem Sektionsanfang etwas anderes, passiert dies nicht. Steht ein Sektionsende allein in einer Zeile, gilt das selbe. Bei einfachen Markern passiert das nie.
Hier ist der reguläre Ausdruck:
Code: Alles auswählen
RE = r'^\s*{{([#/].+?)}}\s*\n|{{([#/].+?)}}|{{(.+?)}}|([^{]+|{)'
Kommen wir zu den Tests. Der einfachste Fall ist statischer Text:
Code: Alles auswählen
class TemplateTest(unittest.TestCase):
def test_static_text(self):
self.assertEquals("", Template("").render({}))
self.assertEquals("abc", Template("abc").render({}))
Code: Alles auswählen
class Template:
def __init__(self, source):
self.root = self.parse(re.finditer(RE, source), "")
def parse(self, matches, name):
section = Section(name)
for m in matches:
sec1, sec2, marker, text = m.groups()
if text:
section.append(Text(text))
return section
def render(self, value):
return self.root.render_nodes(Context(value))
Code: Alles auswählen
def test_text_with_marker(self):
self.assertEquals("()", Template("({{a}})").render({}))
self.assertEquals("(x)", Template("({{a}})").render({"a": "x"}))
self.assertEquals("x)", Template("{{a}})").render({"a": "x"}))
self.assertEquals("(x", Template("({{a}}").render({"a": "x"}))
self.assertEquals("xy", Template("{{a}}{{b}}").render({"a": "x", "b": "y"}))
Code: Alles auswählen
def parse(self, matches, name):
section = Section(name)
for m in matches:
sec1, sec2, marker, text = m.groups()
if text:
section.append(Text(text))
elif marker:
section.append(Marker(marker))
return section
Code: Alles auswählen
def test_inline_section(self):
data = {"a": [1, 2]}
self.assertEquals("()", Template("({{#a}}{{.}}{{/a}})").render({}))
self.assertEquals("(12)", Template("({{#a}}{{.}}{{/a}})").render(data))
self.assertEquals("12)", Template("{{#a}}{{.}}{{/a}})").render(data))
self.assertEquals("(12", Template("({{#a}}{{.}}{{/a}}").render(data))
self.assertEquals(" 12 ", Template(" {{#a}}{{.}}{{/a}} ").render(data))
self.assertEquals(" 1 2 ", Template("{{#a}} {{.}} {{/a}}").render(data))
Code: Alles auswählen
def test_multiline_section(self):
data = {"a": [1, 2]}
template = Template("(\n{{#a}}\n {{.}}\n{{/a}}\n)")
self.assertEquals("(\n 1\n 2\n)", template.render(data))
self.assertEquals("(\n)", template.render({}))
template = Template("({{#a}}\n {{.}}\n{{/a}})")
self.assertEquals("(\n 1\n\n 2\n)", template.render(data))
self.assertEquals("()", template.render({}))
template = Template("{{#a}}(\n {{.}}\n){{/a}}")
self.assertEquals("(\n 1\n)(\n 2\n)", template.render(data))
self.assertEquals("", template.render({}))
Code: Alles auswählen
def parse(self, matches, name):
section = Section(name)
for m in matches:
sec1, sec2, marker, text = m.groups()
if text:
section.append(Text(text))
elif marker:
section.append(Marker(marker))
else:
sec = sec1 or sec2
t, sec = sec[0], sec[1:]
if t == '#':
section.append(self.parse(matches, sec))
else:
if sec == name: break
if name:
raise SyntaxError("expected {{/%s}} but found {{/%s}}" % (name, sec))
raise SyntaxError("unexpected {{/%s}}" % sec)
else:
if name: raise SyntaxError("missing {{/%s}}" % name)
return section
Code: Alles auswählen
def test_example(self):
template = Template("Hello {{name}}\n"
"You have just won ${{value}}!\n"
"{{#in_ca}}\n"
"Well, ${{taxed_value}}, after taxes.\n"
"{{/in_ca}}")
data = {"name": "Chris", "value": 10000, "taxed_value": 6000, "in_ca": True}
self.assertEquals("Hello Chris\n"
"You have just won $10000!\n"
"Well, $6000, after taxes.\n", template.render(data))
Das Vorbild von Mustache, Googles CTemplate, kennt noch die IMHO sehr nützliche Eigenschaft, dass zu einem Sektionsnamen `foo` innerhalb der Sektion noch ein `foo_separated` gesetzt wird, was `True` ist, solange man noch nicht das letzte Element erreicht hat. So etwas ist hilfreich, wenn man z.B. Komma-separierte Listen erstellen will. Man könnte auch `foo_index` ergänzen, was dann einen Schleifenindex enthält. CTemplate erlaubt es auch, einen Wert in einem Marker (ähnlich wie Djangos Template-Sprache es kann) zu filtern, indem man `{{foo:filter}}` benutzt.
Das Original-Mustache erlaubt es nicht, Namen wie `foo.bar` zu benutzen, was dann bedeuten würde, dass man in `foo` nach einem Wert bzw. Attribut namens `bar` sucht, aber einige finden das praktisch und so gibt es Varianten von Mustache, die das erlauben. Natürlich könnte man auch sagen, innerhalb von `{{...}}` steht ein beliebiger Python-Ausdruck, der einfach ausgewertet wird.
Stefan