Input & UX

Theming Rich Output Consistently in Python CLIs

Give a Python CLI one visual language: semantic Rich theme styles, a small output helper, palettes that work on light and dark terminals, user overrides and tests.

Updated

After a few months of development, most CLIs using Rich have accumulated a colour palette nobody chose: [red] in one command and [bold red] in another for the same kind of error, [green] successes next to [bright_green] ones, a dim grey that is unreadable on light terminal themes, blue text that vanishes on dark ones, and a check mark in some success messages but not others. Users stop being able to read the output at a glance, because the same colour means different things in different places. The fix is the same one web developers use: semantic styles. Define what success, warning, error and muted look like once, in a Rich Theme; write [error] instead of [red] everywhere; and route messages through a tiny helper so the format of each kind of message is consistent too. This guide builds that, chooses a palette that survives light and dark themes, lets users override it from their config, and tests it. It belongs to the interactive terminal UI with Rich topic.

Prerequisites

  • Rich 13+ (included with Typer) and Python 3.11+ for tomllib.
  • A CLI with more than a couple of commands producing styled output.

Semantic styles, not colours

Semantic styles, not colours A Rich Theme defining semantic style names such as success, warning, error, muted and code, used everywhere instead of literal colours. Semantic styles, not colours Theme({...}) one definition success green warning yellow error bold red muted dim Code says [error], not [red] Changing a colour is one edit Users can override in config Semantic names make the output consistent and the palette replaceable.

A Rich Theme maps style names to style definitions. Once a console has the theme, markup like [error]...[/] uses the named style. The code now says what a piece of text means — this is an error, this is secondary detail, this is a path — and the theme decides what that looks like. Changing the error colour becomes a one-line edit, users can override styles in their own config, and every command automatically agrees with every other.

The recipe

# src/mytool/theme.py
from __future__ import annotations

import tomllib
from pathlib import Path

from rich.console import Console
from rich.errors import StyleSyntaxError
from rich.style import Style
from rich.theme import Theme

DEFAULT_STYLES: dict[str, str] = {
    "success": "green",
    "warning": "yellow",
    "error": "bold red",
    "muted": "dim",
    "heading": "bold",
    "code": "cyan",
    "path": "underline",
}
SYMBOLS = {"success": "✓", "warning": "!", "error": "✗"}


def load_theme(user_file: Path | None = None) -> Theme:
    """Defaults, overridden per key by an optional [theme] table in the user's config."""
    styles = dict(DEFAULT_STYLES)
    if user_file and user_file.is_file():
        with user_file.open("rb") as fh:
            overrides = tomllib.load(fh).get("theme", {})
        for name, value in overrides.items():
            if name not in DEFAULT_STYLES:
                raise ValueError(f"unknown style {name!r}; known: {', '.join(DEFAULT_STYLES)}")
            try:
                Style.parse(value)
            except StyleSyntaxError as exc:
                raise ValueError(f"invalid style for {name!r}: {exc}") from None
            styles[name] = value
    return Theme(styles, inherit=True)


def make_console(theme: Theme, *, stderr: bool = False, **kwargs) -> Console:
    return Console(theme=theme, stderr=stderr, highlight=False, **kwargs)


class Out:
    """The only way commands produce styled messages."""

    def __init__(self, console: Console) -> None:
        self.console = console

    def success(self, text: str) -> None:
        self.console.print(f"[success]{SYMBOLS['success']}[/] {text}")

    def warning(self, text: str) -> None:
        self.console.print(f"[warning]{SYMBOLS['warning']}[/] {text}")

    def error(self, text: str, hint: str | None = None) -> None:
        self.console.print(f"[error]{SYMBOLS['error']} {text}[/]")
        if hint:
            self.console.print(f"  [muted]hint:[/] {hint}")

    def detail(self, text: str) -> None:
        self.console.print(f"  [muted]{text}[/]")

In a Typer app, build the theme and console once in the callback and share them:

import typer

from mytool.theme import Out, load_theme, make_console
from mytool.paths import CONFIG_FILE          # e.g. from platformdirs

app = typer.Typer()


@app.callback()
def main(ctx: typer.Context) -> None:
    theme = load_theme(CONFIG_FILE)
    ctx.obj = {"out": Out(make_console(theme)), "err": Out(make_console(theme, stderr=True))}


@app.command()
def deploy(ctx: typer.Context, site: str) -> None:
    out, err = ctx.obj["out"], ctx.obj["err"]
    out.success(f"deployed {site} to [path]prod[/]")
    err.warning("2 files over 1 MB")
    err.detail("uploaded 14 files in 3.2s")

The pieces

DEFAULT_STYLES is the whole visual language in seven lines. inherit=True keeps Rich's built-in styles (used by tables, tracebacks and syntax highlighting) available alongside yours.

Out is the only way commands produce status messages. It fixes not just colour but shape: every success starts with , every error with , hints are indented and muted. Consistency of shape is what lets people scan output quickly — they learn once that lines matter.

Symbols and words carry the meaning. Colour reinforces them. With NO_COLOR, a colour-blind user, or output piped to a file, "✗ deploy failed" still says everything; see respecting NO_COLOR and FORCE_COLOR.

highlight=False stops Rich's automatic highlighter from colouring numbers and strings inside your messages in ways your theme did not choose.

User overrides are validated. A [theme] table in the user's config can change any style; unknown names and invalid style strings are rejected with a clear message rather than failing later in the middle of output.

