โ† Home

146. Rich (App V0.7)

RAM Manager v0.7 โ€” beautiful terminal output with Rich

146. Rich (App V0.7)

A wall of print() output is hard to read. Rich makes terminals beautiful.

๐Ÿค” Socratic question: Why do some CLI tools look amazing (with colors, tables, progress bars) while others just spew plain text? Itโ€™s not magic โ€” itโ€™s one library. Meet Rich.

๐Ÿ›๏ธ Origin story: Will McGugan built Rich as a weekend side project in 2020 while working a day job. Within a year it became one of the most starred Python repos on GitHub. He then quit his job to build Textual (a full TUI framework) full-time. This is the power of building in public and open source. One weekend project โ†’ a company.

๐Ÿ“บ YouTube search: โ€œWill McGugan Rich Python terminal talk PyConโ€ โ€” watch the creator explain why he built it.

from rich.console import Console
from rich.table import Table
from rich import box

console = Console()
table = Table(box=box.ROUNDED, title="RAM: 87.3%")
table.add_column('Process', style='cyan')
table.add_column('PID', justify='right')
table.add_column('RSS MB', justify='right')

for p in snapshot['processes']:
    rss = p['rss_mb']
    color = 'red' if rss > 500 else ('yellow' if rss > 100 else 'green')
    table.add_row(p['name'], str(p['pid']), f'[{color}]{rss:.0f}[/]')

console.print(table)

Output: โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ RAM: 87.3% โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ Process โ”‚ PID โ”‚ RSS MB โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ chrome โ”‚ 812 โ”‚ 800 โ”‚ โ”‚ python โ”‚ 421 โ”‚ 150 โ”‚ โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

Two things catch him out:

  1. Closing tag is [/] not [/red] โ€” Rich markup uses [/] to close any open tag
  2. Table.add_column('title') โ€” the argument is the column label, not a Column object
  3. Rich.Live() goes outside the update loop โ€” otherwise you create a new Live context every iteration and get flickering

๐Ÿ’ก Real-world: The Python packaging tools pip, poetry, and uv all use Rich for their beautiful output. When you see pip install with that nice progress bar โ€” thatโ€™s Rich. Youโ€™re using the same library as the tools you use every day.

๐Ÿ’ก Fun fact: Richโ€™s markup syntax was inspired by BBCode (Bulletin Board Code), a tag language used in forums since the 1990s. Rich detects whether the terminal supports color (checking TERM, NO_COLOR, etc.) and automatically disables markup in environments that donโ€™t support ANSI escape codes โ€” so it wonโ€™t pollute log files or CI output with garbage characters.

โš ๏ธ Watch out: Rich uses [square brackets] for markup, NOT HTML <angle brackets>. Writing <red>text</red> prints the literal angle brackets โ€” it does not color anything. The correct syntax is [red]text[/]. Also, [/] closes any open tag โ€” you never need [/red] specifically.

๐Ÿค” Think about it: The solution opens Live() once outside the loop and calls live.update() each iteration. What visual difference would you see if you put with Live() as live: inside the loop? Why does Live need to know about the terminal size before the loop starts, and why does creating it fresh each iteration prevent that?


๐ŸŽจ Aryan upgrades the RAM managerโ€™s output from plain print() to a Rich table with color-coded process memory. He uses HTML tags instead of Rich markup, puts add_column() calls in the wrong order, and creates a new Live() inside the loop instead of outside it.

Learning objectives

  • Use [color]โ€ฆ[/] Rich markup (not HTML tags)
  • Add columns with positional title argument
  • Color-code by threshold (red > 500, yellow > 100, green otherwise)
  • Keep Live() context outside the loop

Key concepts

  • [red]text[/] โ€” Rich markup for colored output
  • Table.add_column(โ€˜Titleโ€™) โ€” positional title
  • Live(refresh_per_second=N) โ€” in-place terminal update
  • live.update(renderable) โ€” replace content without scrolling

Try it

Concept detail

App v0.7 โ€” Rich terminal dashboard

โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚                   Top Processes                  โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Process            โ”‚      PID โ”‚           RSS MB โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ chrome             โ”‚      812 โ”‚              800 โ”‚
โ”‚ python             โ”‚      421 โ”‚              150 โ”‚
โ”‚ vim                โ”‚      111 โ”‚               20 โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
# Rich markup โ€” square brackets, not HTML
f'[red]{rss:.0f}[/]'         # red text, [/] closes all
f'[bold green]OK[/]'         # bold green
console.print('[yellow]Warning![/]')

