Typer CLI with Rich tables, progress and --json output: working versions (tested 2026-10-15)
A Typer command that renders Rich tables and progress on stderr, with a --json mode for piping, tested offline with CliRunner and no-color Rich output.
passed5 passed · 0 failed2026-10-15 13:26 UTC- Status
- passed
- Last passed
- 2026-10-15 13:26 UTC
- Tests
- 5 / 5
- Language
- Python
- Runtime
- Python 3.13.16
pinned versions3
typer@0.27.3rich@15.0.0pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
test environment
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38741369695
The code
Exactly the files that passed. Your agent can pull them with get_recipe("typer-rich-table-cli") (install), or see them on GitHub.
requirements.txt
typer==0.27.3 rich==15.0.0 pytest==9.1.1
cli.py
import json
from pathlib import Path
from typing import Annotated
import typer
from rich import box
from rich.console import Console
from rich.markup import escape
from rich.progress import track
from rich.table import Table
app = typer.Typer(add_completion=False)
def make_console(*, stderr: bool = False, color: bool = True) -> Console:
# Build the Console per invocation. A module-level Console binds to the
# real sys.stdout at import time and misses CliRunner's captured streams.
return Console(stderr=stderr, color_system="auto" if color else None, width=80)
@app.command()
def report(
path: Annotated[Path, typer.Argument(exists=True, dir_okay=False, readable=True)],
json_out: Annotated[bool, typer.Option("--json", help="Emit JSON instead of a table.")] = False,
plain: Annotated[bool, typer.Option("--plain", help="Disable color.")] = False,
) -> None:
rows = json.loads(path.read_text())
if json_out:
# Keep stdout pure JSON so it can be piped into other tools.
typer.echo(json.dumps(rows, indent=2))
return
# Progress goes to stderr so it never corrupts the table on stdout.
err = make_console(stderr=True, color=not plain)
table = Table(box=box.SIMPLE, show_edge=False)
table.add_column("name")
table.add_column("count", justify="right")
for row in track(rows, description="Building table", console=err, disable=not err.is_terminal):
# Escape user data so values like "[bold]" are printed literally.
table.add_row(escape(str(row["name"])), str(row["count"]))
make_console(color=not plain).print(table)
if __name__ == "__main__":
app()
tests/test_cli.py
import json
import pytest
from typer.testing import CliRunner
from cli import app
runner = CliRunner()
ROWS = [{"name": "alpha", "count": 3}, {"name": "beta", "count": 12}]
@pytest.fixture
def data(tmp_path):
p = tmp_path / "rows.json"
p.write_text(json.dumps(ROWS))
return p
def test_table_plain_has_no_ansi(data):
result = runner.invoke(app, [str(data), "--plain"])
assert result.exit_code == 0
assert "\x1b[" not in result.stdout
assert "alpha" in result.stdout and "12" in result.stdout
def test_table_lines_fit_80_columns(data):
result = runner.invoke(app, [str(data), "--plain"])
assert all(len(line) <= 80 for line in result.stdout.splitlines())
def test_json_stdout_is_pure_json(data):
result = runner.invoke(app, [str(data), "--json"])
assert result.exit_code == 0
assert json.loads(result.stdout) == ROWS
def test_markup_in_data_is_escaped(tmp_path):
p = tmp_path / "r.json"
p.write_text(json.dumps([{"name": "[bold red]x[/]", "count": 1}]))
result = runner.invoke(app, [str(p), "--plain"])
assert "[bold red]x[/]" in result.stdout
def test_missing_file_is_usage_error(tmp_path):
result = runner.invoke(app, [str(tmp_path / "nope.json")])
assert result.exit_code == 2
pytest.ini
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.