How do you walk a string.templatelib.Template to render it with per-value escaping?
answer
- One loop, one isinstance check
- Literal chunks are not input
- Iteration interleaves the parts for you
- Format before escaping, never after
- One renderer per target grammar
basics
~20 sIterate the Template: it yields the literal string chunks and Interpolation objects in source order, skipping empty chunks. Append the literal chunks unchanged, format and escape each interpolation's value for the target syntax, then join the pieces into one str.
solid answer
~40 sA `Template` from **Python 3.14** stores `.strings`, the literal chunks from the source, and `.interpolations`, one `Interpolation` per `{...}`; `len(.strings)` is always `len(.interpolations) + 1`, with empty strings padding the ends. Rather than zipping those tuples, iterate the template -- it yields the chunks and `Interpolation` objects already interleaved, skipping empty chunks -- and branch on `isinstance(part, Interpolation)`. Literal chunks pass through untouched, because they came from your own source. For an interpolation, apply `.conversion` and `.format_spec` first with `format(part.value, part.format_spec)`, then escape the resulting text for the grammar you are building: `html.escape` for markup, `shlex.quote` for a shell word, driver-level parameter binding for a query. Finally `"".join(...)`. There is no universal escaper, so you write one renderer per target syntax.
code
python · 17 linesimport html
from string.templatelib import Interpolation, Template
def render_html(template: Template) -> str:
parts: list[str] = []
for part in template:
if isinstance(part, Interpolation):
text = format(part.value, part.format_spec)
parts.append(html.escape(text))
else:
parts.append(part)
return "".join(parts)
comment = '<b>hi</b> & "bye"'
print(render_html(t"<p>{comment}</p>"))go deeper
Know that a Template has to be rendered by a function you write or import, and that iterating it hands you the literal pieces and the interpolations in order. Being able to sketch the loop is enough at this level.
Explain the mechanics precisely: the strings/interpolations length invariant, what iteration skips, and why literal chunks are never escaped while values always are. Be ready to write the ten-line renderer on a whiteboard.
Demonstrate judgement about ordering and targets: conversion, then format spec, then escape; a separate renderer per grammar; and query building that emits placeholders plus values rather than substituting text at all. Say how you would test a renderer.
Own where renderers live. Argue for a small reviewed set of them behind your own APIs rather than one per team, and for the boundary at which you render to str because a downstream library only accepts strings -- that is where the guarantee stops.
Rendering is where t-strings earn their keep, because a `string.templatelib.Template` object -- new in **Python 3.14** with PEP 750 -- deliberately has no rendered form of its own. Somebody has to write the function that turns one into a `str`, and that function is where every escaping decision lives. ### The shape of the object A `Template` stores the literal text and the substituted values apart: * `.strings` is a tuple of the literal chunks typed in the source. * `.interpolations` is a tuple of `string.templatelib.Interpolation` objects, one per `{...}`. `len(.strings)` is always `len(.interpolations) + 1`, with empty strings padding the ends. `t"{user}"` therefore has `.strings == ("", "")`, and `t"id={user}"` has `("id=", "")`. Relying on that invariant is safe; assuming the two tuples are the same length is a bug. Each `Interpolation` exposes `.value` (the evaluated result), `.expression` (the source text of the expression), `.conversion` (`"r"`, `"s"`, `"a"` or `None`) and `.format_spec` (the text after the colon, or an empty string). ### Two ways to walk it You can zip the two tuples yourself, alternating a chunk and an interpolation, but the ergonomic form is to iterate the template directly. `Template` implements `__iter__`, and iteration yields the literal chunks and the `Interpolation` objects interleaved in source order, skipping the empty chunks. A renderer collapses to a single loop with one `isinstance` test: ```python import html from string.templatelib import Interpolation, Template def render(template: Template) -> str: out = [] for part in template: if isinstance(part, Interpolation): out.append(html.escape(format(part.value, part.format_spec))) else: out.append(part) return "".join(out) ``` ### The literal chunks are never escaped This is the rule people get backwards. The chunks in `.strings` came from the programmer's own source file; they are the markup, the command skeleton, the query shape. Escaping them would corrupt the output -- an HTML renderer that escapes its own `<p>` tags produces visible entity text instead of a paragraph. Only the interpolated values are escaped, because only they came from outside. ### Conversion and format spec are data, not behaviour A t-string parses `!r` and `:.3f` exactly as an f-string does, but it does not apply them. They arrive as `.conversion` and `.format_spec` for the renderer to honour, and a renderer that ignores them silently changes what the source asked for. Consider an archiver that logs `t"archived {count} transcripts in {elapsed:.3f}s"`. A renderer that skips `format_spec` and calls `str()` on the value emits the full binary-float expansion instead of three decimals, and totals assembled from those log lines drift away from the ones the source intended. Honouring the spec costs one call. Order matters: apply the conversion first, then the format spec, then escape. `format(part.value, part.format_spec)` produces the display text; escaping *that* text is what makes it safe for the target syntax. Escape first and the formatter is free to mangle your work -- an alignment or truncation spec can slice an escape sequence in half, and a numeric spec can discard quoting you added. ### One renderer per target syntax There is no universal escaper, and a renderer that tries to be one is worse than no renderer at all. HTML wants entity encoding via `html.escape`. A shell word wants `shlex.quote`, which wraps the value in single quotes and neutralises everything inside. A database driver wants parameter binding, not text substitution at all -- in that case the "renderer" produces a query with placeholders plus a separate tuple of values, and the `Template` is a convenient carrier for both. So the signature you write is `render_html(t: Template) -> str`, `render_shell(t: Template) -> str`, and so on. Each knows exactly one grammar. That is the payoff of the design: the escaping logic exists once, in a function that can be reviewed and tested on its own, instead of being re-derived at every call site. ### Testing a renderer Because the input is a structured object, renderers are unusually easy to test. Build a template with a hostile value, call the renderer, and assert on the output string. Assert too that the literal chunks survived unescaped, which catches the classic over-escaping regression. And assert that a value carrying a format spec comes out formatted, which catches the silent drift described above. ### What the renderer cannot do It cannot recover anything the template did not keep. The values were evaluated eagerly when the literal was evaluated, so a renderer sees results, not expressions to re-run -- `.expression` is source text for diagnostics, not something to evaluate. And it cannot fix a template that was built by concatenating an already-rendered string: once text is flat, the boundary is gone.
- In a renderer, do you escape the value before or after applying its format spec?Format first, escape second. `format(part.value, part.format_spec)` produces the display text, and escaping that text is what makes it safe for the target grammar. Escape first and the formatter can undo the work -- an alignment or truncation spec can cut an escape sequence in half, and a numeric spec can discard quoting you added. The conversion (`!r`, `!s`, `!a`) is applied before the format spec, in the same order an f-string would use.
- What breaks if a renderer ignores .format_spec entirely?The output stops matching what the source asked for, silently. An archiver logging `t"archived {count} transcripts in {elapsed:.3f}s"` through a renderer that just calls `str()` on the value emits the full binary-float expansion instead of three decimals, and any totals reassembled from those log lines drift from the intended figures. Honouring the spec is one `format()` call, and it is the difference between a renderer and a broken one.
- Why iterate the template instead of zipping .strings with .interpolations yourself?Both work, and the invariant makes zipping safe: `len(.strings)` is always `len(.interpolations) + 1`, with empty strings padding the ends. Iteration is just the ergonomic form -- it yields the parts already interleaved in source order and drops the empty chunks, so the renderer is one loop with an `isinstance` check instead of an index dance. Reach for the tuples when you need something other than left-to-right assembly, such as emitting placeholders plus a separate values tuple.
A renderer is a border officer: the paperwork you printed yourself passes through untouched, and every traveller arriving from outside gets stamped for the country they are entering -- a different stamp for HTML than for a shell.
saying these in an interview costs you the question
- Calls str() on the whole Template and escapes the result
- Writes one universal escaper for HTML, shell and queries alike
- Escapes the interpolated value before applying its format spec
- Escapes the literal chunks along with the values
- Assumes .strings and .interpolations always have equal length