# Live dashboard pattern
with Live(refresh_per_second=2) as live:
    while True:
        live.update(make_table(take_snapshot()))
        time.sleep(1)

Solution

import time
import psutil
import os
import requests
from datetime import datetime
from dotenv import load_dotenv
from rich.console import Console
from rich.table import Table
from rich.live import Live
from rich import box

load_dotenv()
console = Console()

def take_snapshot(n: int = 10) -> dict:
    mem = psutil.virtual_memory()
    processes = []
    for proc in psutil.process_iter(['pid', 'name', 'memory_info']):
        try:
            rss_mb = proc.info['memory_info'].rss / 1e6
            processes.append({'name': proc.info['name'], 'pid': proc.info['pid'], 'rss_mb': rss_mb})
        except (psutil.NoSuchProcess, psutil.AccessDenied):
            pass
    return {
        'percent': mem.percent,
        'used_gb': mem.used / 1e9,
        'total_gb': mem.total / 1e9,
        'processes': sorted(processes, key=lambda p: p['rss_mb'], reverse=True)[:n],
        'timestamp': datetime.now().isoformat(),
    }

def make_process_table(snapshot: dict) -> Table:
    table = Table(box=box.ROUNDED, title='Top Processes')
    table.add_column('Process', style='cyan')
    table.add_column('PID', justify='right')
    table.add_column('RSS MB', justify='right')

    for p in snapshot['processes'][:10]:
        rss = p['rss_mb']
        color = 'red' if rss > 500 else ('yellow' if rss > 100 else 'green')
        table.add_row(
            p['name'],
            str(p['pid']),
            f'[{color}]{rss:.0f}[/]',
        )
    return table

def print_header(snapshot: dict) -> None:
    pct = snapshot['percent']
    color = 'red' if pct > 80 else ('yellow' if pct > 60 else 'green')
    used = snapshot['used_gb']
    total = snapshot['total_gb']
    console.print(f'[{color}]RAM: {pct:.1f}%  ({used:.1f}/{total:.1f} GB)[/]')

def run_live_dashboard(duration: int = 10):
    # Live() context opened ONCE outside the loop
    with Live(console=console, refresh_per_second=2) as live:
        start = time.time()
        while time.time() - start < duration:
            snapshot = take_snapshot()
            live.update(make_process_table(snapshot))
            time.sleep(1)

def main():
    snapshot = take_snapshot()
    print_header(snapshot)
    console.print(make_process_table(snapshot))

if __name__ == '__main__':
    main()

Tests

from io import StringIO
from rich.console import Console
from rich.table import Table

SAMPLE_SNAPSHOT = {
    'percent': 72.5, 'used_gb': 11.6, 'total_gb': 16.0,
    'timestamp': '2024-01-01T10:00:00',
    'processes': [
        {'name': 'chrome', 'pid': 812, 'rss_mb': 800.0},
        {'name': 'python', 'pid': 421, 'rss_mb': 150.0},
        {'name': 'vim',    'pid': 111, 'rss_mb': 20.0},
    ],
}

def test_make_process_table_returns_table():
    table = make_process_table(SAMPLE_SNAPSHOT)
    assert isinstance(table, Table)

def test_make_process_table_has_three_columns():
    table = make_process_table(SAMPLE_SNAPSHOT)
    assert len(table.columns) == 3

def test_make_process_table_high_rss_is_red():
    table = make_process_table(SAMPLE_SNAPSHOT)
    buf = StringIO()
    c = Console(file=buf, markup=True, highlight=False)
    c.print(table)
    output = buf.getvalue()
    assert 'red' in output.lower() or '๐Ÿ”ด' in output

def test_make_process_table_uses_rich_markup_not_html():
    import inspect
    src = inspect.getsource(make_process_table)
    assert '<red>' not in src, 'Use Rich markup [red]...[/] not HTML <red>'
    assert '[' in src, 'Use Rich markup [color]...[/]'

def test_print_header_uses_rich_markup():
    import inspect
    src = inspect.getsource(print_header)
    assert '<' not in src.split('console.print')[1].split('\n')[0], (
        'Use Rich markup [color]...[/] not HTML tags'
    )

def test_live_not_created_inside_loop():
    import inspect
    src = inspect.getsource(run_live_dashboard)
    assert src.count('with Live') == 1
    # The while loop should be INSIDE the with Live block
    live_pos = src.index('with Live')
    while_pos = src.index('while')
    assert while_pos > live_pos, 'while loop must be inside with Live()'

Resources