Input & UX

Respecting NO_COLOR and FORCE_COLOR in Python CLIs

Handle colour the way users expect in a Python CLI: a --color flag, the NO_COLOR and FORCE_COLOR conventions, TTY detection with Rich, and tests for every mode.

Updated

Colour makes a CLI's output faster to scan: green for healthy, red for failed, dim for less important detail. It also causes some of the most irritating bugs in command-line tools — escape codes like \x1b[32m sprinkled through files, grep results, CI logs and data piped into other programs — and it ignores users who simply do not want it, whether for accessibility, readability on their terminal theme, or preference. Two community conventions exist precisely to settle who decides: NO_COLOR (colour off, from any tool) and FORCE_COLOR (colour on, even when the output is not a terminal). This guide implements colour handling that respects both, adds a --color flag that beats them, routes everything through one Rich console so nothing bypasses the decision, and tests every combination. It belongs to the cross-platform terminal compatibility topic.

Prerequisites

  • A CLI that uses Rich for styled output (Typer includes it), or Click's style/secho.
  • A habit of never writing raw ANSI escape sequences yourself — if you have them, this guide replaces them.

Who decides whether to colour

Deciding whether to colour The order in which a CLI decides whether to emit colour: an explicit flag, NO_COLOR, FORCE_COLOR, then whether the stream is a terminal. Deciding whether to colour --color=always|never|auto wins explicit request on this command line NO_COLOR (any value) off the user never wants colour FORCE_COLOR on colour even when not a TTY — CI log viewers stream.isatty() and TERM != dumb auto the automatic default A flag on the command line beats any environment variable, as with every other setting.

The decision follows the same precedence as every other setting — explicit command-line choice first, then the environment, then automatic detection:

  1. --color=always|never|auto on the command line. The user said exactly what they want for this invocation.
  2. NO_COLOR: if set to any non-empty value, do not emit colour. It is how users opt out globally, across every tool that follows the convention.
  3. FORCE_COLOR: emit colour even though output is not a terminal. CI systems and log viewers that render ANSI codes set it so logs keep their colour.
  4. Automatic: colour only when writing to a terminal whose TERM is not dumb.

Note the subtlety in NO_COLOR's definition: it asks tools not to add colour; bold and underline are still allowed. Rich follows that reading, which is why NO_COLOR output may still contain bold escape codes when it goes to a terminal — and why a separate --color never that disables all styling is still worth offering.

Who already does this for you

Who honours what Which colour environment variables Rich, Click and the colorama package honour by default. Who honours what Library NO_COLOR FORCE_COLOR TTY detection Rich Console yes yes yes click.style / secho via strip on non-TTY no yes Hand-written ANSI only if you check only if you check only if you check Route all colour through one Console and the conventions are handled in one place.

Rich's Console implements most of the decision automatically: it detects whether its file is a terminal, respects TERM=dumb, strips colour when NO_COLOR is set, and forces styling when FORCE_COLOR is set. Click's echo strips ANSI codes when the output is not a terminal. The recurring bug is code that bypasses these: hand-written escape strings, print() of pre-styled text, or a second Console created somewhere with different settings. The fix is structural — make one console, from one decision, and use it everywhere.

The recipe

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

from enum import Enum

from rich.console import Console


class ColorMode(str, Enum):
    auto = "auto"
    always = "always"
    never = "never"


def make_console(mode: ColorMode = ColorMode.auto, *, stderr: bool = False) -> Console:
    """One place that decides styling.

    auto   -> Rich decides: TTY and TERM, then NO_COLOR (strip colour) and FORCE_COLOR (force it)
    always -> styled output even into pipes and files (the flag beats the environment)
    never  -> no escape codes at all
    """
    if mode is ColorMode.always:
        return Console(stderr=stderr, force_terminal=True)
    if mode is ColorMode.never:
        return Console(stderr=stderr, color_system=None, force_terminal=False)
    return Console(stderr=stderr)
# src/mytool/cli.py
from __future__ import annotations

from typing import Annotated

import typer

from mytool.color import ColorMode, make_console

app = typer.Typer()
STATUS = [("web", "healthy"), ("api", "degraded"), ("billing", "down")]
STYLE = {"healthy": "green", "degraded": "yellow", "down": "bold red"}
MARK = {"healthy": "✓", "degraded": "!", "down": "✗"}


@app.callback()
def main(
    ctx: typer.Context,
    color: Annotated[ColorMode, typer.Option("--color", envvar="MYTOOL_COLOR",
                                             help="Colour output: auto, always or never.")] = ColorMode.auto,
) -> None:
    """Service status tool."""
    ctx.obj = make_console(color)


@app.command()
def status(ctx: typer.Context) -> None:
    """Show service health."""
    console = ctx.obj
    for name, state in STATUS:
        console.print(f"{MARK[state]} {name:<8} [{STYLE[state]}]{state}[/]", highlight=False)


if __name__ == "__main__":
    app()

