Python CLI tools
A good CLI is a function with a stable contract: arguments in, exit code out, data on stdout, diagnostics on stderr.
Method
- Pick the framework by size.
argparsefor stdlib-only tools and scripts;typerwhen you want subcommands, type-hint parsing, and help generation with minimal code;clickwhen you need its ecosystem (plugins, complex composition). Do not hand-parsesys.argv. - Separate parsing from logic.
main(argv=None)parses and calls a pure function that takes plain values and returns data or raises. This makes the CLI testable without subprocess calls:assert run(["--json"]) == 0. - Respect the streams. Results to stdout, progress and errors to
stderr, so pipes work:
tool | jqmust never choke on a log line. Offer--jsonfor machine consumers and keep the human format for terminals (sys.stdout.isatty()decides the default). - Exit codes are the API. 0 success, 1 generic failure, 2 usage error
(argparse's default). Catch expected failures at the top, print one
clear line to stderr,
sys.exit(code). Let genuine bugs traceback; swallowing them hides your own defects. - Handle interruption cleanly. Ctrl-C should not print a traceback:
catch
KeyboardInterruptin main, exit 130. Wrap SIGPIPE-prone output (headclosing the pipe) by catchingBrokenPipeErrorand exiting 0. - Package with an entry point. In pyproject:
[project.scripts] tool = "pkg.cli:main". Users install withuv tool install .or pipx and gettoolon PATH; nopython tool.pyinstructions, noif __name__execution of package modules. - Version and help are non-negotiable.
--versionprinted from package metadata (importlib.metadata.version),-hwith one-line examples of the two most common invocations.
Boundaries
- For interactive TUIs (menus, live panes) this contract does not apply; that is a different kind of program (textual, curses).
- Do not read config from cwd implicitly; take a
--configpath or use platformdirs locations, or two runs will differ for invisible reasons.