Input & UX

Cross-Platform Terminal Compatibility for Python CLIs

Make Python CLI output work everywhere: Windows encodings, colour conventions, terminal width, CI and non-interactive shells, and one render decision made at startup.

Updated

A command-line tool is developed in one terminal and used in dozens. The author's macOS terminal is wide, dark-themed, UTF-8 and full of colour. The tool's users run it in Windows Terminal, in the legacy Windows console, inside VS Code's integrated terminal, over SSH into an 80-column server session, inside tmux, in GitHub Actions logs, in cron jobs whose output goes to email, and piped into jq, less or a file. Each of those environments differs in what it can display, how wide it is, whether a person is watching, and what bytes it expects. Output that looks perfect on the author's machine crashes with UnicodeEncodeError on Windows, fills CI logs with thousands of progress-bar redraws, writes ANSI escape codes into data files, or wraps into an unreadable mess at 80 columns.

This topic covers making a Python CLI's terminal behaviour portable: text encoding (especially on Windows), colour and the NO_COLOR/FORCE_COLOR conventions, adapting to terminal width, and detecting CI and non-interactive sessions — all tied together by one decision made at startup about how to render. It sits in the Advanced Input Parsing & User Experience section and builds on working with stdin, stdout and pipes, which covers the stream-level rules.

What this topic covers The cross-platform terminal topic covers Unicode and encoding on Windows, NO_COLOR and FORCE_COLOR, terminal width, and detecting CI and non-interactive shells. What this topic covers Terminals are not all alike Windows, CI, pipes, SSH Encoding UTF-8 on Windows Colour NO_COLOR, FORCE_COLOR Width wrap, truncate, fit CI + non-TTY no prompts, no spinners each branch has its own in-depth guide The same output meets a Windows console, a CI log viewer, a pipe and an 80-column SSH session.

TL;DR

  • Encode explicitly. Write files with encoding="utf-8", reconfigure stdout/stderr to UTF-8 with errors="replace" on Windows, and never let a symbol crash a command.
  • Colour follows conventions. A --color flag beats NO_COLOR, which beats FORCE_COLOR, which beats automatic TTY detection.
  • Width comes from the terminal, with COLUMNS and a fallback of 80; human views adapt, machine output never truncates.
  • Detect non-interactive sessions — no TTY, CI=true, TERM=dumb — and switch off prompts, spinners and pagers.
  • Decide once, at startup, and pass the decision down, instead of checking environment variables in every print.

Where your output ends up

Where your output ends up Output environments a command line tool meets, whether each is a terminal, whether it supports colour, and what usually goes wrong. Where your output ends up Environment TTY? Colour? Usual problem macOS / Linux terminal yes yes narrow windows Windows Terminal yes yes legacy code pages Legacy Windows console yes limited encoding errors CI log no often rendered spinner spam Pipe or file no no escape codes in data Detect the environment instead of assuming the one on your desk.

It helps to think of each environment in terms of three questions: Is it a terminal? (a TTY, as reported by isatty()), what can it display? (colours, Unicode, cursor movement), and is a person watching? Interactive terminals answer yes to all three; pipes and files answer no to all three; CI logs are the awkward middle — not a TTY, often rendered by a viewer that understands colour codes, and watched by a person only after the fact.

The failure pattern is always the same: code written for the first case running in one of the others. The fix is also always the same: detect the environment, and choose behaviour from it.

One render decision

Scattering if sys.stdout.isatty() and os.environ.get("NO_COLOR") checks through a codebase guarantees they disagree. Make the decision once, early, and store it:

Deciding how to render The CLI inspects whether stdout is a terminal, colour environment variables and CI markers, and chooses rich interactive output, plain output or machine output. Deciding how to render isatty() stdout, stderr NO_COLOR / FORCE_COLOR user intent CI / TERM=dumb environment Render mode rich / plain / json then then decide Decide once at startup and pass the decision down, instead of checking in every print.
# src/mytool/terminal.py
from __future__ import annotations

import os
import shutil
import sys
from dataclasses import dataclass
from typing import Literal

ColorChoice = Literal["auto", "always", "never"]


