Render a TSV or JSON stream — or a list of dicts from Python — as a styled rich table. Works two ways: as a CLI filter at the end of a pipe, and as an importable library (render()) so other scripts get consistent table formatting from one place.
Created 2026-03-10 by Noah Coad w/ Claude Code.
This script + docs are published as a public gist. Update the SAME gist rather than creating a new one:
- ID:
5e27fea8f3c30b89ea0a04c9b2cb23d3 - URL: https://gist.github.com/noahcoad/5e27fea8f3c30b89ea0a04c9b2cb23d3
- The doc file is named
rich_table.readme.mdin the gist (sorich_table.pysorts first and becomes the gist's display title) — local file staysrich_table.md. - Re-push after edits (the
-f <gist-filename> <local-file>arg maps the local file onto the gist's filename):
gh gist edit 5e27fea8f3c30b89ea0a04c9b2cb23d3 -f rich_table.py rich_table.py
gh gist edit 5e27fea8f3c30b89ea0a04c9b2cb23d3 -f rich_table.readme.md rich_table.mdKeep table styling in ONE module. Any script that wants a nice table imports rich_table.render(...) instead of hand-rolling its own rich.Table. Update the look here (borders, box style, widths) and every caller picks it up automatically — no copy-paste.
- Python 3.12 (the file's shebang is
/opt/homebrew/bin/python3.12, but it's pure stdlib +rich). richinstalled for that interpreter:pip install rich.
--help prints a ready-to-paste alias recommendation tailored to your environment: it detects your shell (via $SHELL) and names the right rc file (~/.zshrc, ~/.bashrc, or ~/.config/fish/config.fish), uses the Python 3 interpreter currently running the file (so rich is guaranteed present), and shows this file's ~-relative path. Copy that line into your rc and source it. Example:
alias rtbl='python3 ~/code/py/misc/rich_table.py'Once aliased, all examples below work as rtbl ….
<cmd> | rich_table.py [OPTIONS] [COL_SPEC ...]
| Option | Meaning |
|---|---|
--json |
Parse stdin as JSON (array of objects, or a {"key": [...]} envelope); infer columns from field names |
--fields k1,k2 |
Comma-separated JSON field names to include, in order (--json only); default: all fields from the first object |
--markup |
Enable rich markup in cell values (e.g. [link=URL]text[/], [bold]…[/], semantic + custom-color tags — see Markup below) |
--demo |
Print reference tables (markup effects/colors/tags, color options, column specs, basic CLI examples) with copy-paste recipes, then exit. Ignores stdin. |
--header |
TSV mode: treat the first input row as column headers |
--sep CHAR |
Field separator for TSV mode (default: tab) |
--border COLOR |
Border color as hex, name, or rgb(...) (default: rgb(60,140,255)) |
--header-color COLOR |
Header text color, rendered bold (default: white) |
--text-color COLOR |
Cell text color for all columns (default: terminal default) |
--key-color COLOR |
Color for the first (key) column, overriding --text-color there (default: #AFD7FF) |
-h, --help |
Show help |
COLOR accepts any rich color: a name (red, cyan, bright_green), hex (#ff8800), or rgb(255,136,0).
Positional args override/define column headers and per-column options:
"Column Name"
"Column Name:no_wrap"
"Column Name:min_width=19"
"Column Name:max_width=40"
"Column Name:min_width=19,no_wrap"
Recognized opts: no_wrap, min_width=N, max_width=N.
With --markup, cell values are interpreted as rich markup. Run rich_table.py --demo for a live reference of everything below (source beside rendered result) plus color-option and column-spec recipes.
- Plain rich tags —
[bold],[italic],[underline],[strike],[dim],[reverse], colors by name ([red]), hex ([#9ECE6A]), or[rgb(94,192,211)], and links ([link=URL]text[/]). Close with the shorthand[/]. - Semantic tags (color by meaning):
[error]bold red,[good]bold green,[warn]bold yellow,[info]cyan,[mute]dim. - Custom palette (named colors → friendly tags):
[xgreen]#9ECE6A,[xpink]#FF87AF,[xorange]#FF9E64,[xmagenta]#F92472,[xcyan]#5EC0D3,[xlime]#A1DB2B,[xgrass]#52BF37,[xsand]#E7DB74. Defined inCUSTOM_COLORSnear the top of the module; raw hex ([#9ECE6A]…[/]) works too. - Hyperlinks —
[link=URL]text[/]makes text clickable (no color on its own). Barehttp(s)://…URLs in cells are auto-colored#6A71F7(theHYPERLINK_COLORconstant), and the[hlink]tag colors link text explicitly:[link=URL][hlink]text[/][/]. Auto-coloring only kicks in with--markup, and only touches URLs — numbers/data in other cells are left alone.
Terminal support. Coloring uses standard 24-bit ANSI truecolor — works in any modern terminal (iTerm2, Ghostty, Terminal.app, Alacritty, kitty, WezTerm, VS Code, tmux). Old/xterm-class terminals get auto-downsampled to the nearest 256/16 color. Clickable links use OSC 8 hyperlinks — supported by iTerm2, Ghostty, kitty, WezTerm, GNOME Terminal; where unsupported (e.g. Terminal.app) the link text still shows and is still colored, just not clickable. Piping/redirecting (non-TTY) strips color by design.
Gotcha — named theme tags don't combine inline. A theme name (semantic or custom-palette) only works as a standalone tag. [underline xorange]…[/] silently no-ops because rich won't merge a theme name with other style words. To combine an effect with a palette color, either use the inline hex ([underline #FF9E64]…[/]) or nest the tags ([underline][xorange]…[/][/]). Inline hex/named-color + effect ([bold #F92472], [bold red]) works fine — the restriction is only on theme names.
# TSV, explicit headers
printf 'alice\t30\nbob\t25\n' | rich_table.py "Name" "Age:no_wrap"
# TSV, first row is the header
printf 'name\tage\nalice\t30\n' | rich_table.py --header
# Custom separator
printf 'alice,30\nbob,25\n' | rich_table.py --sep , "Name" "Age"
# JSON array — columns inferred from keys
echo '[{"name":"alice","age":30},{"name":"bob","age":25}]' | rich_table.py --json
# JSON, pick + order specific fields
echo '[{"id":1,"title":"x","status":"open"}]' | rich_table.py --json --fields id,title,status
# JSON envelope ({"items":[...]}) is unwrapped automatically
jq '{items: .results}' data.json | rich_table.py --json --fields id,title
# Clickable links via markup
echo '[{"name":"site","url":"[link=https://coad.net]coad.net[/]"}]' | rich_table.py --json --markup
# Semantic + custom-color tags
printf 'web\t[good]up[/]\ndb\t[error]down[/]\n' | rich_table.py --markup "Service" "Status"
printf 'api\t[xgreen]healthy[/]\n' | rich_table.py --markup "Service" "Status"
# Full color styling
printf 'web\tup\n' | rich_table.py --markup --border "#FF9E64" --header-color "#9ECE6A" --text-color "#A1DB2B" --key-color "#FF87AF" "Service" "Status"
# Reference tables (no stdin needed)
rich_table.py --demoNotes:
- In
--jsonmode, inferred headers aresnake_case → Title Caseunless you pass--fields(then headers are the raw field names) or explicit COL_SPECs (which always win). - Without
--header/COL_SPECs in TSV mode, columns are auto-namedCol 1,Col 2, … - JSON envelope unwrapping grabs the first value that is a list (handy for
{"data":[…]},{"tickets":[…]}, etc.).
Import the module and call render() with a list of dicts. This is the preferred path for other Python tools — it keeps formatting centralized.
render(
data: list[dict], # rows
fields: list[str] | None = None, # keys to include, in order (default: all keys of data[0])
col_specs: list[str] | None = None, # optional "Header:opts" strings (override headers/options)
border: str = 'rgb(60,140,255)', # border color
markup: bool = False, # enable rich markup in cell values (semantic + custom-color tags)
header_color: str = 'white', # header text color, rendered bold
text_color: str | None = None, # cell text color for all columns
key_color: str | None = '#AFD7FF', # first (key) column color, overriding text_color there
)- Prints the table to stdout plus a trailing
N result(s)line. - Empty
dataprintsNo results.and returns. col_specsmap positionally to the columns produced byfields— so give them in the same order. Use them to set friendly headers andno_wrap/ width hints.
Use it as an included library. Beyond the CLI,
rich_tableis meant to be imported into any Python app that wants a consistent, nicely-styled table — callrender()with a list of dicts and you get the same borders, colors, markup, and last-column wrapping the CLI produces. Centralizing the look here means restyling once updates every tool that imports it.
import rich_table
rows = [
{"name": "alice", "age": 30},
{"name": "bob", "age": 25},
]
# 1. Minimal — friendly headers + no-wrap columns
rich_table.render(rows, col_specs=["Name:no_wrap", "Age:no_wrap"])
# 2. Pick / order a subset of fields
people = [{"id": 1, "name": "alice", "age": 30, "city": "austin"}]
rich_table.render(people, fields=["name", "city"])
# 3. Markup in cells — semantic tags, custom palette, and auto-colored URLs
svc = [
{"service": "api", "status": "[good]up[/]", "docs": "https://coad.net/api"},
{"service": "billing", "status": "[error]down[/]", "docs": "https://coad.net/billing"},
{"service": "cache", "status": "[xorange]warn[/]", "docs": "n/a"},
]
rich_table.render(svc, markup=True, col_specs=["Service:no_wrap", "Status", "Docs"])
# 4. Full color control — border / header / text / key column
rich_table.render(
rows,
border="#FF9E64", header_color="#9ECE6A",
text_color="#A1DB2B", key_color="#FF87AF",
col_specs=["Name:no_wrap", "Age"],
)
# 5. Clickable link with explicit color via the [hlink] tag (needs markup=True)
links = [{"label": "homepage", "url": "[link=https://coad.net][hlink]coad.net[/][/]"}]
rich_table.render(links, markup=True)A few things to know when embedding:
render()prints the table (plus a trailingN result(s)line); it doesn't return a string. EmptydataprintsNo results..markup=Trueis required for any[tag]interpretation and for bare-URL auto-coloring.col_specsmap positionally to the columns fromfields(or all ofdata[0]'s keys) — keep them in the same order.
rich_table.py lives in ~/code/py/misc, which usually isn't importable. Add it at runtime, then import — best-effort so a missing dependency degrades gracefully instead of crashing your tool:
import sys
from pathlib import Path
def _load_rich_table():
"""Import the shared rich_table renderer as a library. Returns the module, or None if unavailable."""
rt_dir = str(Path.home() / "code/py/misc")
if rt_dir not in sys.path:
sys.path.insert(0, rt_dir)
try:
import rich_table
return rich_table
except Exception:
return NoneThis is the pattern used by activity_log.py. render_table() returns True if it drew a table and False if rich_table (or rich) wasn't importable, so the caller can fall back to plain text:
def render_table(rows: list[dict], col_specs: list[str]) -> bool:
"""Render row-dicts as a styled rich table via the shared lib.
Returns True if rendered (incl. empty), False if the lib couldn't be imported."""
rt = _load_rich_table()
if rt is None:
return False
fields = list(rows[0].keys()) if rows else []
rt.render(rows, fields=fields, col_specs=col_specs)
return True
# Caller — default to table, fall back to plain text:
rows = [{"id": "AB12", "project": "foo", "summary": "did a thing"}]
if not render_table(rows, ["ID:no_wrap", "Project:no_wrap", "Summary"]):
for r in rows:
print(f" {r['id']} [{r['project']}] {r['summary']}")Because the import is live, editing rich_table.py's styling (box, border, header style) instantly changes every importing tool's output — no need to touch the callers.
If you need them directly:
parse_col_spec("Name:min_width=19,no_wrap") -> dict— turn a COL_SPEC string intoadd_columnkwargs.load_json_rows(raw_json_str, fields) -> (headers, rows)— parse a JSON string (array or envelope) into header + string-row lists.
By default the last column wraps (folds) long content instead of overflowing the table width — it gets overflow='fold' so a long summary/description spills onto extra lines while every row stays aligned and fixed columns stay compact. Columns are sized to their content (the table is NOT expand=True), so short content doesn't get padded wide. Automatic — no flag needed — in both CLI and library modes.
Opt the last column OUT of folding by pinning it with no_wrap or an explicit max_width:
# last column folds long content (default)
... | rich_table.py "Date:max_width=10" "ID:no_wrap" "Summary"
# last column won't fold
... | rich_table.py "Date" "ID" "Summary:no_wrap"
... | rich_table.py "Date" "ID" "Summary:max_width=60"Tip: to wrap a fixed-format column onto multiple lines (e.g. a timestamp), give it a max_width just wide enough for the first chunk — "Date:max_width=10" renders 2026-06-09 on line 1 and HH:MM on line 2.
- All cell values are stringified;
Nonebecomes''. - Table width is
max(terminal_width, 120)so it stays readable when output is piped/redirected. - The last column folds long content by default — see Dynamic last column above to pin it.
--markup(CLI) /markup=True(lib) is OFF by default — turn it on only when your cell values contain rich markup you want interpreted, or[…]text will be eaten.- COL_SPECs always win over inferred/
--fields/--headercolumn names.