Field selection and CSV cover most scripting needs, but there is one request they handle awkwardly: "I just want one line per item that says web-1 is hot (91%)". Docker answers it with --format '{{.Names}}', kubectl with -o jsonpath and -o custom-columns, the GitHub CLI with --template. A Python CLI can offer the same with syntax its users already know — the replacement fields of str.format — as long as it closes the one hole that makes str.format on user input dangerous. This guide builds a --format option that is expressive, validated up front and safe. It belongs to the output formats topic, next to selecting fields and columns.
Prerequisites
- Python 3.10+ and Typer or Click.
- Commands that return records (dictionaries with raw values) and a declared list of available fields.
- Familiarity with format specs such as
{x:>8}and{x:.1%}from f-strings.
Why not just call template.format(**record)
Python's format mini-language is powerful, which is the problem. A replacement field can reach attributes and items of the value it formats: {name.__class__} evaluates record["name"].__class__, and chains of __class__, __init__ and __globals__ can walk from any object to module globals. If a record ever contains a richer object than a string — a model instance, a Path, a client — a user-supplied template can read things it should not, and in a CLI that runs in CI with secrets in the environment, "only the user can type it" is not a strong guarantee.
The fix is not to sanitise the string but to stop delegating field resolution to Python. string.Formatter().parse() splits a template into literal text and fields without evaluating anything. Your code then decides what each field name means: a dotted path into the record and nothing else.
The recipe
# src/fleet/template.py
from __future__ import annotations
import re
import string
from collections.abc import Callable, Sequence
from typing import Any
_PARSER = string.Formatter()
_ESCAPES = {"t": "\t", "n": "\n", "\\": "\\"}
class TemplateError(ValueError):
"""Raised for a template the user must fix."""
def _unescape(template: str) -> str:
# Shells pass '\t' through literally; turn the common escapes into characters.
return re.sub(r"\\([tn\\])", lambda m: _ESCAPES[m.group(1)], template)
def _lookup(record: dict[str, Any], path: str) -> Any:
current: Any = record
for part in path.split("."):
if not isinstance(current, dict) or part not in current:
return None
current = current[part]
return current
def compile_template(template: str, available: Sequence[str]) -> Callable[[dict[str, Any]], str]:
"""Validate the template once and return a function that renders one record."""
pieces = []
for literal, field, spec, conversion in _PARSER.parse(_unescape(template)):
if field is None: # trailing literal text
pieces.append((literal, None, "", None))
continue
if field == "" or field.isdigit():
raise TemplateError("use field names such as {name}, not {} or {0}")
if "[" in field or "]" in field:
raise TemplateError(f"indexing is not supported: {{{field}}}")
if field not in available:
raise TemplateError(f"unknown field {field!r}; available: {', '.join(available)}")
if "{" in (spec or ""):
raise TemplateError("nested fields inside a format spec are not supported")
pieces.append((literal, field, spec or "", conversion))
def render(record: dict[str, Any]) -> str:
out: list[str] = []
for literal, field, spec, conversion in pieces:
out.append(literal)
if field is None:
continue
value = _lookup(record, field)
if value is None:
value = ""
if conversion == "r":
value = repr(value)
elif conversion in ("s", "a"):
value = str(value) if conversion == "s" else ascii(value)
try:
out.append(format(value, spec))
except (TypeError, ValueError) as exc:
raise TemplateError(f"cannot format {field!r} with {spec!r}: {exc}") from None
return "".join(out)
return render
Every field name is checked against the declared list, so {name.__class__} fails validation as an unknown field — the dotted path name.__class__ is simply not one of the available fields. Attribute access never happens because _lookup only indexes dictionaries. Format specs and conversions keep working, because they are applied with the built-in format() on an already-resolved plain value.
Compiling once and returning a closure has two benefits: errors in the template are reported before any output is written, and rendering a million records does not re-parse the template each time.
Wiring it into a command is short. The template takes over the whole line, so it is mutually exclusive with --output:
# src/fleet/cli.py
from typing import Annotated, Optional
import typer
from fleet.template import TemplateError, compile_template
app = typer.Typer()
SERVERS = [
{"name": "web-1", "cpu": 0.42, "status": "ok", "owner": {"email": "web@example.com"}},
{"name": "web-2", "cpu": 0.91, "status": "hot", "owner": {"email": "web@example.com"}},
]
AVAILABLE = ["name", "cpu", "status", "owner.email"]
@app.callback()
def main() -> None:
"""Manage the fleet."""
@app.command("list")
def list_servers(
fmt: Annotated[Optional[str], typer.Option(
"--format",
help="Template per line, e.g. '{name}\\t{cpu:.0%}'. Fields: " + ", ".join(AVAILABLE),
)] = None,
) -> None:
"""List servers."""
if fmt is None:
for s in SERVERS:
typer.echo(s["name"])
return
try:
render = compile_template(fmt, AVAILABLE)
for s in SERVERS:
typer.echo(render(s))
except TemplateError as exc:
raise typer.BadParameter(str(exc), param_hint="--format") from None
if __name__ == "__main__":
app()
Templates in real pipelines
The point of a template is to produce exactly the text another command wants, without an intermediate parsing step. A few patterns cover most of what users do with it:
# One SSH target per line, ready for a loop or xargs
fleet list --format '{name}.internal' | xargs -n1 -P4 ssh-keyscan
# A Markdown table row per server, for a status page
fleet list --format '| {name} | {status} | {cpu:.0%} |' >> STATUS.md
# Environment-variable style output that a shell can source
fleet list --format 'SERVER_{name}={owner.email}' > servers.env
# Tab-separated values for a while-read loop
fleet list --format '{name}\t{status}' | while IFS=$'\t' read -r name status; do
[ "$status" = hot ] && echo "investigate $name"
done
Each of these would need jq or awk with JSON or CSV output. With a template, the shape is right at the source. Two cautions apply. Values are inserted verbatim, so a template that builds shell commands or KEY=value lines inherits whatever characters the data contains — never eval template output built from untrusted values. And a template is a per-record format, so it does not escape anything for you; if the consumer needs quoting, use CSV or JSON instead.
When a template grows long, it is a sign that users want a saved view. Some tools let users store named templates in their config file ([templates] hot = "{name} is hot ({cpu:.0%})") and select them with --format @hot; the same compiled-template function serves both forms.
UX considerations
- Use single quotes in examples.
'{name}\t{cpu}'survives bash, zsh and fish untouched; double quotes invite the shell to interpret\tand$. PowerShell users need single quotes too. - List the fields in the help text, as above, or offer
--format helpthat prints them. The template is useless if users have to guess names. - Format specs are the power feature.
{name:<12}aligns columns,{cpu:.0%}prints a percentage,{size:,}adds thousands separators. Show two or three in the docs and people will find the rest. - Missing values render as empty. A record without
owner.emailshould produce an empty field, notNoneand not an error, so a template works across heterogeneous records. - Do not invent a second syntax. Go-template-style
{{.Name}}is familiar from Docker, but supporting it means writing and documenting a parser. Python users already know{name}.
Testing the behaviour
The interesting tests are the rejections. Pin them so a refactor never quietly reopens attribute access:
# tests/test_template.py
import pytest
from typer.testing import CliRunner
from fleet.cli import app
from fleet.template import TemplateError, compile_template
AVAILABLE = ["name", "cpu", "owner.email"]
REC = {"name": "web-1", "cpu": 0.42, "owner": {"email": "a@example.com"}}
def test_specs_escapes_and_dotted_fields():
render = compile_template(r"{name:<6}|{cpu:.0%}\t{owner.email}", AVAILABLE)
assert render(REC) == "web-1 |42%\ta@example.com"
@pytest.mark.parametrize("bad", ["{name.__class__}", "{0}", "{}", "{name[0]}", "{name:{w}}"])
def test_dangerous_or_ambiguous_fields_are_rejected(bad):
with pytest.raises(TemplateError):
compile_template(bad, AVAILABLE)
def test_missing_value_renders_empty():
assert compile_template("[{owner.email}]", AVAILABLE)({"name": "x"}) == "[]"
def test_non_ascii_survives_unescaping():
assert compile_template("é {name}", AVAILABLE)(REC) == "é web-1"
def test_bad_spec_is_a_usage_error():
result = CliRunner().invoke(app, ["list", "--format", "{name:.2f}"])
assert result.exit_code == 2
The non-ASCII test is there for a reason: the tempting shortcut codecs.decode(template, "unicode_escape") handles \t but mangles any character outside Latin-1, which is why the recipe unescapes only the three sequences shells leave behind.
Conclusion
A --format template gives users exact control over each output line with syntax they already know. Parse it with string.Formatter().parse(), resolve fields yourself against a declared list, apply specs with format(), and compile once before writing any output. That keeps the feature as safe as --fields while covering the one-line-per-record jobs that tables, JSON and CSV make clumsy.
Frequently asked questions
Can templates span multiple lines or include headers?
Use \n inside the template for multi-line records. For a header, print a separate --header string first, or document printf 'NAME\tCPU\n'; mytool list --format .... Keeping the template strictly per-record keeps the implementation simple.
Should I support Jinja2 templates instead?
Only if users need loops and conditionals in output, which is rare for a listing command. Jinja2 is a large dependency, and its sandbox must be configured carefully for user-supplied templates. A format-string template plus JSON output and jq covers almost every real case.
What about kubectl-style custom columns?
--fields already produces columns. If you want user-defined headers, accept HEADER:field pairs (--columns NAME:name,CPU:cpu) and feed them to the table renderer; it is a thin layer over field selection.
Is string.Template a safer alternative?
string.Template ($name) cannot access attributes, so it is safe by construction, but it has no format specs and no dotted names. The approach here keeps both features while staying just as safe.