A palette that survives every terminal theme

A palette that survives light and dark themes Style choices for semantic roles in a command line tool and how each reads on light and dark terminal backgrounds. A palette that survives light and dark themes Role Style Why it works success green themes remap ANSI green warning yellow bold helps on light themes error bold red bold adds a second cue muted dim relative to the theme Avoid #777777, blue on black vanishes on one theme Named ANSI colours adapt to the user's theme; exact RGB values do not.

Users run light themes, dark themes, high-contrast themes and custom palettes. Two rules keep text readable on all of them:

  • Use the named ANSI coloursred, green, yellow, cyan — for semantic styles. Terminal themes remap these sixteen colours to shades that suit their background, so green is readable on both light and dark. Exact RGB values (#777777, rgb(0,0,255)) are rendered literally and disappear on one theme or the other.
  • Add a second cue for important styles. bold red for errors is still distinguishable in a theme with a muted red; dim for secondary text adapts to the theme's foreground instead of choosing a grey.

Test the palette once in a light and a dark theme — and once with NO_COLOR=1 — before settling on it.

UX considerations

Consistent styles across commands Terminal output from two different commands using the same semantic styles for success, warnings and muted detail. Consistent styles across commands bash $ mytool deploy web ✓ deployed web to prod (success) ! 2 files over 1 MB (warning) $ mytool check ✗ billing: replicas < 1 (error) checked 14 files in 0.3s (muted) Users learn what each style means once and read every command faster.
  • Few styles, used strictly. Seven semantic styles cover almost every CLI. More styles means more meanings users must learn.
  • Style only human output. Machine-readable output (--json, CSV) is never styled, whatever the theme; see emitting JSON output for scripting.
  • Consistent across tables too. Use theme names in table cells ("[error]failed[/]") and column style= arguments, so tables and messages agree — as in rendering tables and JSON with Rich.
  • Document the override. A sentence in the docs showing the [theme] table lets users with unusual palettes fix readability themselves.

Testing the behaviour

Render into a StringIO console to test both the plain text (the meaning) and, with a forced terminal and a fixed colour system, that styles really come from the theme:

# tests/test_theme.py
import io

import pytest
from rich.console import Console

from mytool.theme import DEFAULT_STYLES, Out, load_theme, make_console


def render(out_fn, theme=None, **console_kw) -> str:
    console = make_console(theme or load_theme(), file=io.StringIO(), width=80, **console_kw)
    out_fn(Out(console))
    return console.file.getvalue()


def test_plain_output_keeps_meaning_without_colour():
    text = render(lambda o: o.error("deploy failed", hint="run mytool auth login"), color_system=None)
    assert text == "✗ deploy failed\n  hint: run mytool auth login\n"


def test_styles_come_from_the_theme():
    text = render(lambda o: o.success("deployed"), force_terminal=True, color_system="standard")
    assert "\x1b[32m" in text                       # green, from the 'success' style


def test_user_override(tmp_path):
    cfg = tmp_path / "config.toml"
    cfg.write_text('[theme]\nsuccess = "bold blue"\n')
    theme = load_theme(cfg)
    assert str(theme.styles["success"]) == "bold blue"


@pytest.mark.parametrize("toml, message", [
    ('[theme]\nsucess = "green"\n', "unknown style"),
    ('[theme]\nerror = "not-a-colour"\n', "invalid style"),
])
def test_bad_overrides_are_explained(tmp_path, toml, message):
    cfg = tmp_path / "config.toml"
    cfg.write_text(toml)
    with pytest.raises(ValueError, match=message):
        load_theme(cfg)


def test_every_semantic_style_parses():
    theme = load_theme()
    for name in DEFAULT_STYLES:
        assert name in theme.styles

The first test is the most valuable: with colour disabled, the exact text is still complete and meaningful. A small lint helps too — a test or pre-commit hook that greps the source for raw colour markup such as [red] outside theme.py keeps new code on the semantic names.

Conclusion

Consistent output comes from naming meanings, not colours. Define a handful of semantic styles in a Rich Theme, route messages through a small helper that fixes their shape and symbols, choose named ANSI colours with a second cue for the important ones, keep machine output unstyled, and let users override styles from their config with validation. Every command then speaks the same visual language, on every terminal theme.

Frequently asked questions

Can Typer's own help and error panels use my theme?

Typer's help formatting uses its own Rich styles, configurable through module-level settings in typer.rich_utils. Matching them to your theme is possible but rarely worth it; users expect help to look like help.

Should I support light and dark theme variants?

Usually not needed if you stick to named ANSI colours, which the terminal adapts for you. If you use RGB colours for branding, provide two themes and let users choose with a config setting or an environment variable.

How do I style log output consistently?

Rich's RichHandler renders log levels with styles named logging.level.warning and so on; add those names to your theme to align log colours with message colours. The handler itself is covered in structured logging for CLI apps.

Where should the theme live in the codebase?

In one small module next to the command layer, as theme.py above, imported only by code that produces human output. Core logic should never import it — core returns data and raises errors, and the command layer decides how they look. That separation is what lets the same core serve a --json mode, a TUI and tests without any styling concerns.

Are emoji a good fit for status symbols?

Plain symbols (✓ ✗ !) render at a predictable width in nearly every font; emoji vary in width and may show as boxes on servers and older consoles. Use symbols, and provide ASCII fallbacks when the output encoding cannot represent them.