Wie ich mir eine Template-Engine baue

Gute Links und Tutorials könnt ihr hier posten.
Antworten
sma
User
Beiträge: 3018
Registriert: Montag 19. November 2007, 19:57
Wohnort: Kiel

[Ich wollte schon immer mal testen, ob phpbb3 nun wirklich besser ist, was das Anzeigen von Codeabschnitten angeht und habe daher in den letzten Tagen ein Tutorial als Beispiel gebaut. Hatte jetzt aber keine Lust, mein Markdown mehr als notdürftig in BBCode umzuwandeln. Für so etwas könnte man natürlich auch mal ein Programm schreiben...]

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}}
Gegeben sei folgendes Objekt, in dessen Kontext das Template dann ausgeführt wird:

Code: Alles auswählen

    { "name": "Chris", "value": 10000, "taxed_value": 6000, "in_ca": True }
Dann sieht das Ergebnis so aus:

Code: Alles auswählen

    Hello Chris
    You have just won $10000!
    Well, $6000, after taxes.
*Marker*, die durch Werte aus dem Dictionary ersetzt werden, stehen in doppelten geschweiften Klammern. *Sektionen* werden durch `{{#...}}` und `{{/...}}` begrenzt. Ist der entsprechende Wert aus dem Dictionary logisch falsch (`False`, `None`, `0`, leere Liste oder leerer String usw.), wird die Sektion weggelassen. Ist es etwas Aufzählbares, wird die Sektion entsprechend häufig wiederholt. Andernfalls wird die Sektion genau einmal dargestellt. Eine Sektion ist somit sowohl eine bedingte Anweisung als auch eine Schleife.

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}}
Die Daten:

Code: Alles auswählen

    {
        tag: "li",
        links: [
            {"title": "Google", "url": "url": "http://www.google.com/"},
            {"title": "Yahoo", "url": "url": "http://www.yahoo.com/"}
        ]
    }
Das Ergebnis:

Code: Alles auswählen

    <li><a href="http://www.google.com/">Google</a></li>
    <li><a href="http://www.yahoo.com/">Yahoo</a></li>
Mustache hat noch ein paar weitere Features, doch für mein Tutorial soll dies reichen.

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()
Nun kann ich in der Klasse `ContextTest` einen Test implementieren:

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"))
Führe ich den Code aus, schlägt der Test natürlich fehlt. Daher definiere ich nun:

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
Ich sagte, dass ein Wert, der in einem Kontext nicht gefunden wird, in dem äußeren Kontext gesucht wird. Dies prüft der folgende Test in `ContextTest`, der natürlich zunächst einmal fehlschlägt:

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"))
Um ihn zu erfüllen, muss ich `Context.get` anpassen:

Code: Alles auswählen

    def get(self, name):
        try:
            return self.value[name]
        except KeyError:
            pass
        return self.outer and self.outer.get(name)
Ich möchte nun nicht nur `dict`s (oder andere Objekte, die `__getitem__` implementieren) unterstützen, sondern auch auf Attribute eines Objekts zugreifen können. Dies prüft der nächste Test:

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"))
Dazu muss ich erneut `Context.get` anpassen:

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)
Mit fällt gerade auf, dass es bei einem `dict`, welches ja z.B. eine Methode `clear` hat, zu einem Problem führen kann wenn ich dort einen Wert unter dem Namen `clear` erfragen will. Ein Test soll dies deutlich machen:

Code: Alles auswählen

    def test_dict_access(self):
        context = Context({"clear": 1})
        self.assertEquals(1, context.get("clear"))
Der Test schlägt fehlt, denn es wird nicht `1`, sondern das Methodenobjekt aus dem Attribut `clear` zurückgeliefert. Ich muss zunächst auf `__getitem__` prüfen und dann erst auf Attribute, um dieses Problem zu umgehen:

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)
Unter dem Namen `.` möchte ich das Objekt selbst ansprechen können:

Code: Alles auswählen

    def test_dot_access(self):
        context = Context("42")
        self.assertEquals("42", context.get("."))
Das implementiere ich einfach als Spezialfall in `Context.get`:

