Input & UX

Advanced Input Parsing for Python CLIs

Master argument validation, config file precedence, env var handling, and Rich-based terminal rendering for reliable Python CLI input pipelines.

Updated

A CLI lives or dies on the boundary where untrusted input meets your code. This track covers that boundary in both directions: validating and merging everything the user throws at your tool — flags, environment variables, config files — and rendering output that reads clearly to humans while staying parseable for automation.

These patterns sit on top of a sound architecture. Here the focus is reliability and developer experience at the edges.

The CLI input pipeline The CLI input pipeline raw args env vars config files Parse Validate types & rules Merge config Run command Render output (Rich)

A CLI's public API is not what you think

The instinct when polishing a command-line tool is to reach for colour and progress bars. Those are the visible parts, but they are not the parts other software depends on. Four surfaces are:

The four surfaces a script depends on The parts of a command line tool that other programs depend on: the flags, the exit code, the shape of standard output and the messages on standard error. The four surfaces a script depends on changing any of these breaks somebody’s script Flags and arguments the call site Renaming or removing one is a breaking change, however small it looks. Exit codes the branch Every if in a shell script reads this. Meanings must stay stable across releases. stdout the data Anything piped into another program. Format changes break parsers downstream. stderr the narration Progress, warnings, errors. Free to change wording — nothing should parse it. only the last row is safe to reword freely A CLI’s public API is not its Python functions; it is these four surfaces.

Flags and arguments are the call site. Renaming --dir to --directory breaks every script that used the old name, no matter how much better the new one reads. Exit codes are the branch: every if mytool check; then in the world is reading one, and changing what code 2 means silently inverts someone's logic. Standard output is the data — the moment anyone pipes your tool into jq, its shape is a contract. Only standard error is genuinely free: it is narration for a human, and nothing should ever parse it.

That ordering explains why this track is sequenced the way it is. Getting exit codes and streams right costs an afternoon and prevents an entire class of downstream breakage. Getting colour right is pleasant, and nothing depends on it.

What this track covers

Validating arguments

The seven sections of this track The input and user experience track covers validation, configuration, error handling, terminal output, shell completion and logging. The seven sections of this track Input parsing & terminal UX everything between the user and your logic Validation reject bad input at the boundary Config & env flags, variables, files Errors & exit codes the contract with scripts Output & completion Rich, logging, tab completion each section carries runnable guides underneath it These are the parts users actually touch — and the parts that decide whether the tool feels finished.

Merging configuration

Terminal user experience

Failure, feedback, and logging

Validate at the boundary, once

Input arrives from four places — the command line, environment variables, configuration files and standard input — and all of it is untrusted in the same way: it is text typed or generated by something that does not share your assumptions. The pattern that keeps a codebase clean is to convert and check all of it at the boundary, so that everything deeper receives objects that are already known to be good.

Every framework gives you the hook. A parameter type converts and raises; the framework turns the raise into a usage error and exit code 2 without you writing that branch:

@app.command()
def deploy(
    manifest: Annotated[Path, typer.Argument(exists=True, dir_okay=False, readable=True)],
    replicas: Annotated[int, typer.Option(min=1, max=50)] = 3,
) -> None:
    # manifest exists and is readable; replicas is within range. No checks needed here.
    ...

What a parameter type cannot express is a rule that spans two values. --start before --end, or an option that only makes sense alongside another, is a cross-field rule, and it belongs in one validation step immediately after parsing — a model validator if you are parsing into a model, or three lines at the top of the command otherwise. What matters is that it happens once, in a named place, rather than being re-checked defensively in three functions.

The validation strategies guide works through the layering, including nested JSON arguments where the shape itself needs validating rather than a single value.

Configuration: one order, applied everywhere

The moment a tool has more than three options, users want to stop typing them. That means configuration, and configuration means precedence — which is where tools become confusing if the order is not decided deliberately.

The order that matches expectations is immediacy wins: a flag typed for this invocation beats an environment variable scoped to this shell, which beats a project file shared by the team, which beats the built-in defaults. Invert any pair of those and you get the single most frustrating class of bug — a value someone exported months ago quietly overriding what they just typed.

def resolve(cli: dict, env: dict, file: dict, defaults: dict) -> dict:
    merged = {**defaults, **file, **env}
    # Only keys the user actually set on the command line; an untouched option is None.
    merged.update({k: v for k, v in cli.items() if v is not None})
    return merged

Two details make or break this. First, only merge keys a source actually set — a parser default of 3 is indistinguishable from the user typing 3, which is why defaults belong in the lowest layer and options should have no default of their own. Second, coerce types after merging rather than per source, so the string "5" from an environment variable and the integer 5 from a flag go through exactly one conversion path.

Worth adding early: a way for users to see where a value came from. A config show --origin subcommand that prints each setting beside its source ends the "it ignored my setting" conversation permanently, and it costs one extra dictionary while merging. The configuration section covers the search paths, the file formats and the precedence rules in full.

Failure is an interface, not an accident

Most tools handle their happy path well and improvise everything else. That improvisation is what users remember.

Three categories cover almost every failure, and they differ in whose fault it is. A usage error means the command line was wrong: print the usage line, say which option was bad, exit 2 — your framework already does this if you let it. An expected failure means the command was used correctly but could not do the job: a missing file, a service that is down, a validation error in a config file. That deserves one clear sentence on stderr, no traceback, and a documented exit code. An unexpected exception is a bug in your code: a short message, a pointer at --debug for the traceback, and a distinct code so it is distinguishable in logs.

def main() -> None:
    try:
        app()
    except ConfigError as exc:
        typer.secho(f"config: {exc}", fg="red", err=True)
        raise SystemExit(78)
    except Exception:
        typer.secho("internal error — re-run with --debug for details", err=True)
        raise SystemExit(70)

One boundary, one mapping. Commands raise; they never call sys.exit themselves. That is what keeps exit-code meanings in a single reviewable place instead of scattered through twenty command bodies. The error handling section covers the taxonomy, the codes worth adopting and the --debug flag that turns the boundary off for developers.

Output that survives a pipe

The rule is simple and constantly broken: results go to stdout, everything else goes to stderr.

console = Console()                 # results
status = Console(stderr=True)       # progress, warnings, errors

Two console objects, created once, and every print in the program goes through one of them. That single habit is what makes mytool export > data.json produce a clean file while the user still sees progress on screen — and what stops a progress bar corrupting the output of a tool being piped into another.

Interactivity needs the same care in the other direction. A prompt is delightful when a person is typing and catastrophic in a CI job, where it hangs the pipeline until something times out.

Prompt for it, or require a flag? A decision diagram for interactive prompts: prompt only when a terminal is attached and the value cannot be defaulted, otherwise fail with a message naming the flag. Prompt for it, or require a flag? Is stdin a terminal, and is the value genuinely required? Yes to both — a person is running this Prompt with a default where sensible No — a pipe, a cron job, a CI step Fail fast name the flag that fixes it A prompt that appears in a CI job hangs the pipeline until it times out — check for a TTY first.

Check whether standard input is a terminal before prompting; if it is not, fail immediately with a message naming the flag that supplies the value. The same check governs colour, spinners and live progress displays — Rich handles most of it automatically, and the part it cannot guess is whether a bar belongs in a log file. The terminal UI section and the logging section cover both halves, including switching cleanly between human-readable and machine-readable output.

The polish that people actually notice

Two features do more for perceived quality than anything else, and both are cheap.

Tab completion turns discovery from reading --help into pressing a key. Click and Typer both ship it; Typer will even install it for the user's shell with a single command. The one constraint is speed: a dynamic completion callback runs inside the user's shell prompt, so if your CLI takes 400 ms to start, Tab feels broken and people stop using it. That makes completion the strongest practical argument for keeping startup fast.

Good error messages are the most-read output your tool produces, and the difference between a useful one and a useless one is structural: name the input that was wrong, say what was expected, and point at the thing that fixes it. Three sentences, no traceback, on stderr.

Colour, tables and progress bars come after those two. They are genuinely nice — a Rich table beats aligned print statements every time — but a beautifully coloured tool that hangs waiting for a prompt in CI is worse than a plain one that does not.

Logging, and why print() runs out

Every CLI starts with print(), and for a single-purpose script that is the right answer. The point at which it stops being the right answer is recognisable: someone asks for "more detail when it fails", and you find yourself adding an if verbose: in front of a dozen print calls.

The standard library already models this properly. A logger has a level, a handler has a destination, and a formatter decides appearance — three knobs that stay separate:

import logging

def configure_logging(verbosity: int) -> None:
    level = {0: logging.WARNING, 1: logging.INFO}.get(verbosity, logging.DEBUG)
    logging.basicConfig(
        level=level,
        stream=sys.stderr,                     # never stdout
        format="%(levelname)s %(name)s: %(message)s",
    )
    logging.getLogger("httpx").setLevel(logging.WARNING)   # a known-noisy dependency

Configure it once, in the root callback, before any command runs — a log call that happens before configuration goes through the root logger's defaults and often to the wrong stream. Then map the verbosity flags onto levels in one function: --quiet to ERROR, nothing to WARNING, -v to INFO, -vv to DEBUG. Nothing else in the program should ever ask how verbose the user wanted things to be.

The second audience appears later. When your tool runs in CI or on a fleet of machines, a human reading a terminal is replaced by an aggregator indexing fields, and the useful format becomes one JSON object per line. That is a formatter swap rather than a rewrite — which is exactly why routing every message through logging in the first place is worth the small ceremony. The structured logging section covers both renderers and the bound context that makes a single run's lines findable among thousands.

Testing the parts users touch

The behaviours in this track are unusually testable, because they are all observable from outside the program: an exit code, some text on a stream, a value that ended up in a settings object.

from typer.testing import CliRunner

runner = CliRunner()

def test_missing_config_file_is_a_usage_error(tmp_path):
    result = runner.invoke(app, ["--config", str(tmp_path / "nope.toml"), "sync"])
    assert result.exit_code == 78
    assert "nope.toml" in result.stderr

def test_flag_beats_environment(monkeypatch):
    monkeypatch.setenv("MYTOOL_RETRIES", "9")
    settings = resolve(cli={"retries": 2}, env=os.environ, file={}, defaults={"retries": 3})
    assert settings["retries"] == 2

Two habits keep these tests useful. Assert on the exit code first and the message second — a test that only checks output passes happily when the command failed for an unrelated reason. And test the resolver as a plain function wherever you can: passing argv, an environment mapping and file contents in as arguments removes monkeypatching entirely and makes precedence rules testable in a dozen lines.

The one area that needs a different approach is shell completion, because a real interactive shell is not reproducible in CI. Both Click and Typer let you drive the completion machinery directly by setting the completion environment variables, which turns "does Tab work" into an ordinary assertion. The completion section shows the invocation.

What to fix first A suggested order for improving a command line tool: exit codes first, then error messages, then configuration, then output polish. What to fix first Exit codes scripts depend on them 1 Error messages the most-read output 2 Config layering flags, env, files 3 Polish colour, progress, completion 4 ordered by how much breaks when you get it wrong Colour is the most visible improvement and the least important one — earn it by fixing the first three.
  1. Validate arguments at the boundary so bad input fails fast and clearly.
  2. Define a configuration precedence chain your users can reason about.
  3. Design errors and exit codes so both humans and scripts know what happened.
  4. Add logging you can dial up with -v or silence with --quiet.
  5. Layer on Rich output and shell completion to make the tool a pleasure to use.

An audit you can run this afternoon

If you have an existing tool and want to know which of these sections to read first, run it through eight invocations and watch what happens. The results are usually uncomfortable and always actionable.

  1. A missing required argument. Expect a usage line and exit 2. A traceback here means the parser is not doing its job.
  2. A file that does not exist. Expect one sentence naming the path, and a documented exit code. Most tools produce a FileNotFoundError traceback.
  3. A malformed config file. Expect the file, the key and ideally the line — plus exit 78.
  4. A flag that contradicts an environment variable. The flag must win, and config show should be able to tell the user why.
  5. The tool piped into head. Expect a clean exit, not a BrokenPipeError traceback.
  6. The tool run with stdout redirected to a file. Expect no colour codes and no progress bar in the file, and the result intact.
  7. The tool run in a CI job with no terminal. Expect no prompt, no hang, and a message naming the flag that would have supplied the value.
  8. Ctrl-C halfway through. Expect a clean stop, no traceback, and exit 130 rather than 0.

Each failure maps directly onto a section of this track, and each is typically a handful of lines to fix. The eighth is the one people are most surprised by: a cancelled run that exits 0 tells every wrapper script that the work completed.

Key takeaways

  • Flags, exit codes and the shape of stdout are your public API; stderr is the only free surface.
  • Convert and validate at the boundary so nothing deeper needs a defensive check.
  • Resolve configuration in one order — flags, environment, file, defaults — and only merge keys a source actually set.
  • Raise domain exceptions and map them to exit codes in one boundary, never with scattered sys.exit calls.
  • Check for a terminal before prompting, colouring, or drawing a live progress display.

Frequently asked questions

Where should validation live — the parser, the model, or the command?

As close to the boundary as the rule can be expressed. Single-value rules belong on the parameter type, so the framework produces the usage error for you. Rules spanning two values belong in one validation step right after parsing. Only checks that need the outside world — does this file exist right now, does this API accept this token — belong in the command body, because they are not really input validation at all.

Should my CLI prompt for missing values?

Only when a person is there. Check that standard input is a terminal first; if it is not, fail with a message naming the flag. A prompt in a CI job hangs the pipeline until it times out, and the log gives no clue why. When you do prompt, offer a sensible default and always allow the flag as an alternative so the interactive path is never the only path.

How do I support both human and machine output?

Add a --json flag (or --format) that switches stdout to a stable, documented structure, and keep everything else — progress, warnings, timing — on stderr where it cannot contaminate the data. Resist inventing a second format for each command; one envelope shape used everywhere makes your tool scriptable in a way per-command formats never do.

Is colour worth the trouble?

Yes, if it is conditional. Colour used sparingly to distinguish an error from a warning genuinely helps. Colour that appears in a log file, a pipe or a NO_COLOR environment is noise at best and unreadable escape sequences at worst. Honour NO_COLOR, check for a terminal, and offer --no-color; that is about five lines and makes your tool a good citizen everywhere it runs.

What is the fastest way to make an existing CLI feel better?

Audit the failures. Run the tool with a missing file, a bad flag, an unreachable service and an invalid config, and look at what a user actually sees. Most tools produce a traceback for at least two of those, and fixing them — a message, a code, a hint — changes the perceived quality of the tool more than any amount of colour.

Should log output go to stdout so users can pipe it?

No. Logs are narration, not results, and mixing them into stdout means anyone piping your tool has to filter your logs out of their data. Send both human-readable and JSON logs to stderr; log collectors read both streams, so nothing is lost, and mytool export > data.json keeps working.

How much configuration is too much?

When two options can contradict each other and the resolution is not obvious, you have crossed the line. Every option is a permanent commitment and a branch in your test matrix. Prefer good defaults with one escape hatch over five knobs, and let the layered configuration system carry the values that genuinely differ between environments.

How do I deprecate a flag without breaking scripts?

Keep it working and make it complain. Accept the old name as a hidden alias, emit a warning on stderr naming the replacement, and note the removal version in the changelog and the help text. Give people at least one minor release — ideally two — before the alias disappears, and never change what an existing flag means while keeping its name, which is far more damaging than removing it outright.

Should errors be printed by the command or raised?

Raised. A command that prints and exits has made two decisions the boundary should own: what the user sees and what the shell receives. Raising a domain exception keeps the command focused on its work and keeps every message and exit code in one file, which is also the only way to test the mapping without invoking every command.

What belongs in --help versus the documentation?

Help output answers "what can I type here"; documentation answers "why would I". Keep each option description to one line naming the effect and the default, add two or three realistic examples in an epilog, and link to the docs for anything conceptual. A help screen that tries to teach becomes a help screen nobody reads, and the flag list gets lost inside it.

Do I need to support Windows terminals differently?

Modern Windows Terminal and PowerShell handle ANSI colour and Unicode fine, so Rich behaves the same way it does elsewhere. What still differs is the legacy console, where box-drawing characters and emoji can render as boxes. Rich degrades automatically, and the practical safeguard is running your test suite on Windows in CI rather than special-casing anything in the code.

Set up the project in Project Setup & Dependency Management and design the command surface in Modern Python CLI Frameworks & Architecture before polishing the input and output layers here.