Skip to content

Instantly share code, notes, and snippets.

@noahcoad
Last active June 10, 2026 04:33
Show Gist options
  • Select an option

  • Save noahcoad/5e27fea8f3c30b89ea0a04c9b2cb23d3 to your computer and use it in GitHub Desktop.

Select an option

Save noahcoad/5e27fea8f3c30b89ea0a04c9b2cb23d3 to your computer and use it in GitHub Desktop.
rich_table.py — render TSV/JSON streams as styled rich tables (CLI filter + importable lib). Markup, custom color palette, --demo reference tables.
#!/usr/bin/env python3
"""
rich_table.py — render a TSV or JSON stream as a styled rich table
created 2026-03-10 by Noah Coad w Claude Code
Usage:
<cmd> | rtbl [OPTIONS] [COL_SPEC ...]
COL_SPEC:
"Column Name"
"Column Name:no_wrap"
"Column Name:min_width=19"
"Column Name:min_width=19,no_wrap"
Options:
--json Parse input as JSON (array of objects); infers columns from field names
--fields k1,k2 Comma-separated JSON field names to include (--json only); default: all fields
--markup Enable rich markup in cell values. Supports plain rich tags ([bold], [red],
[#ff8800], [link=URL]text[/link]) AND semantic tags (see below)
--demo Print reference tables (markup effects/colors/tags, color options, and column
specs) with copy-paste recipes, then exit. Ignores stdin.
--header TSV mode: use the first input row as column headers
--sep CHAR Field separator for TSV mode (default: tab)
--border COLOR Border color as hex 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, to flag it as the key field (default: #AFD7FF)
COLOR accepts any rich style color: a name (red, cyan, bright_green), hex (#ff8800), or rgb(255,136,0).
Semantic markup tags (with --markup) — wrap cell text to color it by meaning:
[error]...[/error] bold red [good]...[/good] bold green
[warn]...[/warn] bold yellow [info]...[/info] cyan
[mute]...[/mute] dim
Custom color tags (with --markup) — named palette colors; raw hex like [#9ECE6A]...[/] also works:
[xgreen] #9ECE6A [xpink] #FF87AF [xorange] #FF9E64 [xmagenta] #F92472
[xcyan] #5EC0D3 [xlime] #A1DB2B [xgrass] #52BF37 [xsand] #E7DB74
Examples:
printf 'alice\\t30\\nbob\\t25\\n' | rtbl "Name" "Age:no_wrap"
printf 'alice\\t30\\nbob\\t25\\n' | rtbl --header-color cyan --text-color "#aaaaaa" "Name" "Age"
printf 'web\\tup\\ndb\\tdown\\n' | rtbl --key-color "bold cyan" "Service" "Status"
printf 'web\\t[good]up[/good]\\ndb\\t[error]down[/error]\\n' | rtbl --markup "Service" "Status"
printf 'disk\\t[warn]91%%[/warn]\\n' | rtbl --markup "Resource" "Usage"
echo '[{"name":"alice","age":30},{"name":"bob","age":25}]' | rtbl --json
echo '[{"name":"alice","age":30}]' | rtbl --json --fields name
jq '[.items[]]' data.json | rtbl --json --fields id,title,status
"""
import json
import os
import sys
import shutil
import argparse
import rich.box
from rich.console import Console
from rich.table import Table
from rich.theme import Theme
from rich.highlighter import RegexHighlighter
from rich.text import Text
# Custom named colors usable as markup tags when --markup is on, e.g. [xgreen]text[/xgreen].
# Raw hex tags ([#9ECE6A]text[/]) work too — these just give the palette friendly names.
CUSTOM_COLORS = {
'xgreen': '#9ECE6A',
'xpink': '#FF87AF',
'xorange': '#FF9E64',
'xmagenta': '#F92472',
'xcyan': '#5EC0D3',
'xlime': '#A1DB2B',
'xgrass': '#52BF37',
'xsand': '#E7DB74',
}
# Semantic markup tags available in cell values when --markup is on, e.g. [error]FAILED[/error].
# Plain rich markup ([bold], [red], [#ff8800], [link=URL]...) and the CUSTOM_COLORS names work alongside these.
# Default hyperlink color, applied to bare URLs in cells via URLHighlighter (below) and exposed
# as the [hlink] markup tag so you can color link text explicitly: [link=URL][hlink]text[/][/].
HYPERLINK_COLOR = '#6A71F7'
SEMANTIC_THEME = Theme({
'error': 'bold red',
'good': 'bold green',
'warn': 'bold yellow',
'info': 'cyan',
'mute': 'dim',
'hlink': HYPERLINK_COLOR,
**CUSTOM_COLORS,
})
class URLHighlighter(RegexHighlighter):
"""Color bare http(s) URLs in cell text with HYPERLINK_COLOR, without rich's full highlighter
(which would also recolor numbers/booleans in data). Table cells bypass the console highlighter,
so we apply this per-cell via highlight_cell()."""
base_style = 'rtbl.'
highlights = [r'(?P<url>https?://[^\s]+)']
URL_THEME = Theme({**SEMANTIC_THEME.styles, 'rtbl.url': HYPERLINK_COLOR})
_URL_HL = URLHighlighter()
def highlight_cell(value: str, markup: bool) -> Text:
"""Turn a cell string into rich Text: interpret markup (if on) then color any bare URLs.
Table cells aren't run through the console highlighter, so URL coloring must happen here."""
text = Text.from_markup(value) if markup else Text(value)
if markup:
_URL_HL.highlight(text)
return text
def demo():
"""Render several reference tables showing what rtbl can do — markup + styling options.
Self-contained (ignores stdin). Tables:
1. Markup reference — literal source beside rendered result (effects, colors, tags, palette).
2. Color options — what --border / --header-color / --text-color / --key-color produce.
3. Column specs — no_wrap, min_width, and last-column wrapping.
4. Basic CLI examples — common invocations (mirrors --help).
Use it to eyeball what your terminal supports and to copy styling recipes.
"""
w = max(shutil.get_terminal_size().columns, 100)
console = Console(markup=True, width=w, theme=URL_THEME, highlighter=URLHighlighter())
def rule(title):
console.print(f'\n[bold #AFD7FF]{title}[/]')
# --- Table 1: markup reference (source beside rendered) ---
rule('1. Markup reference — use with --markup')
sections = [
('Hyperlinks', [
f'[link=https://coad.net][hlink]a hyperlink[/][/] (default {HYPERLINK_COLOR})',
'https://coad.net (bare URL auto-colored)',
'[link=https://coad.net][#5EC0D3]custom-color link[/][/]',
]),
('Text effects', [
'[bold]bold[/]', '[italic]italic[/]', '[underline]underline[/]',
'[strike]strike[/]', '[dim]dim[/]', '[reverse]reverse[/]',
'[blink]blink[/]', '[bold italic]bold italic[/]',
]),
('Named colors', [
'[red]red[/]', '[green]green[/]', '[yellow]yellow[/]',
'[blue]blue[/]', '[magenta]magenta[/]', '[cyan]cyan[/]',
'[bright_red]bright_red[/]', '[bright_cyan]bright_cyan[/]',
]),
('Hex / rgb colors', [
'[#9ECE6A]#9ECE6A[/]', '[#FF87AF]#FF87AF[/]', '[#FF9E64]#FF9E64[/]',
'[rgb(94,192,211)]rgb(94,192,211)[/]', '[white on #F92472]white on #F92472[/]',
]),
('Semantic tags', [
'[error]error[/]', '[good]good[/]', '[warn]warn[/]',
'[info]info[/]', '[mute]mute[/]',
]),
('Custom palette', [f'[{name}]{name} {hex_}[/]' for name, hex_ in CUSTOM_COLORS.items()]),
# Effect + color must use inline HEX, not a theme name: rich won't combine a named theme
# tag with other style words (e.g. "[underline xorange]" silently no-ops).
('Combos', [
'[bold #F92472]bold magenta[/]', '[italic #5EC0D3]italic cyan[/]',
'[underline #FF9E64]underline orange[/]', '[bold #9ECE6A on grey15]badge[/]',
'[bold][xgreen]nested theme tag[/][/]',
]),
]
t1 = Table(show_header=True, header_style='bold white', box=rich.box.HEAVY_HEAD, border_style='rgb(60,140,255)')
t1.add_column('Section', style='#AFD7FF', no_wrap=True)
t1.add_column('Markup source', style='dim', no_wrap=True)
t1.add_column('Rendered', no_wrap=True)
for title, samples in sections:
for i, s in enumerate(samples):
t1.add_row(title if i == 0 else '', s.replace('[', r'\['), highlight_cell(s, True))
t1.add_section()
console.print(t1)
# --- Table 2: color options (border/header/text/key) ---
rule('2. Color options — --border / --header-color / --text-color / --key-color')
t2 = Table(show_header=True, header_style='bold #9ECE6A', box=rich.box.HEAVY_HEAD, border_style='#FF9E64')
t2.add_column('Service', style='#FF87AF', no_wrap=True) # key-color
t2.add_column('Region', style='#A1DB2B') # text-color
t2.add_column('Status', style='#A1DB2B')
t2.add_row('api-gateway', 'us-east-1', '[good]healthy[/good]')
t2.add_row('worker-pool', 'us-west-2', '[warn]degraded[/warn]')
t2.add_row('billing-db', 'eu-west-1', '[error]down[/error]')
console.print(t2)
console.print('[dim] recipe: rtbl --markup --border "#FF9E64" --header-color "#9ECE6A" --text-color "#A1DB2B" --key-color "#FF87AF" "Service" "Region" "Status"[/dim]')
# --- Table 3: column specs (no_wrap / min_width / last-column wrap) ---
rule('3. Column specs — "Name:no_wrap", "Name:min_width=N"; last column wraps')
t3 = Table(show_header=True, header_style='bold white', box=rich.box.HEAVY_HEAD, border_style='rgb(60,140,255)')
t3.add_column('Id:no_wrap', style='#AFD7FF', no_wrap=True)
t3.add_column('Owner:min_width=14', min_width=14)
t3.add_column('Notes (wraps)', overflow='fold')
t3.add_row('TT3VD8K', 'noah', 'this last column folds long content onto multiple lines instead of overflowing the table width, keeping every row aligned')
t3.add_row('G9K1S7A', 'claude', 'short note')
console.print(t3)
console.print('[dim] recipe: ... | rtbl "Id:no_wrap" "Owner:min_width=14" "Notes"[/dim]')
# --- Table 4: basic CLI examples (mirrors --help) ---
rule('4. Basic CLI examples — see `rtbl --help` for the full list')
t4 = Table(show_header=True, header_style='bold white', box=rich.box.HEAVY_HEAD, border_style='rgb(60,140,255)')
t4.add_column('Command', style='#AFD7FF', no_wrap=True)
t4.add_column('What it does')
examples = [
('printf \'alice\\t30\\nbob\\t25\\n\' | rtbl "Name" "Age:no_wrap"', 'TSV with explicit headers'),
('printf \'name\\tage\\nalice\\t30\\n\' | rtbl --header', 'TSV, first row is the header'),
('printf \'alice,30\\n\' | rtbl --sep , "Name" "Age"', 'custom field separator'),
('printf \'web\\t[good]up[/]\\n\' | rtbl --markup "Svc" "Status"', 'semantic markup in cells'),
('echo \'[{"name":"alice","age":30}]\' | rtbl --json', 'JSON array — columns inferred from keys'),
('echo \'[{...}]\' | rtbl --json --fields id,title,status', 'JSON, pick + order specific fields'),
('jq \'[.items[]]\' data.json | rtbl --json', 'pipe jq output straight in'),
]
for cmd, desc in examples:
t4.add_row(cmd.replace('[', r'\['), desc) # show commands literally, not as markup
console.print(t4)
def parse_col_spec(spec: str) -> dict:
"""Parse 'Name:opt1,opt2=val' into column kwargs."""
if ':' not in spec:
return {"header": spec}
name, opts_str = spec.split(':', 1)
kwargs = {"header": name}
for opt in opts_str.split(','):
opt = opt.strip()
if '=' in opt:
key, val = opt.split('=', 1)
if key == 'min_width':
kwargs['min_width'] = int(val)
elif key == 'max_width':
kwargs['max_width'] = int(val)
elif opt == 'no_wrap':
kwargs['no_wrap'] = True
return kwargs
def dynamic_last_column(specs: list[dict]) -> bool:
"""Let the LAST column WRAP long content instead of overflowing, without padding it wide when
content is short (mutates `specs` in place). Skips columns the caller pinned with an explicit
width or no_wrap. Returns False so the table is NOT created with expand=True — rich then sizes
every column to its content and only wraps the flexible last column when the row is too wide."""
if not specs:
return False
last = specs[-1]
if last.get('no_wrap') or 'max_width' in last:
return False
last.setdefault('overflow', 'fold')
return False
def load_json_rows(raw: str, fields: list[str] | None) -> tuple[list[str], list[list[str]]]:
"""Parse JSON input into (headers, rows). Accepts array or newline-delimited objects."""
data = json.loads(raw)
if isinstance(data, dict):
# Unwrap common envelope shapes: {"data": [...]} or {"tickets": [...]} etc.
for v in data.values():
if isinstance(v, list):
data = v
break
if not isinstance(data, list):
raise ValueError("JSON input must be an array of objects")
if not data:
return [], []
keys = fields if fields else list(data[0].keys())
headers = [k.replace('_', ' ').title() if not fields else k for k in keys]
rows = [[str(obj.get(k, '') if obj.get(k) is not None else '') for k in keys] for obj in data]
return headers, rows
def render(data: list[dict], fields: list[str] | None = None, col_specs: list[str] | None = None, border: str = 'rgb(60,140,255)', markup: bool = False, header_color: str = 'white', text_color: str | None = None, key_color: str | None = '#AFD7FF'):
"""Render a list of dicts as a rich table.
Args:
data: List of dicts (rows).
fields: Keys to include, in order (default: all keys from first row).
col_specs: Optional list of 'Header:opts' strings to override column headers/options.
border: Border color (default: rgb(60,140,255)).
markup: Enable rich markup in cell values, incl. semantic tags ([error]/[good]/[warn]/[info]/[mute]).
header_color: Header text color (default: white); rendered bold.
text_color: Cell text color applied to every column (default: terminal default).
key_color: Style for the first (key) column, overriding text_color there (default: #AFD7FF).
"""
if not data:
print('No results.')
return
keys = fields if fields else list(data[0].keys())
headers = [k.replace('_', ' ').title() for k in keys]
rows = [[str(obj.get(k) if obj.get(k) is not None else '') for k in keys] for obj in data]
specs = [parse_col_spec(c) for c in col_specs] if col_specs else [{'header': h} for h in headers]
w = max(shutil.get_terminal_size().columns, 120)
# URL highlighter colors bare links; only enabled with markup so plain data is untouched.
console = Console(highlight=False, markup=markup, width=w, theme=URL_THEME,
highlighter=URLHighlighter() if markup else None)
expand = dynamic_last_column(specs)
table = Table(
show_header=True,
header_style=f'bold {header_color}',
box=rich.box.HEAVY_HEAD,
border_style=border,
expand=expand,
)
for i, spec in enumerate(specs):
spec = dict(spec)
# Don't clobber a per-column style the caller set via col_spec.
if text_color: spec.setdefault('style', text_color)
# Key column (first) gets its own color, overriding text_color there.
if i == 0 and key_color: spec['style'] = key_color
table.add_column(spec.pop('header'), **spec)
for row in rows:
table.add_row(*(highlight_cell(c, markup) for c in row))
console.print(table)
print(f'{len(data)} result(s)')
def install_hint() -> str:
"""Recommend an `alias rtbl=...` line + which rc file to add it to, based on the current shell.
Uses sys.executable (a Python 3 interpreter, since this file requires it) and this file's
absolute path, so the suggested alias is copy-paste correct for THIS environment.
"""
shell = os.path.basename(os.environ.get('SHELL', ''))
rc = {'zsh': '~/.zshrc', 'bash': '~/.bashrc', 'fish': '~/.config/fish/config.fish'}.get(shell, '~/.<shell>rc')
py = sys.executable or 'python3' # the Python 3 interpreter running this file
path = os.path.abspath(__file__)
home = os.path.expanduser('~')
# Prefer a ~-relative, portable path for the alias (works via the Dropbox symlink too).
cloud = os.path.join(home, 'Library/CloudStorage/Dropbox')
if path.startswith(cloud): path = '~' + path[len(cloud):]
elif path.startswith(home): path = '~' + path[len(home):]
if shell == 'fish':
alias = f"alias rtbl '{py} {path}'"
else:
alias = f"alias rtbl='{py} {path}'"
return (
"Recommended setup — add an `rtbl` alias to your shell rc, then `source` it (or open a new shell):\n"
f" detected shell: {shell or 'unknown'} -> add to {rc}\n"
f" {alias}\n"
" (run with Python 3; the path above is this file's absolute location)"
)
def main():
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('-h', '--help', action='store_true', help='Show this help message and exit')
parser.add_argument('--json', action='store_true', dest='json_mode')
parser.add_argument('--fields', default=None)
parser.add_argument('--markup', action='store_true')
parser.add_argument('--demo', action='store_true', help='Show reference tables of markup, colors, and column specs, then exit (ignores stdin)')
parser.add_argument('--header', action='store_true', help='TSV mode: first row is the header')
parser.add_argument('--sep', default='\t')
parser.add_argument('--border', default='rgb(60,140,255)')
parser.add_argument('--header-color', default='white', help='Header text color (default: white); rendered bold')
parser.add_argument('--text-color', default=None, help='Cell text color for all columns (default: terminal default)')
parser.add_argument('--key-color', default='#AFD7FF', help='Color for the first (key) column, overriding --text-color there (default: #AFD7FF)')
parser.add_argument('cols', nargs='*')
args = parser.parse_args()
if args.help:
print(__doc__.strip())
print('\n' + install_hint())
return
if args.demo:
demo()
return
raw = sys.stdin.read()
if args.json_mode:
fields = [f.strip() for f in args.fields.split(',')] if args.fields else None
headers, rows = load_json_rows(raw, fields)
# COL_SPECs override auto-inferred headers if provided
col_specs = [parse_col_spec(c) for c in args.cols] if args.cols else [{"header": h} for h in headers]
else:
lines = [line.rstrip('\n') for line in raw.splitlines() if line.strip()]
rows = [line.split(args.sep) for line in lines]
col_specs = [parse_col_spec(c) for c in args.cols]
if args.header and rows:
# Explicit COL_SPECs still win; otherwise pull headers from the first row
header_row = rows.pop(0)
if not col_specs:
col_specs = [{"header": h} for h in header_row]
if not col_specs and rows:
col_specs = [{"header": f"Col {i+1}"} for i in range(len(rows[0]))]
w = max(shutil.get_terminal_size().columns, 120)
console = Console(highlight=False, markup=args.markup, width=w, theme=URL_THEME,
highlighter=URLHighlighter() if args.markup else None)
expand = dynamic_last_column(col_specs)
table = Table(
show_header=True,
header_style=f"bold {args.header_color}",
box=rich.box.HEAVY_HEAD,
border_style=args.border,
expand=expand,
)
for i, spec in enumerate(col_specs):
header = spec.pop('header')
# Don't clobber a per-column style set via col_spec.
if args.text_color: spec.setdefault('style', args.text_color)
# Key column (first) gets its own color, overriding --text-color there.
if i == 0 and args.key_color: spec['style'] = args.key_color
table.add_column(header, **spec)
for row in rows:
table.add_row(*(highlight_cell(c, args.markup) for c in row), end_section=False)
console.print(table)
if __name__ == '__main__':
main()

