The same command should behave differently when a person is watching and when it is running inside a pipeline or a CI job. Colour, progress bars, prompts, table widths and pagers all depend on that one question, and getting it wrong is how a tool ends up writing escape sequences into a log file or hanging a build waiting for an answer.
TL;DR
sys.stdout.isatty()for output decisions;sys.stdin.isatty()for input ones — they differ.- Honour
NO_COLORand an explicit--color/--no-color, in that precedence. - Treat
CIandTERM=dumbas strong hints that nobody is watching. - Decide once, in one helper, and let every renderer follow it.
- Test both branches: under the runner,
isattyis alwaysFalse.
Prerequisites
A CLI that prints something more elaborate than plain text — colour, a table, a progress display — and the stream discipline from working with stdin, stdout and pipes.
Which stream to ask
The mistake worth avoiding first: stdout and stdin answer different questions and are checked
for different reasons.
sys.stdout.isatty() # should the RESULT be formatted for a human?
sys.stderr.isatty() # should PROGRESS be a live display?
sys.stdin.isatty() # is there a person who can answer a prompt?
They genuinely differ in practice. mytool export > data.json has a redirected stdout and a
terminal stderr, so results should be plain while a progress bar is still welcome.
cat jobs.json | mytool apply - has piped stdin and terminal output, so prompting is impossible
even though colour is fine.
One helper, consulted everywhere
Scattering isatty checks through the codebase guarantees they drift. Decide once:
# src/mytool/environment.py
import os
import sys
from dataclasses import dataclass
@dataclass(frozen=True)
class Presentation:
colour: bool
live_progress: bool
can_prompt: bool
width: int | None # None means "fit the terminal"
def detect(colour_flag: bool | None = None) -> Presentation:
ci = bool(os.environ.get("CI"))
dumb = os.environ.get("TERM") == "dumb"
no_colour_env = os.environ.get("NO_COLOR") is not None
if colour_flag is not None:
colour = colour_flag # an explicit flag always wins
else:
colour = sys.stdout.isatty() and not (no_colour_env or dumb)
return Presentation(
colour=colour,
live_progress=sys.stderr.isatty() and not (ci or dumb),
can_prompt=sys.stdin.isatty() and not ci,
width=None if sys.stdout.isatty() else 80,
)
Build it once in the callback, put it on the context, and let renderers read it. Nothing else in
the program calls isatty — which also means the whole behaviour can be tested by constructing a
Presentation directly, with no patching.
Honouring the conventions
Three environment variables are worth respecting because users already expect them:
NO_COLOR. Any value, including empty, means "no colour anywhere". It is a cross-tool
convention, it costs one line, and users who set it have usually set it because something in their
pipeline chokes on escape sequences.
CI. Set by essentially every CI provider. It is not a statement about terminals — some CI
runners do allocate a TTY — but it is a reliable signal that nobody is watching interactively, which
is what matters for live progress and prompts.
TERM=dumb. Set by editors' embedded terminals and some tooling. It means "this terminal
cannot handle cursor movement", so it should disable live displays even if isatty says true.
An explicit flag beats all of them:
@app.callback()
def main(
ctx: typer.Context,
color: Annotated[bool | None, typer.Option("--color/--no-color", help="Force colour on or off.")] = None,
) -> None:
ctx.obj = detect(colour_flag=color)
What each branch should do
Colour off rather than "colour on regardless". Plain output is always readable; escape sequences in a log file are not.
Progress becomes periodic lines rather than disappearing entirely. A CI job running a twenty-minute command with no output looks hung to whoever is watching the build; one line a minute is enough:
if presentation.live_progress:
with Progress(console=err) as progress:
...
else:
for index, item in enumerate(items, start=1):
if index % 500 == 0:
err.print(f"{index}/{len(items)} processed")
Prompts fail fast, naming the flag that supplies the value, rather than blocking. A prompt in CI hangs the job until the pipeline times out, and the log gives no clue why.
Tables get a fixed width so redirected output is stable and diffable. This is also what makes snapshot testing of output possible at all.
UX considerations
Never make the human path the only path. Every interactive behaviour needs a non-interactive
equivalent: --yes for a confirmation, a flag for a prompted value, --format json for a table.
Tools that fail this are the ones people wrap in expect.
Do not detect a "smart terminal" and use it as licence. Unicode box characters and 24-bit colour render beautifully somewhere and badly elsewhere. Rich degrades sensibly; hand-rolled detection usually does not.
Make the decision visible. A doctor command that prints the detected presentation — colour on
or off, and why — turns "why is my output plain" into a five-second answer instead of a support
thread.
Respect the user's choice permanently. If someone passes --no-color, nothing later in the run
may re-enable it, including a library you call. Constructing the console once with no_color=True
is what enforces that.
Testing the behaviour
Under CliRunner, streams are captured, so isatty() is always False — which means the
interactive branch is never exercised unless you make it so. Testing the helper directly is the
clean way:
import pytest
@pytest.mark.parametrize("flag,tty,no_color_env,expected", [
(None, True, False, True), # terminal, nothing set: colour on
(None, False, False, False), # piped: colour off
(None, True, True, False), # NO_COLOR beats the terminal
(True, False, True, True), # an explicit flag beats everything
(False, True, False, False),
])
def test_colour_decision(monkeypatch, flag, tty, no_color_env, expected):
monkeypatch.setattr("sys.stdout.isatty", lambda: tty)
monkeypatch.delenv("NO_COLOR", raising=False)
if no_color_env:
monkeypatch.setenv("NO_COLOR", "1")
assert detect(colour_flag=flag).colour is expected
Five cases, no runner, and they pin the precedence rule that is otherwise easy to get backwards. Then one runner test proves the wiring:
def test_no_color_flag_reaches_the_output(cli):
result = cli.invoke(app, ["--no-color", "status"])
assert "\x1b[" not in result.stdout # no escape sequences at all
Conclusion
One helper, built once from the streams and three environment variables, decides colour, progress, prompts and width for the whole program. That single decision point is what keeps behaviour consistent between commands, makes both branches testable without patching, and stops your tool writing escape sequences into somebody's log file.
Frequently asked questions
Why is isatty False in my tests?
Because the runner replaces the streams with in-memory buffers, which are not terminals. That is
correct — it makes the non-interactive path the default in tests — and it is why the interactive
branch needs an explicit override or a directly constructed Presentation.
Should I check isatty on stdout or stderr for progress?
stderr, because that is where progress is written. A command whose stdout is redirected to a file but whose stderr is a terminal should still show a live progress bar — that combination is extremely common, and getting it wrong means users lose progress output whenever they redirect results.
Does NO_COLOR need a specific value?
No. The convention is that the variable being present — even empty — disables colour. Checking for
truthiness rather than presence is a common bug, because NO_COLOR= (empty) is a legitimate way to
set it.
What about FORCE_COLOR?
Some ecosystems honour it to force colour on when output is piped, typically so a CI system can
render coloured logs. It is reasonable to support: treat it exactly like --color, taking
precedence over detection but not over an explicit --no-color.
How do I test the interactive branch?
Construct the Presentation directly, or monkeypatch the one helper. Trying to allocate a real
pseudo-terminal in a test is possible with pty and rarely worth it — the thing worth testing is
your decision logic, not the operating system's.
Does this interact with the pager?
Yes, and the same rule applies: page only when stdout is a terminal. Click's echo_via_pager
already checks, but a hand-rolled call to less will happily launch inside a CI job and block the
build. If you page, honour PAGER and provide a --no-pager flag, because someone will want the
raw output at some point.
What about tools that call my tool?
Treat them as any other non-interactive caller: they get plain output, no prompts and no live
progress, because their stdout is a pipe. If a wrapper genuinely needs coloured output — a build
system that renders logs — FORCE_COLOR or an explicit --color is the documented way to ask.
Should the tool remember a colour preference between runs?
No. Configuration files are for settings that describe intent; presentation should follow the
environment it is running in right now, because the same install is used interactively and from
scripts. NO_COLOR in a shell profile is already the durable way for a user to express the
preference, and it works across every tool rather than only yours.