How it fits together:

  • The flag has three valuesauto, always, never — the same trio used by git, grep, ls and many others. auto is the default.
  • auto delegates to Rich, which applies TTY detection, TERM=dumb, NO_COLOR and FORCE_COLOR. There is no reason to reimplement that logic.
  • always uses force_terminal=True, producing styled output even into a pipe — useful for mytool status --color always | less -R.
  • never uses color_system=None, which disables all styling escape codes, including bold — stricter than NO_COLOR, for users and scripts that want guaranteed plain text.
  • envvar="MYTOOL_COLOR" lets users set a tool-specific default in their shell profile, which still loses to an explicit flag.
  • One console, created in the callback and passed down on the context. Commands never construct their own.
  • Colour never carries meaning alone. Each status has a symbol and a word; colour only reinforces them. With NO_COLOR, output loses decoration, not information.

For narration on stderr — progress, warnings — create a second console with stderr=True from the same mode, so both streams follow the same rule. Styling conventions for the palette itself are covered in theming Rich output consistently.

UX considerations

The same command, three ways Terminal output of a CLI run normally with colour, with NO_COLOR set, and piped to a file where no escape codes are written. The same command, three ways bash $ mytool status ✓ web healthy (green) $ NO_COLOR=1 mytool status ✓ web healthy $ mytool status | cat -v ✓ web healthy # no ^[[32m escape codes Colour is decoration; the text must carry the meaning on its own.
  • Plain output in pipes by default. mytool status | grep down should match plain text, not text wrapped in escape codes. Rich's automatic detection does this; code that forces styling does not.
  • Keep colour off stdout data. Machine output (--json) should never be styled, whatever the colour mode; styling is for human views.
  • Choose theme-safe colours. Use named ANSI colours (red, green, yellow) for status, which terminal themes remap to readable shades, rather than fixed RGB values that disappear on light or dark backgrounds.
  • Document the controls. One line in --help and a sentence in the docs about NO_COLOR, FORCE_COLOR and MYTOOL_COLOR saves users hunting for how to turn colour off.

Testing the behaviour

CliRunner output is not a terminal, which makes it perfect for checking the pipe case, and its env= parameter reaches every environment combination. Testing for the escape-sequence prefix \x1b[ is enough to tell styled from plain output:

# tests/test_color.py
import pytest
from typer.testing import CliRunner

from mytool.cli import app

runner = CliRunner()
ESC = "\x1b["


def run(*args, env=None):
    base = {"NO_COLOR": "", "FORCE_COLOR": "", "TERM": "xterm-256color"}
    return runner.invoke(app, [*args, "status"], env={**base, **(env or {})})


def test_auto_into_a_pipe_is_plain():
    assert ESC not in run().output


def test_always_beats_no_color():
    assert ESC in run("--color", "always", env={"NO_COLOR": "1"}).output


def test_never_beats_force_color():
    assert ESC not in run("--color", "never", env={"FORCE_COLOR": "1"}).output


def test_force_color_applies_in_auto_mode():
    assert ESC in run(env={"FORCE_COLOR": "1"}).output


def test_env_var_can_set_the_mode():
    assert ESC in run(env={"MYTOOL_COLOR": "always"}).output


@pytest.mark.parametrize("mode", ["auto", "always", "never"])
def test_meaning_survives_without_colour(mode):
    out = run("--color", mode).output
    assert "down" in out and "✗" in out

The base environment clears NO_COLOR and FORCE_COLOR so the developer's own settings — or the CI system's — cannot leak into the results. The last test is the one that protects accessibility: in every mode, the words and symbols that carry meaning are present.

Conclusion

Respecting colour conventions is mostly a matter of not fighting the tools that already implement them. Offer --color auto|always|never (with an environment variable for a personal default), let Rich apply NO_COLOR, FORCE_COLOR, TTY and TERM detection in auto mode, force or disable styling explicitly for the other two, and route every styled line through one console created from that decision. Make sure meaning never depends on colour, keep machine output unstyled, and test each mode with CliRunner.

Frequently asked questions

Should NO_COLOR also disable bold and emoji?

The convention covers colour only, so bold is allowed. If you want a mode with no styling at all, --color never provides it. Emoji are a separate question of font support, discussed in the topic overview.

What about CLICOLOR and CLICOLOR_FORCE?

These older BSD-era variables have similar meanings (CLICOLOR=0 off, CLICOLOR_FORCE=1 on). Supporting them costs a couple of lines in the callback — map them to the corresponding mode when neither the flag nor NO_COLOR/FORCE_COLOR is set — and pleases users of BSD and macOS tools.

What about colour on older Windows consoles?

Windows 10 and later understand ANSI escape sequences once virtual-terminal processing is enabled for the console, which Rich does automatically, and Windows Terminal supports truecolour. On the rare legacy console that cannot, Rich falls back to the Windows console API or to plain text. Either way the same make_console decision applies, and the --color never escape hatch works everywhere.

Does GitHub Actions show colour?

The Actions log viewer renders ANSI colours, but the job's stdout is not a TTY, so tools default to plain output. Setting FORCE_COLOR=1 in the workflow environment restores colour for Rich-based tools, which many teams do for readability.

Should Typer's own help and error output follow the flag?

Typer renders help and errors with Rich, which follows NO_COLOR and TTY detection. The global --color flag cannot affect help printed before it is parsed, but it rarely needs to: help goes to a terminal in interactive use and is plain in pipes already.