Decide Rich’s output mode from sys.stdout.isatty() plus the NO_COLOR and CI environment variables, then build rich.console.Console with force_terminal and no_color set accordingly. When the destination is a pipe, a redirected file, or a CI runner, return a plain console with color disabled and skip the live progress bar; when it is an interactive terminal, return the full styled console. This page is part of the Rich Console Output & Progress Bars for GIS CLIs guide within the broader CLI Architecture & Design Patterns for Python GIS reference.
Prerequisites
- Python 3.10 or later
pip install rich(Rich 13.0+)- A GDAL-backed toolchain for the raster examples (
pip install rasterio), though the detection logic itself has no geospatial dependency
If your tool already reads settings from the shell, pair this with Environment Variable Sync so NO_COLOR and CI are resolved through the same layered configuration as everything else.
How the Decision Is Made
The core question is a single boolean: is the current stdout an interactive terminal, or is it a pipe, a file, or a CI log? Rich answers this on its own most of the time, but a robust CLI makes the decision explicit so it can also disable progress widgets and route logs to the right stream. The diagram below shows the three inputs that feed the decision and the two console shapes that come out.
Complete Working Implementation
The module below exposes a single make_console() helper plus a batch_progress() context manager that renders a live bar on a terminal and periodic plain lines everywhere else. It processes a directory of GeoTIFFs (reprojecting each to a target CRS) purely to give the progress reporting something real to track. Copy it, adjust the paths, and run it both directly and piped:
#!/usr/bin/env python3
"""
Console/output-mode detection for a Rich-based geospatial CLI.
Usage:
python clean_output.py ./input --crs EPSG:3857 # interactive
python clean_output.py ./input --crs EPSG:3857 | cat # plain, no ANSI
"""
import os
import sys
import time
import argparse
from pathlib import Path
from contextlib import contextmanager
from rich.console import Console
from rich.progress import Progress, BarColumn, TimeRemainingColumn
import rasterio
from rasterio.warp import calculate_default_transform, reproject, Resampling
def _truthy(name: str) -> bool:
"""A variable counts as 'set' if it exists and is not an explicit off value."""
value = os.environ.get(name)
if value is None:
return False
return value.strip().lower() not in {"", "0", "false", "no"}
def is_interactive(stream=sys.stdout) -> bool:
"""Return True only when the stream is a real terminal AND nothing forces plain.
NO_COLOR (https://no-color.org) and CI take precedence over TTY detection:
a developer may run interactively but still want machine-clean output.
"""
if _truthy("NO_COLOR"):
return False
if _truthy("CI"):
return False
# isatty() may be absent on some wrapped streams (e.g. pytest capture).
return bool(getattr(stream, "isatty", lambda: False)())
def make_console(stderr: bool = True) -> Console:
"""Build a Console tuned for the current output destination.
- Interactive terminal: full color, Rich picks the true width.
- Piped / redirected / CI: no_color=True and force_terminal=False so
absolutely no ANSI escape codes reach the pipe.
Logs go to stderr by default so stdout stays a clean data channel.
"""
interactive = is_interactive(sys.stdout)
return Console(
stderr=stderr,
# force_terminal=False hard-disables ANSI; None would let Rich guess.
force_terminal=False if not interactive else None,
no_color=not interactive,
# A fixed width keeps wrapped log lines reproducible in CI artifacts.
width=None if interactive else 100,
highlight=interactive,
)
@contextmanager
def batch_progress(console: Console, total: int, label: str):
"""Live Rich bar when interactive; periodic plain log lines otherwise."""
if is_interactive(sys.stdout):
with Progress(
"[progress.description]{task.description}",
BarColumn(),
"[progress.percentage]{task.percentage:>3.0f}%",
TimeRemainingColumn(),
console=console,
) as progress:
task = progress.add_task(label, total=total)
yield lambda: progress.advance(task)
else:
# Plain mode: no carriage returns, just a line every 10% or 5 seconds.
state = {"done": 0, "last": time.monotonic()}
def advance():
state["done"] += 1
now = time.monotonic()
step = max(1, total // 10)
if state["done"] % step == 0 or now - state["last"] >= 5:
pct = 100 * state["done"] / total
console.print(f"{label}: {state['done']}/{total} ({pct:.0f}%)")
state["last"] = now
yield advance
console.print(f"{label}: {total}/{total} (100%) done")
def reproject_one(src_path: Path, dst_path: Path, target_crs: str) -> None:
with rasterio.open(src_path) as src:
transform, width, height = calculate_default_transform(
src.crs, target_crs, src.width, src.height, *src.bounds
)
profile = src.profile.copy()
profile.update(crs=target_crs, transform=transform, width=width, height=height)
with rasterio.open(dst_path, "w", **profile) as dst:
for band in range(1, src.count + 1):
reproject(
source=rasterio.band(src, band),
destination=rasterio.band(dst, band),
src_crs=src.crs,
dst_crs=target_crs,
resampling=Resampling.bilinear,
)
def main() -> int:
parser = argparse.ArgumentParser(description="Reproject a folder of GeoTIFFs")
parser.add_argument("input_dir", type=Path)
parser.add_argument("--crs", default="EPSG:4326", help="Target CRS, e.g. EPSG:3857")
parser.add_argument("--out", type=Path, default=Path("./out"))
args = parser.parse_args()
args.out.mkdir(parents=True, exist_ok=True)
console = make_console(stderr=True) # logs/progress -> stderr
result = Console() # data payload -> stdout
sources = sorted(args.input_dir.glob("*.tif"))
if not sources:
console.print("[yellow]No .tif files found[/]")
return 2
console.print(f"Reprojecting {len(sources)} rasters to {args.crs}")
ok = 0
with batch_progress(console, len(sources), "warp") as advance:
for src in sources:
dst = args.out / f"{src.stem}_{args.crs.replace(':', '_')}.tif"
try:
reproject_one(src, dst, args.crs)
ok += 1
except Exception as exc: # noqa: BLE001
console.print(f"[red]FAIL[/] {src.name}: {exc}")
advance()
# Machine-readable summary on stdout, never styled.
result.print(f"{ok} {len(sources)}")
return 0 if ok == len(sources) else 12
if __name__ == "__main__":
sys.exit(main())
Step Annotations
-
_truthy()for env vars —NO_COLORfollows the no-color.org convention where mere presence disables color, but treating an explicitNO_COLOR=0as “off” is friendlier for users who export it globally. The same helper handlesCI, which most runners set totrue. -
is_interactive()order of checks — Environment overrides are evaluated beforeisatty(). A developer running locally can still force clean output withNO_COLOR=1 mytool ..., and a CI job gets plain output even though some runners allocate a pseudo-TTY that would otherwise passisatty(). -
getattr(stream, "isatty", lambda: False)— Wrapped streams (pytest’s capture buffer, some logging handlers) do not always implementisatty(). Guarding the attribute access prevents anAttributeErrorfrom crashing the CLI under test. -
force_terminal=FalseversusNone— PassingFalsehard-disables ANSI; passingNonelets Rich re-run its own detection, which can disagree with yours. Because the plain path must guarantee zero escape codes, it uses the explicitFalse. -
width=100in plain mode — Rich normally probes terminal width, which is undefined on a pipe and defaults to 80. Pinning it keeps wrapped log lines byte-for-byte reproducible across local runs and CI artifacts. -
Two consoles, two streams —
consoletargets stderr for human logs and progress;resulttargets stdout for the machine summary. This is what letsmytool ... > data.txtcapture only the payload while progress still reaches the terminal, and it dovetails with the structured logs described in Error Handling in Spatial Pipelines. -
batch_progress()degradation — In plain mode there is no live bar; the closure prints one line per 10% or every five seconds. CI logs then show real advancement without thousands of carriage-return redraws, and exit code12still signals a partial batch failure.
Named Gotcha: | tee, nohup, and pytest Fool Auto-Detection
Rich’s built-in detection is correct for a plain mytool > file.txt, but three common wrappers defeat it. Running under nohup or inside some CI runners can allocate a pseudo-terminal, so isatty() returns True and Rich happily writes ANSI codes into what is actually a log file. Piping through tee (mytool | tee run.log) sends output to both a terminal and a file, and if you forced a terminal anywhere those escape codes land in run.log. Under pytest, the capture fixture replaces sys.stdout with an object that may or may not implement isatty().
The worst version of this corrupts a structured log: if any part of the payload on stdout carries \x1b[32m sequences, a downstream json.loads() throws json.decoder.JSONDecodeError on the first byte. The fix is the design above — never call force_terminal=True, always route color-bearing output to stderr, and keep the machine payload on a separate unstyled console. When you must override detection for a genuinely non-TTY terminal (a Docker -t shell), expose an explicit --color/--no-color flag rather than guessing.
Verification
Prove that piped output contains no escape codes. The cat -v view and a grep for the escape byte are the fastest checks:
# 1. Piped through cat: there must be NO ^[[ sequences in the output.
python clean_output.py ./input --crs EPSG:3857 | cat -v
# 2. Grep for the raw ESC byte (0x1b). Zero matches = clean.
python clean_output.py ./input --crs EPSG:3857 2>&1 | grep -c $'\x1b\['
# expected: 0
# 3. CI simulation: force the plain path even in an interactive shell.
CI=true python clean_output.py ./input --crs EPSG:3857 | cat -v
# 4. Confirm the machine payload on stdout is unstyled and parseable.
python clean_output.py ./input --crs EPSG:3857 2>/dev/null
# expected: two integers, e.g. "12 12", with no color
A zero count from step 2 and clean integers from step 4 confirm that both the log stream and the data stream stay ANSI-free when redirected.
Troubleshooting
| Symptom | Root Cause | Fix |
|---|---|---|
| ANSI codes in a redirected log file | force_terminal=True set somewhere |
Remove it; let no_color follow isatty() |
Color still shows under nohup / Docker -t |
Pseudo-TTY makes isatty() return True |
Add an explicit --no-color flag and honour NO_COLOR |
JSONDecodeError on captured stdout |
Styled bytes mixed into the data payload | Route logs to Console(stderr=True), payload to a plain console |
Progress bar spams \r in CI logs |
Live Progress used in non-TTY mode |
Gate Progress on is_interactive() and print plain lines |
AttributeError: isatty under pytest |
Capture stream lacks isatty() |
Guard with getattr(stream, "isatty", lambda: False) |
FAQ
Why does Rich still emit color when I redirect output to a file?
Rich only auto-detects a non-terminal on the stream it was constructed with. If you build one global Console bound to stdout but write logs elsewhere, or if force_terminal was set anywhere, detection is bypassed. Build the Console from an explicit isatty() check and leave force_terminal as None unless you deliberately want to override it.
What is the difference between NO_COLOR and forcing a plain terminal?
NO_COLOR strips ANSI color and style but keeps Rich’s layout features such as progress bars and tables in a terminal. A plain, non-TTY console disables color and also drops live-updating widgets because a pipe cannot process carriage returns. For clean log files you want the fully plain path, not just NO_COLOR.
How do I keep the progress bar off log files but on for interactive users?
Gate the Rich Progress instance on the same TTY detection used for the console. When stdout is not a terminal, skip the live Progress and log a plain percentage line every N items or every few seconds instead, so CI logs show measurable advancement without carriage-return spam.
Should logs go to stdout or stderr?
Send machine-consumable results to stdout and human-facing logs and progress to stderr via Console(stderr=True). This lets a user run the tool and pipe stdout to jq or a file while still seeing progress on the terminal, and it prevents progress redraws from corrupting a structured JSON payload.
Related
- Rich Console Output & Progress Bars for GIS CLIs — parent guide covering styled tables, live displays, and progress reporting for geospatial command-line tools
- Rendering a Live Rich Dashboard for Batch Raster Jobs — the interactive counterpart, showing when a live dashboard is worth the terminal-only complexity