Code: Alles auswählen

   def get(self, name):
        if name == '.': return self.value
        ...
Zur Sicherheit möchte ich noch den folgenden Test implementieren:

Code: Alles auswählen

    def test_inherited_acccess_non_dict(self):
        self.assertEquals(1, Context(True, Context({"a": 1})).get("a"))
Er schlägt nicht fehl, doch bei einer früheren Version hatte ich Probleme mit genau diesem Fall. Ich möchte einfach sicherstellen, dass es auch in Zukunft funktioniert.

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")))
Die folgende Implementierung erfüllt alle Zusicherungen:

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 [])
Ein String ist in Python dummerweise aufzählbar, daher muss ich ihn gesondert erkennen und wie jeden anderen skalaren (nicht-Container) Werten behandeln. Alles andere, dass bei `iter()` nicht mit einem `TypeError` antwortet, ist aufzählbar. Spannend ist vielleicht noch die Frage, was eigentlich bei `get_iter('.')` passieren soll:

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(".")))
Ich könnte mir vorstellen, dass praktisch wäre, ein Objekte als Key-Value-Tupel aufzuzählen und nicht nur als Keys. Doch dann müsste man irgendwie auf die Elemente des Tupel zugreifen können, etwa in dem ich bei Tupeln und Listen den Namen in eine Zahl konvertiere, falls der Zugriff einen `TypeError` gibt. Doch das lasse ich mal für später.

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({})))
Die Implementierung ist trivial:

Code: Alles auswählen

    class Text:
        def __init__(self, text):
            self.text = text
        
        def render(self, context):
            return self.text
Ein `Marker` hat einen Namen und setzt den entsprechenden Wert aus dem Kontext ein:

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({})))
Hier ist die Implementierung:

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 ""
Bleibt `Section`. Die Sektion kennt eine Liste von Template-Objekten, die entsprechend häufig dargestellt werden. Die Sektion fungiert dabei sowohl als eine bedingte Anweisung als auch als eine Schleife.

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})))
Hier ist die Implementierung:

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)
Gerade zusammen mit Sektionen ist `{{.}}` praktisch. Daher möchte ich das explizit testen:

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))
Der Test funktioniert bereits. Man könnte sich überlegen, ob man über eine spezielle Variable noch einen Index mitführt oder ein Flag, mit dem man erkennt, ob man das erste Element zu fassen hat oder ein weiteres, was dann das einfügen von Trenntexten einfacher macht, aber das überlasse ich dem Leser.

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|{{([#/].+?)}}|{{(.+?)}}|([^{]+|{)'
Gruppe 1 enthält den Namen (inklusive `#` oder `/`) eines einzeln in einer Zeile stehenden Sektionsanfang oder -ende. Gruppe 2 enthält den Namen (inklusive `#` oder `/`) eines nicht alleine stehenden Sektionsanfang oder -ende. Gruppe 3 enthält den Namen eines Markers und Gruppe 4 enthält ansonsten statischen Text.

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({}))
Diese Implementierung erfüllt ihn:

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))
Als nächstes teste ich das Erkennen von Markern:

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"}))
Dazu muss ich `parse` anpassen (welches "zufällig" schon eine Struktur hat, in der das besonders einfach geht).

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
Nun fehlen noch Sektionen. Hier die einfachen Fälle:

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))
Dann einige Fälle mit Zeilenumbrüchen:

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({}))
Hier sind die Ergänzungen, die an `parse` vorgenommen werden müssen. Ich teste leider nicht die Fehlerfälle, die entstehen können, wenn das Sektionsende fehlt oder wenn die Namen von Sektionsanfang und -ende nicht zusammenpassen. Das überlasse ich dem Leser.

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
Es ist Zeit, das kanonische Beispiel zu testen:

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))
Dieses funktioniert und wir sind somit fertig. Mustache kennt jetzt noch Partials mittels `{{>name}}`, invertierte Sektionen mittels `{{^name}}` und Kommentare mittels `{{! ... }}`. Außerdem kann man mittels "pragma" die "Schnauzbärte" `{{` und `}}` in andere Zeichen umdefinieren.

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
Antworten