@dataclass(frozen=True)
class RenderMode:
    color: bool          # emit colour/styles
    interactive: bool    # a person at a terminal: prompts, spinners, live updates allowed
    width: int           # columns for human-readable layout
    ci: bool             # running under a CI system


def _truthy(name: str) -> bool:
    return os.environ.get(name, "").lower() not in ("", "0", "false", "no")


def detect(color: ColorChoice = "auto", stream=None) -> RenderMode:
    stream = stream or sys.stdout
    is_tty = hasattr(stream, "isatty") and stream.isatty()
    dumb = os.environ.get("TERM") == "dumb"
    ci = _truthy("CI")
    if color == "always":
        use_color = True
    elif color == "never" or os.environ.get("NO_COLOR"):     # any non-empty value
        use_color = False
    elif _truthy("FORCE_COLOR"):
        use_color = True
    else:
        use_color = is_tty and not dumb
    width = shutil.get_terminal_size(fallback=(80, 24)).columns
    interactive = is_tty and sys.stdin.isatty() and not dumb and not ci
    return RenderMode(color=use_color, interactive=interactive, width=max(width, 40), ci=ci)

Created once in the CLI's callback and stored on the context, RenderMode drives everything downstream: whether the Rich Console uses colour, whether a progress bar is shown or replaced by periodic log lines, whether prompts are allowed, and how wide tables may be. Each piece is explored in its own guide below.

Wiring the decision into Rich and the command layer

The render mode is only useful if every piece of output goes through it. In practice that means building the tool's two consoles — one for results on stdout, one for everything else on stderr — from the mode, in the top-level callback, and passing them down:

from typing import Annotated

import typer
from rich.console import Console

from mytool.terminal import ColorChoice, RenderMode, detect

app = typer.Typer()


class UI:
    def __init__(self, mode: RenderMode) -> None:
        self.mode = mode
        self.out = Console(no_color=not mode.color, width=mode.width, highlight=False)
        self.err = Console(stderr=True, no_color=not mode.color, width=mode.width)


@app.callback()
def main(
    ctx: typer.Context,
    color: Annotated[str, typer.Option("--color", help="auto, always or never.")] = "auto",
) -> None:
    ctx.obj = UI(detect(color))           # the one place the environment is inspected

Commands then call ctx.obj.out.print(...) for results and ctx.obj.err.print(...) for narration, and ask ctx.obj.mode.interactive before prompting or starting a live display. Nothing below the callback reads os.environ or calls isatty() again, so the behaviour is consistent across commands and trivially testable: construct a RenderMode by hand and pass it in. The same approach keeps libraries honest — core modules never print, so they cannot bypass the decision.

Pagers, long output and line endings

Two more portability details catch tools that produce a lot of output.

Pagers. Long help text, logs or reports are easier to read through less, and Click offers click.echo_via_pager(). Only page when the mode is interactive; honour the user's PAGER variable (and LESS options); and let --no-pager or PAGER=cat disable it. A pager opened in CI, or in a pipe, either hangs or dumps control sequences into the log. On Windows, more is the fallback pager and behaves differently from less, which is another reason to keep paging optional.

Line endings. Text written to stdout in text mode on Windows gets \r\n line endings, which is what Windows users expect in a console and in files they open in Notepad — but not what a Linux tool downstream in a pipeline expects. For machine-readable output that may cross platforms, such as NDJSON or CSV consumed by other programs, consider writing \n explicitly by reconfiguring the stream with newline="\n", and document the choice. Files your tool generates should follow the same rule, as discussed in filesystem paths and atomic writes.

Paths in output. Printing str(path) gives backslashes on Windows, which is right for users but breaks consumers that parse paths. Use native paths in human output and Path.as_posix() in machine output, as described in cross-platform paths with pathlib.

Encoding: the Windows problem

On Linux and macOS, Python's standard streams are UTF-8 in practically every environment. On Windows the story is more complicated. The modern console handles Unicode — Python writes to it with wide-character APIs — but when output is redirected to a file or pipe, Python encodes it with the locale's legacy code page, typically cp1252 in Western Europe and the Americas. Printing or or a user's name with characters outside that code page then raises UnicodeEncodeError — only when redirected, which is precisely the case that escapes manual testing.

import sys

for stream in (sys.stdout, sys.stderr):
    if hasattr(stream, "reconfigure") and (stream.encoding or "").lower() != "utf-8":
        stream.reconfigure(encoding="utf-8", errors="replace")

Reconfiguring the streams early in the entry point, combined with explicit encoding="utf-8" on every file your tool opens, eliminates the whole class of crash. Users can also opt the entire interpreter into UTF-8 with PYTHONUTF8=1, and Python 3.15 is planned to make UTF-8 mode the default — but CLIs support older Python versions for years. Fixing Unicode and encoding errors on Windows covers reading input as well as writing, subprocess output, and ASCII fallbacks for symbols.

Colour, by convention

Colour makes interactive output easier to scan and makes everything else worse: escape codes in files, in grep output, in data piped to other programs. Two community conventions settle the question of who decides:

  • NO_COLOR — when set to a non-empty value, the user never wants colour, from any tool.
  • FORCE_COLOR — when set, emit colour even though the output is not a terminal; useful for CI log viewers that render ANSI codes.

A tool should honour both, let an explicit --color=always|never|auto flag override them, and otherwise colour only when writing to a terminal whose TERM is not dumb. Rich's Console implements this logic already; the mistake to avoid is writing raw escape codes that bypass it. Respecting NO_COLOR and FORCE_COLOR builds the flag and wires it into Rich and Click.

Width

Human-readable output — tables, wrapped help text, progress bars — needs to know how wide the terminal is. shutil.get_terminal_size() checks the COLUMNS environment variable, then asks the terminal, and falls back to a default when there is no terminal. Layouts should adapt: wrap long text, truncate identifiers with an ellipsis (with a --wide option to show them in full), drop low-priority columns, or switch to a vertical layout on very narrow screens. Machine-readable output must never be truncated to fit a width. Adapting output to terminal width works through each strategy with Rich tables.

Nobody watching: CI and non-interactive shells

The most expensive terminal mistake is a prompt in a CI job: nothing fails, the job simply waits until its timeout, often an hour later. Close behind are progress bars and spinners that redraw hundreds of times per second in a log file, and pagers that wait for a keypress nobody will press. A non-interactive session is signalled by stdin or stdout not being a TTY, by CI=true (set by GitHub Actions, GitLab CI and most others), and by TERM=dumb. In that mode a CLI should fail fast rather than prompt, replace animated progress with occasional timestamped lines, disable pagers, and make exit codes say exactly what happened. Detecting CI environments and non-interactive shells implements it, including the GitHub Actions log grouping and annotation syntax that makes CI output genuinely nicer.

Terminals differ in more than "colour or not". Some support only the basic 8 or 16 ANSI colours, many support 256, and most modern ones support 24-bit truecolour, usually advertised with COLORTERM=truecolor. Rich detects the depth and downgrades colours automatically, so a palette designed in truecolour still produces sensible output on a 16-colour console — one more reason to route styling through Rich rather than hand-written escape codes. When choosing colours, prefer the named ANSI colours (red, green, yellow) for status, since terminal themes remap them to fit light and dark backgrounds, and reserve exact RGB values for decoration.

Modern terminals also support clickable hyperlinks via the OSC 8 escape sequence, which Rich exposes as [link=https://...]text[/link]. Linking a build ID to its web page or an error to its documentation is a genuine convenience — and in terminals that do not support links the text simply appears without them. As with colour, hyperlinks are markup that must never reach files or pipes, so they belong to the same render decision.

Finally, remember that the terminal theme is not yours to choose. Users run light and dark themes, high-contrast themes and custom palettes. Avoid dim grey text for important information, never use colour as the only signal, and test output in both a light and a dark theme at least once. These are small habits, but they are the difference between output that is pleasant everywhere and output that is illegible on half your users' screens. The broader styling approach is covered in theming Rich output consistently.

Symbols, glyphs and fonts

Beyond encoding, there is a quieter compatibility question: can the user's terminal font display the characters you print? Box-drawing characters (used by Rich tables and panels) are safe almost everywhere. Arrows, check marks and ballot boxes are safe in modern terminals. Emoji are the risky category: they render at inconsistent widths, break column alignment, and show as empty boxes in older consoles and many server fonts. A few rules keep output legible everywhere:

  • Never let a symbol carry meaning alone. "✓ passed" and "✗ failed" read correctly even if the symbol renders as a box; a bare "✓" does not.
  • Prefer simple symbols to emoji in anything tabular, where width consistency matters.
  • Offer an ASCII mode. When the output encoding cannot represent a symbol, fall back to [ok] and [FAIL]; Rich's safe_box and legacy_windows handling cover tables and panels automatically.

Testing across environments

Terminal behaviour depends on environment variables and stream types, both of which tests can control. CliRunner gives your command non-TTY streams and accepts an env= mapping, so every mode is reachable from a unit test:

from typer.testing import CliRunner

from mytool.cli import app

runner = CliRunner()


def test_no_color_means_no_escape_codes():
    result = runner.invoke(app, ["status", "--color", "auto"], env={"NO_COLOR": "1"})
    assert "\x1b[" not in result.output


def test_force_color_in_ci():
    result = runner.invoke(app, ["status"], env={"FORCE_COLOR": "1", "CI": "true"})
    assert "\x1b[" in result.output


def test_narrow_terminal_does_not_crash():
    assert runner.invoke(app, ["status"], env={"COLUMNS": "40"}).exit_code == 0

Add a Windows job to CI — ideally one that runs with output redirected and PYTHONUTF8=0 — because encoding failures cannot be reproduced on Linux or macOS. The matrix setup is in testing a CLI across Python versions with GitHub Actions.

Portable terminal output Practices that make command line output work across operating systems and environments, and habits that break it. Portable terminal output Do Write UTF-8 explicitly; replace what cannot encode Honour NO_COLOR and FORCE_COLOR Read the width from the terminal, fall back to 80 Plain output when not a TTY Avoid Emoji as the only carrier of meaning Hard-coded ANSI escape strings Assuming 120 columns Progress bars in CI logs Rich handles most of the left column for you, if you let its Console decide.

Key takeaways

  • Output meets many environments; decide how to render once, at startup, from the stream type and environment.
  • Reconfigure standard streams to UTF-8 with errors="replace" and open files with explicit encodings.
  • Honour --color, then NO_COLOR, then FORCE_COLOR, then TTY detection — ideally all through one Rich Console.
  • Read the terminal width, adapt human views, never truncate machine output.
  • In CI and non-interactive shells: no prompts, no animations, no pagers, clear exit codes.
  • Do not rely on symbols or colour alone to carry meaning; test every mode with CliRunner and a Windows CI job.

Frequently asked questions

Do I still need colorama on Windows?

Rarely. Windows 10 and later support ANSI escape sequences in the console once virtual-terminal processing is enabled, which Rich and Click handle for you. colorama remains useful only for very old Windows versions or code writing raw escape sequences.

Should the tool detect specific terminals like VS Code or tmux?

Generally no. Detect capabilities — TTY, TERM, colour depth via COLORTERM — rather than products. Product checks break as terminals change; capability checks keep working.

What about right-to-left text and combining characters?

Terminals vary widely in how they render bidirectional text and combining marks, and alignment is hard to get right. Rich measures cell widths correctly for most scripts; for data with lots of such text, prefer layouts that do not depend on column alignment.

Why does output look different inside Docker?

docker run without -t gives the container no TTY, so a well-behaved CLI switches to plain, uncoloured output — which surprises people who expected the same output as on their laptop. That is correct behaviour; docker run -it allocates a terminal and restores the interactive mode, and FORCE_COLOR=1 restores colour for log collectors that render it.

Is TERM=dumb still relevant?

Yes: it is set by Emacs shell buffers, some IDE consoles and several CI systems, and it means "no cursor movement or colour". Treat it like a non-interactive terminal for rendering purposes.

How do I let users override automatic detection?

With flags that beat the environment: --color=always|never, --no-input, --width N, and --progress/--no-progress. Detection gives good defaults; flags give control.