Last active
May 21, 2026 10:23
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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