Skip to content

← Pattern Matching, f-Strings and Newer Syntax step 19 of 23

Hard End-to-End

Escape HTML and Build SQL Parameters with t-Strings

This exercise needs Python 3.14, which added template strings.

When you pass an f-string to a function, the function gets one finished string. It cannot tell your markup apart from what a user typed. A template string, written t"...", keeps the two apart. It gives you a Template object instead of a string:

from string.templatelib import Template

name = "<b>ada</b>"
template = t"<li>{name}</li>"
assert isinstance(template, Template)
assert template.strings == ("<li>", "</li>")
assert template.interpolations[0].value == name

The text outside the braces becomes the template's static strings, which template.strings holds. Each {...} becomes an interpolation, an object with four fields:

  • value, the result of the expression. Python evaluates it when it creates the template, as it does for an f-string.
  • expression, the source text, such as "name".
  • conversion, which is "r", "s", "a" or None.
  • format_spec, which is "" when there is none.

Looping over a template gives its static strings and interpolations in order, and skips empty static strings. A template escapes nothing by itself. The function that receives it decides what to do with each value, and you write two such functions.

What to write

render_html(template) -> str builds HTML:

  • Copy each static string unchanged.
  • For each interpolation, apply repr, str or ascii if its conversion asks for one. With no conversion, keep the value as it is.
  • Call format(converted, interpolation.format_spec).
  • Escape the result with html.escape(text, quote=True), and add it.
  • Join the pieces with nothing between them.

sql(template) -> tuple[str, list[object]] builds a query for a driver that uses ? placeholders:

  • Copy each static string unchanged.
  • Replace each interpolation with "?", and append its value to the parameter list, in order.
  • Ignore conversions and format specs. The database gets the original values.

"When Not to Use an f-String", earlier in this track, explains why the driver must get the values separately from the SQL.

solve(name, comment, min_age, score) writes three template literals and returns a dictionary with four keys:

Key Built with
"html" render_html(t"<li><b>{name}</b> said: {comment}</li>")
"detail" render_html(t"{name!r} scored {score:.2f}")
"sql", "params" The query and the list from sql(t"SELECT * FROM users WHERE name = {name} AND age > {min_age}")

With name "ada", comment "hello", min_age 18 and score 9.5, solve returns:

{
    "html": "<li><b>ada</b> said: hello</li>",
    "detail": "&#x27;ada&#x27; scored 9.50",
    "sql": "SELECT * FROM users WHERE name = ? AND age > ?",
    "params": ["ada", 18],
}

Edge cases

  • An empty string is still a value. It adds nothing to the HTML, and it still adds a parameter.
  • render_html escapes a name that contains <b>, and sql puts the same name in the parameters unchanged.
  • In "detail", repr runs before escaping, so its quote characters are escaped too.

Going further

Template is not a subclass of str, so mypy rejects an f-string passed to a function that expects a Template. That catches a slip, not an attack, because code can also build a Template by hand from any strings.

This renderer is safe for text between tags. A value inside a <script> block, a URL or an attribute name needs different handling, so use an HTML template library for those.

An older Python rejects the whole file if it contains a t"..." literal, even inside an if branch that never runs.