rich_table.py

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.

Published gist

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.md in the gist (so rich_table.py sorts first and becomes the gist's display title) — local file stays rich_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.md

Why

Keep 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.

Requirements

  • Python 3.12 (the file's shebang is /opt/homebrew/bin/python3.12, but it's pure stdlib + rich).
  • rich installed for that interpreter: pip install rich.

Setup (rtbl alias)

--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 ….


CLI usage

<cmd> | rich_table.py [OPTIONS] [COL_SPEC ...]

Options

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).

COL_SPEC

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.

Markup (--markup)

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 in CUSTOM_COLORS near the top of the module; raw hex ([#9ECE6A]…[/]) works too.
  • Hyperlinks[link=URL]text[/] makes text clickable (no color on its own). Bare http(s)://… URLs in cells are auto-colored #6A71F7 (the HYPERLINK_COLOR constant), 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.

CLI examples

# 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 --demo

Notes:

  • In --json mode, inferred headers are snake_case → Title Case unless 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-named Col 1, Col 2, …
  • JSON envelope unwrapping grabs the first value that is a list (handy for {"data":[…]}, {"tickets":[…]}, etc.).

Library usage (import + embed)

Import the module and call render() with a list of dicts. This is the preferred path for other Python tools — it keeps formatting centralized.

Signature

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 data prints No results. and returns.
  • col_specs map positionally to the columns produced by fields — so give them in the same order. Use them to set friendly headers and no_wrap / width hints.

Use it as an included library. Beyond the CLI, rich_table is meant to be imported into any Python app that wants a consistent, nicely-styled table — call render() 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.

Examples

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 trailing N result(s) line); it doesn't return a string. Empty data prints No results..
  • markup=True is required for any [tag] interpretation and for bare-URL auto-coloring.
  • col_specs map positionally to the columns from fields (or all of data[0]'s keys) — keep them in the same order.

Importing when it's not on sys.path

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 None

Recommended wrapper — table with plain-text fallback

This 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.

Lower-level helpers

If you need them directly:

  • parse_col_spec("Name:min_width=19,no_wrap") -> dict — turn a COL_SPEC string into add_column kwargs.
  • load_json_rows(raw_json_str, fields) -> (headers, rows) — parse a JSON string (array or envelope) into header + string-row lists.

Dynamic last column

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.

Gotchas

  • All cell values are stringified; None becomes ''.
  • 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/--header column names.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment