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.
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:
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
- Advanced argument validation strategies — enforce schema-driven, pre-execution data integrity with Pydantic v2, Typer, and custom validators. Then handle the hard case: parsing nested JSON arguments in Python CLIs with Click custom types and shell-safe encoding.
Merging configuration
- Handling config files and env vars in CLIs — build a deterministic precedence chain and give it strict type safety. See the security-sensitive case of loading YAML configs safely in CLI apps, and the exact rules for config precedence: flags, env, files, defaults.
Terminal user experience
- Interactive terminal UI with Rich — tables, panels, prompts, and live-updating output that elevate a CLI from functional to polished. Start with progress bars and spinners for Python CLIs for both deterministic and indeterminate work.
- Shell completion for Python CLIs — give users Tab-completion: enable it in Click and Typer and install completion for bash, zsh, and fish.
Failure, feedback, and logging
- Error handling and exit codes for CLIs — make failure predictable: choose exit codes scripts can trust and replace tracebacks with friendly error messages.
- Structured logging for CLI apps — wire the logging module for the terminal, emit structured JSON logs, and map verbose and quiet flags to log levels.
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.
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.
A recommended path
- Validate arguments at the boundary so bad input fails fast and clearly.
- Define a configuration precedence chain your users can reason about.
- Design errors and exit codes so both humans and scripts know what happened.
- Add logging you can dial up with
-vor silence with--quiet. - 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.
- A missing required argument. Expect a usage line and exit 2. A traceback here means the parser is not doing its job.
- A file that does not exist. Expect one sentence naming the path, and a documented exit
code. Most tools produce a
FileNotFoundErrortraceback. - A malformed config file. Expect the file, the key and ideally the line — plus exit 78.
- A flag that contradicts an environment variable. The flag must win, and
config showshould be able to tell the user why. - The tool piped into
head. Expect a clean exit, not aBrokenPipeErrortraceback. - The tool run with stdout redirected to a file. Expect no colour codes and no progress bar in the file, and the result intact.
- 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.
- 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.exitcalls. - 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.
Related tracks
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.