Skip to content

Instantly share code, notes, and snippets.

@tobiashochguertel
Last active May 21, 2026 10:23
Show Gist options
  • Select an option

  • Save tobiashochguertel/e1a800f5f42f37c758eeab7f727b464b to your computer and use it in GitHub Desktop.

Select an option

Save tobiashochguertel/e1a800f5f42f37c758eeab7f727b464b to your computer and use it in GitHub Desktop.
Reusable OutputRenderer for Typer CLIs — human/json/jsonl/yaml output via --format flag
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "typer>=0.15",
# "rich>=13.0",
# "pyyaml>=6.0",
# "pydantic>=2.0",
# ]
# ///
"""
typer-output-renderer.py
A reusable OutputRenderer for Typer CLIs that supports multiple output formats
via a global --format flag. Drop this into any Typer app, add the renderer to
your AppContext, and get human/json/jsonl/yaml output for free.
Usage:
./typer-output-renderer.py --help
./typer-output-renderer.py list --format json
./typer-output-renderer.py list --format yaml
Integration:
1. Copy OutputRenderer into your project
2. Add to your AppContext dataclass
3. Wire --format into the callback
4. Replace console.print(data) with cfg.output.print(data)
5. Pass lists of dicts for machine-readable output
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Annotated, Any, Protocol, runtime_checkable
import typer
import yaml
from pydantic import BaseModel
from rich.console import Console
console = Console()
OUTPUT_FORMATS = ["human", "json", "jsonl", "yaml"]
# ── Renderable protocol ──────────────────────────────────────────────────────
@runtime_checkable
class Renderable(Protocol):
"""Anything renderable by Rich (has ``__rich_console__``).
Any Rich renderable satisfies this without importing ``Table``:
``Table``, ``Text``, ``Panel``, ``Syntax``, ``Columns``, etc.
For machine formats (json/jsonl/yaml), Renderables are silently
ignored — use ``OutputTable`` or raw ``list[dict]`` instead.
"""
def __rich_console__(self, console: object, options: object) -> Any: ...
# ── OutputTable ──────────────────────────────────────────────────────────────
class OutputTable:
"""Tabular data that renders as Rich Table (human) or dicts (machine).
The recommended way to pass tabular data to ``OutputRenderer.print()``.
Use exactly like ``rich.table.Table`` — same ``add_column`` and
``add_row`` signatures — but the renderer can emit the data as
JSON/JSONL/YAML without needing a separate ``list[dict]``.
``OutputTable`` is the **only** tabular type that works for both
human and machine output. Raw Rich ``Table`` objects only render
in human mode (silently ignored in machine formats).
Usage:
tbl = OutputTable("Resources")
tbl.add_column("ID", style="cyan")
tbl.add_column("Name")
tbl.add_row("R01", "alpha")
output.print(tbl) # human → Rich table, json/yaml → dicts
"""
def __init__(self, title: str = ""):
from rich.table import Table
self.table = Table(title=title)
self._headers: list[str] = []
self.rows: list[dict[str, str]] = []
def add_column(self, *args, **kwargs) -> None:
name = kwargs.get("header", args[0] if args else "")
self._headers.append(str(name))
self.table.add_column(*args, **kwargs)
def add_row(self, *args, **kwargs) -> None:
self.table.add_row(*args, **kwargs)
row = {}
for i, val in enumerate(args):
key = self._headers[i] if i < len(self._headers) else str(i)
row[key] = str(val)
self.rows.append(row)
# ── The Renderer ─────────────────────────────────────────────────────────────
class OutputRenderer:
"""Central CLI output handler supporting multiple formats.
``.fmt`` can be ``"human"``, ``"json"``, ``"jsonl"``, or ``"yaml"``.
Change it at runtime to switch formats::
output.fmt = "yaml"
output.print(data)
Supported ``data`` types and their behavior::
OutputTable human → Rich Table machine → list[dict]
Renderable human → rendered machine → ignored
list[dict] human → str(list) machine → formatted
dict human → str(dict) machine → formatted
BaseModel human → str(model) machine → .model_dump()
str human → printed machine → printed
``OutputTable`` is the only type that works for **both** human and
machine output from the same data. Raw Rich ``Table`` objects only
render in human mode.
The ``title`` parameter is reserved for future use (e.g. YAML
document separators like ``---``). Pass it now to avoid churn
when it is wired up later.
"""
def __init__(self, fmt: str = "human"):
self.fmt = fmt
def print(self, data: Renderable | OutputTable | str | list | dict | BaseModel, title: str = "") -> None:
if isinstance(data, OutputTable):
if self.fmt == "human":
console.print(data.table)
else:
self._print_machine(data.rows)
return
if self.fmt == "human":
if isinstance(data, Table):
console.print(data)
else:
console.print(data)
return
self._print_machine(data)
def _print_machine(self, data: Renderable | str | list | dict | BaseModel) -> None:
"""Internal: render data as JSON/JSONL/YAML."""
if isinstance(data, Renderable):
return
if isinstance(data, BaseModel):
data = data.model_dump()
if self.fmt == "json":
console.print(json.dumps(data, indent=2, default=str))
elif self.fmt == "jsonl":
items = data if isinstance(data, list) else [data]
for item in items:
console.print(json.dumps(item, default=str))
elif self.fmt == "yaml":
console.print(yaml.safe_dump(data, default_flow_style=False, allow_unicode=True))
# ── Demo App ─────────────────────────────────────────────────────────────────
app = typer.Typer(no_args_is_help=True, pretty_exceptions_enable=False)
@app.callback()
def callback(
ctx: typer.Context,
format: Annotated[str, typer.Option("--format", "-f", help="Output format")] = "human",
) -> None:
if format not in OUTPUT_FORMATS:
console.print(f"[red]Invalid format:[/red] {format}")
console.print(f"Valid: {', '.join(OUTPUT_FORMATS)}")
raise typer.Exit(2)
ctx.obj = OutputRenderer(format)
@app.command()
def list(
ctx: typer.Context,
name: Annotated[str | None, typer.Option("--name", "-n", help="Filter by name")] = None,
) -> None:
"""List demo items with output format support."""
items = [
{"id": "R01", "name": "alpha", "tags": "docs, guide"},
{"id": "R02", "name": "beta", "tags": "tool, cli"},
{"id": "R03", "name": "gamma", "tags": "plugin, sdk"},
]
if name:
items = [i for i in items if name.lower() in i["name"].lower()]
output: OutputRenderer = ctx.obj
table = OutputTable(title="Demo Items")
table.add_column("ID", style="cyan")
table.add_column("Name", style="green")
table.add_column("Tags", style="yellow")
for item in items:
table.add_row(item["id"], item["name"], item["tags"])
output.print(table)
@app.command()
def show(
ctx: typer.Context,
item_id: str = typer.Argument(..., help="Item ID"),
) -> None:
"""Show a single item."""
items = {
"R01": {"id": "R01", "name": "alpha", "tags": ["docs", "guide"], "url": "https://example.com/alpha"},
"R02": {"id": "R02", "name": "beta", "tags": ["tool", "cli"], "url": "https://example.com/beta"},
}
item = items.get(item_id.upper())
if not item:
console.print(f"[red]Not found:[/red] {item_id}")
raise typer.Exit(1)
output: OutputRenderer = ctx.obj
output.print(item)
if __name__ == "__main__":
app()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment