Fix Three Planted Bugs in a Tiny Python Template Engine
StripeSoftware Engineer · Technical ScreenLast updated
Trace three reported failures in a small template engine to their root causes (a greedy regex, a nesting-unaware block parser and a path-traversal hole), fix each minimally and add regression tests. Tests systematic debugging across tokenizer, parser and loader layers.
You inherit tinytpl, a 100-line template engine used to render notification emails. A teammate reports three failures and suspects that bugs slipped in during a refactor. Find each root cause, fix it with the smallest correct change to the library (not the callers), and add a regression test for each fix.
How the engine works
tokenizesplits the source into text and three kinds of tags:{{ expr }}(expression with optional|filterchain),{% ... %}(control tagsfor/endforandif/endif), and{# ... #}(comments).parsebuilds a tree of("text", ...),("expr", ...),("for", ...)and("if", ...)nodes.render_nodeswalks the tree with a context dict.Loader(root).load(name)reads a template file that must live insideroot.
import os
import re
TOKEN_RE = re.compile(r"(\{\{.*\}\}|\{%.*?%\}|\{#.*?#\})", re.S)
FILTERS = {"upper": str.upper, "lower": str.lower, "trim": str.strip}
class TemplateError(Exception):
pass
def tokenize(src):
return [t for t in TOKEN_RE.split(src) if t]
def parse(tokens, pos=0, until=None):
"""Return (nodes, next_pos). Stops when it meets a tag named in `until`."""
nodes = []
while pos < len(tokens):
tok = tokens[pos]
if tok.startswith("{#"):
pos += 1
elif tok.startswith("{{"):
nodes.append(("expr", tok[2:-2].strip()))
pos += 1
elif tok.startswith("{%"):
words = tok[2:-2].split()
if until and words[0] in until:
return nodes, pos
if words[0] == "for": # {% for x in items %}
end = tokens.index("{% endfor %}", pos)
body, _ = parse(tokens[pos + 1:end])
nodes.append(("for", words[1], words[3], body))
pos = end + 1
elif words[0] == "if": # {% if cond %}
body, pos = parse(tokens, pos + 1, until=("endif",))
nodes.append(("if", words[1], body))
pos += 1
else:
raise TemplateError(f"unknown tag {words[0]!r}")
else:
nodes.append(("text", tok))
pos += 1
if until:
raise TemplateError(f"missing {until[0]}")
return nodes, pos
def lookup(ctx, dotted):
val = ctx
for part in dotted.split("."):
val = val[part] if isinstance(val, dict) else getattr(val, part)
return val
def eval_expr(expr, ctx):
name, *filters = [p.strip() for p in expr.split("|")]
val = str(lookup(ctx, name))
for f in filters:
val = FILTERS[f](val)
return val
def render_nodes(nodes, ctx):
out = []
for node in nodes:
kind = node[0]
if kind == "text":
out.append(node[1])
elif kind == "expr":
out.append(eval_expr(node[1], ctx))
elif kind == "for":
_, var, seq, body = node
for item in lookup(ctx, seq):
out.append(render_nodes(body, {**ctx, var: item}))
elif kind == "if":
_, cond, body = node
if lookup(ctx, cond):
out.append(render_nodes(body, ctx))
return "".join(out)
def render(src, ctx):
nodes, _ = parse(tokenize(src))
return render_nodes(nodes, ctx)
class Loader:
def __init__(self, root):
self.root = root
def load(self, name):
path = os.path.join(self.root, name)
if not os.path.isfile(path):
raise TemplateError(f"template not found: {name}")
with open(path, encoding="utf-8") as fh:
return fh.read()
Reported symptoms
Symptom 1. Two expressions on one line crash:
>>> render("Hi {{ first }} {{ last|upper }}!", {"first": "Ada", "last": "Byron"})
KeyError: 'first }} {{ last'
Expected output: Hi Ada BYRON!
Symptom 2. Nested loops crash:
>>> render("{% for row in grid %}[{% for c in row %}{{ c }}{% endfor %}]{% endfor %}",
... {"grid": [[1, 2], [3]]})
ValueError: list.index(x): x not in list
Expected output: [12][3]
Symptom 3. A security review found that Loader("/srv/app/templates").load("../config/secrets.env") happily returns the file's contents. So does passing an absolute path. Template names come from a database column that support staff can edit.
Constraints
- Fix one bug at a time, and explain how you traced each symptom to its cause before changing code.
- Keep the public API unchanged.
- Your fixes must not break existing behavior: comments,
ifblocks, filters and dotted lookups. - Standard library only.
Deliverables
For each bug: the root cause in one or two sentences, the diff, and a regression test. Finally, name one more latent issue you noticed but did not fix.
Hints
Hint 1
Before reading the renderer, print the token list for each failing template. Does it look right?
Hint 2
Compare how the parser finds the end of an if block with how it finds the end of a for block.
Hint 3
What does os.path.join do when its second argument is absolute, or contains '..'?
Hint 4
A string startswith check on paths has its own bug. Look at os.path.commonpath.
Follow-up questions
- Add auto-escaping for HTML output with an explicit opt-out. Where in the pipeline should it happen?
- How would you compile templates to Python functions and cache them, and how do you invalidate the cache when files change?
- Add {% else %} support for both if and for (render when the loop is empty).
- How would you report template errors with the line and column of the offending tag?
Solution walkthrough is for members
Get the full walkthrough with reference code and complexity analysis, plus Kitty Coach at 300 messages a day. Hints, the code runner and community answers stay free.