Simple Python template engine • 5 Jan 2011
UPDATE: A more recent version of the source code can be found at github.com/.../ovotemplate.py
An idea for a simple templating engine
I want to do basic template expansion, like this:
Hello {=name}, thanks for your order.
And then feed a 'name' to it, in this way:
vars = { "name": "Joe"}
So that it expands to this:
Hello Joe, thanks for you order.
If "{=name}" occurs multiple times in the template, each instance should be substituted with 'Joe'; and not only the first one encountered.
Reinventing the wheel?
Why did I write this, you ask? Perfectly valid question, because there are already many templating engines out there. I even use a few of them ;-) But for some situations I want to have a template solution which doesn't use embedded Python statements, nor an XML-ish syntax, but does support repeating and conditional blocks. I didn't find an existing one, so I wrote it myself.
In my opinion templates should not contain statements: code belongs in the application. Intermingling code with literal page fragments makes me think of how things were done in the classic ASP days, and stirs up forgotten memories of horrible mashed-together PHP pages. It becomes an unmaintainable mess. Some people object: "So? Why not have code in the template? In the end you are the one looking at them anyway!" But that's not true. I regularly have my templates translated by translators who don't speak any programming language, but understand that something like {product_id} will be filled in later and shouldn't be translated.
On with the feature list! ...
Conditionals
I also want to be able to mark certain parts of the template so they will only appear in the output when a variable is True:
Hello {=name}, thanks for your order.
{?ccpayment
The sum of {=grandtotal} will be deducted from your creditcard.
}
...combined with this data...
vars = {
"name": "Joe",
"ccpayment": True,
"grandtotal": "23.99",
}
Result:
Hello Joe, thanks for your order.
The sum of 23.99 will be deducted from your creditcard.
If ccpayment would be False, the entire line "The sum of..." would not be displayed.
Repeating groups
And it should do repeating groups, too! For example, to generate the following output:
Hello Joe, thanks for your order.
The sum of 23.99 will be deducted from your creditcard.
You bought:
2x 'Coders at work' by Peter Seibel = 15.10
1x 'Hackers and painters' by Paul Graham = 3.12
1x 'More Joel on Software' by Joel Spolsky = 7.62
I'd like to use this template:
Hello {=name}, thanks for your order.
{?ccpayment
The sum of {=grandtotal} will be deducted from your creditcard.
}
{#purchases
{count}x '{title}' by {author} = {total}
}
And I'd have to feed it with this data:
vars = {
"name": "Joe",
"ccpayment": True,
"purchases": (
{"count": 2, "title": "Coders at work", "author": "Peter Seibel", "total": 15.10},
{"count": 1, "title": "Hackers and painters", "author": "Paul Graham", "total": 3.12},
{"count": 1, "title": "More Joel on Software", "author": "Joel Spolsky", "total": 7.62},
)
"grandtotal": "23.99",
}
Nesting
Nesting would be a great feature. For example, to make a list with sublist like the one below, a nested repeat can be used:
1. Introduction
1.1. Features
1.2. FAQ
1.3. Getting started
2. Usage
2.1. Daily usage
2.2. Exceptional cases
The template would look like this:
{#chapters
{=nr}. {=title}
{#subchapters
{=subnr}. {=subtitle}
}
}
and the data structure like this:
vars = {
"chapters": (
{"nr": "1", "title": "Introduction", "subchapters": (
{"subnr": "1.1", "subtitle": "Features"},
{"subnr": "1.2", "subtitle": "FAQ"},
{"subnr": "1.3", "subtitle": "Getting started"},
)
},
{"nr": "2", "title": "Usage", "subchapters":
{"subnr": "2.1", "subtitle": "Daily usage"},
{"subnr": "2.2", "subtitle": "Exceptional cases"},
},
)
}
Source code
# -*- coding: utf8 -*-
# ovotemplate.py
#
# Version 1.1, 5 jan 2010
#
# Simple templating class, software by Michiel Overtoom, motoom@xs4all.nl
#
# {=word} simpele replacement
# {?tag ...} conditional
# {#tag ...} repeat
# {/tag ...} separator
#
import pprint
import re
whitechars = re.compile("\s")
verbose = False
# verbose = True
def indent(level):
return "| " + " " * level
class Container(list):
"Generic container"
def __init__(self, name=""):
self.name = name
def __repr__(self):
tag = "%s %s" % (self.__class__.__name__, self.name)
return "%s: %s" % (tag.strip(), super(Container, self).__repr__())
def render(self, vars, last=False, level=0):
if verbose: print "%sContainer.render(vars=%s,last=%s) type(vars)=%s, self.name=%s" % (indent(level),vars,last,type(vars),self.name)
output = ""
for child in self:
if verbose: print "%sContainer.render child %s" % (indent(level), child)
output += child.render(vars, last, level+1)
return output
class Lit(Container):
"Container for literal content"
def __init__(self, contents=""):
super(Lit, self).__init__()
self.append(contents)
def __repr__(self):
return super(Lit, self).__repr__()
def render(self, vars, last, level):
if verbose: print "%sLit.render(vars=%s,last=%s) type(vars)=%s, self=%s" % (indent(level), vars,last,type(vars),self)
return self[0]
class Sep(Container):
"Container for separator. Same as Lit, but doesn't result in output in the last iteration of a Rep."
def __init__(self, name):
super(Sep, self).__init__(name)
def __repr__(self):
return super(Sep, self).__repr__()
def render(self, vars, last, level):
if verbose: print "%sSep.render(vars=%s) type(vars)=%s, self=%s" % (indent(level), vars,type(vars),self)
if last:
if verbose: print "%sSep.render last is True, empty string returned" % indent(level)
return ""
output = ""
for child in self:
if verbose: print "%sSep.render child %s" % (indent(level), child)
output += child.render(vars, last, level+1)
return output
class Sub(Container):
"Container for a variable substitution"
def __init__(self, name):
super(Sub, self).__init__(name)
def __repr__(self):
return super(Sub, self).__repr__()
def render(self, vars, last, level):
if verbose: print "%sSub.render(vars=%s) type(vars)=%s, self.name=%s" % (indent(level), vars,type(vars),self.name)
value = vars[self.name]
if isinstance(value, (int, float, long)):
value = str(value)
return value
class Cond(Container):
"Container for conditional content"
def __init__(self, name):
super(Cond, self).__init__(name)
def __repr__(self):
return super(Cond, self).__repr__()
def render(self, vars, last, level):
if verbose: print "%sCond.render(vars=%s) type(vars)=%s, self.name=%s" % (indent(level), vars,type(vars),self.name)
if not vars[self.name]:
if verbose: print "%sCond.render cond is False, empty string returned" % indent(level)
return ""
output = ""
for child in self:
if verbose: print "%sCond.render child %s" % (indent(level), child)
output += child.render(vars, last, level+1)
return output
class Rep(Container):
"Container for repeating content"
def __init__(self, name):
super(Rep, self).__init__(name)
def __repr__(self):
return super(Rep, self).__repr__()
def render(self, vars, last, level):
if verbose: print "%sRep.render(vars=%s) type(vars)=%s, self.name=%s" % (indent(level),vars,type(vars),self.name)
output = ""
#if not self.name in vars:
# raise NameNotFound("A required variable name '%s' was not present in '%r'" % (self.name, vars))
subvars = vars[self.name] # A KeyError here means that a required variable wasn't present.
for nr, subvar in enumerate(subvars):
if verbose: print "%sRep.render subvar=%s, type(subvar)=%s" % (indent(level),subvar,type(subvar))
for child in self:
last = nr == len(subvars)-1
if verbose: print "%sRep.render child %s, last=%s" % (indent(level), child,last)
output += child.render(subvar, last, level+1)
return output
def makecontainer(s):
"Return a proper container depending on what type is needed"
# TODO: Wel of niet metachars (?,#,=,/) opnemen in naam?
name = s[1:].strip()
if s.startswith("?"):
tmp = Cond(name)
elif s.startswith("#"):
tmp = Rep(name)
elif s.startswith("="):
tmp = Sub(name)
elif s.startswith("/"):
tmp = Sep(name)
else:
# tmp = Lit(name)
raise ValueError("argument '%s' should begin with =, ?, # or /100CASIO" % s)
return tmp
def splitfirst(s):
"Split a string into a first special word, and the rest"
if not s:
return "", ""
if s[0] in "?#=/":
parts = whitechars.split(s, 1)
if len(parts) < 2:
return s, ""
else:
return parts[0], parts[1]
else:
return "", s
# level 0 is altijd literal text
# level 1 en hoger is spul dat met { begint
# eerste string moet een van volgende zijn: toegestaan: {?, {#, {=
# de rest van de strings op hetzelfde level is literal text
def descendingparse(s, i=0, level=0):
"Parse a part of the template recursively"
if verbose:
print "%sEnter descendingparse('%s', %d, %d) s[%d:] = '%s'" % (indent(level), s, i, level, i, s[i:])
collect = ""
node = Container() if level == 0 else None
while i < len(s):
c = s[i]
i += 1
if c == "{":
if verbose:
print "%s{ seen on pos %d, collected text in front of it: '%s' " % (indent(level), i-1, collect) # wat voor de { staat
if level == 0:
rest = collect
else:
first, rest = splitfirst(collect)
if first:
node = makecontainer(first)
if rest:
node.append(Lit(rest))
i, between = descendingparse(s, i, level + 1) # tussen { en }
node.append(between)
collect = "" # verzamel opnieuw voor de rest
elif c == "}":
if level == 0:
raise Exception("Too many }")
if verbose:
print "%s} seen on pos %d, collected text '%s' output before pop" % (indent(level), i-1, collect) # output before pop
first, rest = splitfirst(collect)
if not node:
node = makecontainer(first)
if rest:
node.append(Lit(rest))
if verbose:
print "%sLeave descendingparse, return(%d, %r) s[%d:] = '%s' " % (indent(level), i, node, i, s[i:])
return i, node # pop
else:
collect += c
# end of input string reached
if collect:
if verbose:
print "%sInput exhausted at pos %d, collected text: '%s' " % (indent(level), i, collect)
node.append(Lit(collect))
else:
if verbose:
print "%sInput exhausted at pos %d, no collected text" % (indent(level), i)
if verbose:
print "%sLeave descendingparse, return(%d, %r) s[%d:] = '%s' " % (indent(level), i, node, i, s[i:])
return i, node
class Ovotemplate(object):
def __init__(self, s):
_, self.root = descendingparse(s)
def pprint(self):
pprint.pprint(self.root)
def render(self, vars):
return self.root.render(vars)
if __name__ == "__main__":
basictests = (
# Basic tests.
("", {}), # pathetisch geval: lege template
("a", {}), # een lettertje
("hallo daar hoe gaat het ermee?", {}), # langere string
("{=status}", {"status": "STATUS"}), # simpele substitutie
("{=status}", {"status": 67.2334}),
("ERVOOR{=status}", {"status": "STATUS"}),
("{=status}ERNA", {"status": "STATUS"}),
# twee substituties zonder tekst ertussen:
("{=one}{=two}", {"one": "ONE", "two": "TWO"}),
# twee substituties met tekst ertussen:
("{=one}AND{=two}", {"one": "ONE", "two": "TWO"}),
# twee substituties met spatie ertussen:
("{=one} {=two}", {"one": "ONE", "two": "TWO"}),
# twee substituties met spaties ertussen
("{=one} {=two}", {"one": "ONE", "two": "TWO"}),
# twee substituties met tekst en spatie ertussen
("{=one}, {=two}", {"one": "ONE", "two": "TWO"}),
# twee substituties met spatie en tekst ertussen
("{=one} ({=two})", {"one": "ONE", "two": "TWO"}),
# simpele repetities
("{#cls{=co}}", {"cls": ({"co": "red"}, {"co": "gr"}, {"co": "bl"})}),
("{#cls ({=co})}", {"cls": ({"co": "red"}, {"co": "gr"}, {"co": "bl"})}),
("{#cls {=co}, }", {"cls": ({"co": "red"}, {"co": "gr"}, {"co": "bl"})}),
("{#cls {=co} x }", {"cls": ({"co": "red"}, {"co": "gr"}, {"co": "bl"})}),
("{#cls {=co} _}", {"cls": ({"co": "red"}, {"co": "gr"}, {"co": "bl"})}),
# substitutie met tekst ertussen
("well{=here}it{=goes}with{=some}test", {"here": "HERE", "goes": "GOES", "some": "SOME"}),
# repeat met variabele als laatste op de regel
("{#blop\n{=you}\n}", dict(blop=(dict(you=123),dict(you=456)))),
# simple conditions
("throw a {?condition big }party", {"condition": True}),
("throw a {?condition big }party", {"condition": False}),
# repeats
("{#a{=b}{=c}}", {"a": ({"b": 11, "c": 22},)}), # the comma behind the } is significant - without it, the dict wouldn't be placed in a tuple.
("{#a {=b} {=c}}", {"a": [{"b": 33, "c": 44}]}), # or use a list instead of a tuple
("{#a STA{=b}STO BEG{=c}END }", {"a": ({"b": 55, "c": 66},)}),
("{#a {=b} {=c}}", {"a": ({"b": 7.70, "c": 88}, {"b": 99, "c": 1.234567})}),
("{#a {=b} {=c}}", {"a": ()}),
("{#kleuren {=kleur}{/komma , }}", dict(kleuren=(dict(kleur="rood"),dict(kleur="groen"),dict(kleur="blauw")))),
# this is an error ("{#a {=b} {=c}}", {}),
)
tests = (
("buy {=count} articles: {#articles {=nam} txt {=pri}, }", {"count": 2, "articles": ({"nam": "Ur", "pri": 1}, {"nam": "Mo", "pri": 2})}),
("dear {=name}, {?market Please get the following groceries: \n \
{#groceries Item: {=item}, {=count} pieces \n }} \
{?deadline Be back before {=time}.}",
{"name": "Joe",
"market": "True",
"count": 5,
"groceries": [dict(item="lemon", count=2), dict(item="cookies", count=4)],
"deadline": True,
"time": "17:30",
}
), # condition with repeat
("Contents: {#chapters Chapter {=name}. {#sections Section {=name}. }",
{"chapters": [
dict(name="Intro", sections=[dict(name="Foreword"), dict(name="Methodology")]),
dict(name="Middle", sections=[dict(name="Measuring"), dict(name="Calculation"), dict(name="Results")]),
dict(name="Epilogue", sections=[dict(name="Conclusion")])
]
}), # Nested repeats.
)
for tems, temv in basictests+tests:
print "Testcase: template = \"%s\", variables = %r" % (tems, temv)
tem = Ovotemplate(tems)
print
tem.pprint()
print "Expansion: \"%s\"" % tem.render(temv)
print
tp = u"""
Beste {=naam},
Bedankt voor je bestelling. De volgende artikelen
zullen we zo snel mogelijk opsturen:
{#levering
{=artikel} €{=prijs} * {=aantal} stuks = €{=regtot}
}
{?backorder De volgende artikelen zijn nog in backorder:
{#backartikels
{=artikel} €{=prijs} * {=aantal} stuks
}
}
{?buitenland Voor buitenlandse bestellingen wordt {=porto} euro in rekening gebracht.}
"""
vars = {
"naam": "Jan",
"levering": (
{"artikel": "Bestekset", "prijs": 35, "aantal": 1, "regtot": 35},
{"artikel": "Schaal", "prijs": 10, "aantal": 2, "regtot": 20},
{"artikel": "Theelepels", "prijs": 1.15, "aantal": 5, "regtot": 5},
),
"backorder": True,
"backartikels": (
{"artikel": u"Théédoek", "prijs": 2.50, "aantal": 2},
{"artikel": "Pannenset", "prijs": 79.95, "aantal": 1},
),
"buitenland": False,
"porto": "5 euro",
}
tem = Ovotemplate(tp)
tem.pprint()
content = tem.render(vars)
print content
Comments
Michiel • 25 Apr 2011
However, it is already useful.
Cees Joppe • 14 Aug 2011
Thanx for sharing your clever template Michiel!
Pavel • 26 Nov 2011
GREAT engine! One question: How can you enter a '{' or a '}' into the template for expansion in the out? Thanks!
Bogomil • 20 Mar
I do the following change on rendering all children: output = [] output = [child.render(vars, last, level+1) for child in self] return "".join(output) Now the string operations are much, much faster
Michiel • 21 Dec 2010
This is not complete yet!