Input & UX

Custom Output Templates with a Format String in Python CLIs

Let users shape each output line with a --format template safely: str.format syntax, dotted fields, format specs, escapes, validation and tests.

Updated

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.

Ways to apply a user template A comparison of str.format on user input, string.Template, and parsing with string.Formatter while resolving fields yourself. Ways to apply a user template Approach Format specs Dotted fields Attribute access template.format(**rec) yes via attributes yes — unsafe string.Template no no no Formatter().parse + lookup yes yes, as dict paths no Parsing without evaluating keeps the power of the format mini-language and drops the risk.

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.

Compile once, render many The template is unescaped, parsed into literal text and fields, validated against available fields, and turned into a render function used for every record. Compile once, render many Unescape \t and \n only Parse literals + fields Validate known names only render() per record text pieces closure A bad template fails before the first line of output is written.

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 practice Terminal session using a format template with alignment and percentage specs, and the error for an attribute-access attempt. Templates in practice bash $ fleet list --format '{name:<8}{cpu:>5.0%} {owner.email}' web-1 42% web@example.com web-2 91% web@example.com $ fleet list --format '{name.__class__}' Error: Invalid value for --format: unknown field 'name.__class__' Format specs work as in f-strings; anything that is not a declared field is rejected.

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 \t and $. PowerShell users need single quotes too.
  • List the fields in the help text, as above, or offer --format help that 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.email should produce an empty field, not None and 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.