Writing a Command-Line Tool
argparse turns a script into a tool: subcommands, a --help worth reading, and an exit code CI can act on.
parser = argparse.ArgumentParser(prog="deploy"); args = parser.parse_args()A script becomes a tool the moment someone other than you runs it. argparse is the standard library's answer: it parses sys.argv, converts and validates each value, generates --help from what you declared, and exits with status 2 when the invocation itself is wrong.
Arguments against flags
A positional argument is something the command cannot run without: deploy push billing. A flag has a default, so --tag falls back to latest. Do not make a mandatory value a flag; nothing in --help tells the reader which flags are compulsory.
Subcommands
add_subparsers(dest="command", required=True) (the required keyword arrived in 3.7) gives each verb its own parser and its own help text. Attach the implementation with set_defaults(func=...) and dispatch through args.func(args).
The short form and the long form
action="store_true" is documented as the special case of action="store_const", const=True, default=False — the same thing, written shorter. In the same way argparse.BooleanOptionalAction (added in 3.9) declares --cache and --no-cache from a single add_argument call instead of two options in a mutually exclusive group.
Exit codes
Have each command return an int and end the module with sys.exit(main()). Zero means it worked; anything else fails the CI step that wrapped it. Send diagnostics to sys.stderr so piped stdout stays machine-readable.
In 3.14 ArgumentParser gained color (default True, suppressed by NO_COLOR or PYTHON_COLORS=0) and suggest_on_error, and argparse.FileType became deprecated — open files yourself after parsing.
Example
When to use it
- Expose one binary with push, rollback and status subcommands instead of three loose scripts.
- Add a --dry-run flag so the destructive half of an ops script can be reviewed before it runs.
- Return exit code 1 from a nightly reconciliation check so the CI job wrapping it turns red.
- Document a tool once in help= strings and let --help stay accurate as it changes.
More examples
A boolean setting, both ways
BooleanOptionalAction collapses the two-option group into one line and keeps the help entry honest. Reach for the longhand only when the two spellings need different help text or different dest names, and recognise it in older code as the same thing.
import argparse
parser = argparse.ArgumentParser()
# Longhand: two options in a mutually exclusive group.
group = parser.add_mutually_exclusive_group()
group.add_argument("--cache", dest="cache", action="store_true")
group.add_argument("--no-cache", dest="cache", action="store_false")
parser.set_defaults(cache=True)
# Shorthand, identical behaviour: one declaration, both spellings,
# and --help shows "--cache, --no-cache". Added in 3.9.
parser.add_argument("--colour", action=argparse.BooleanOptionalAction, default=True)
print(parser.parse_args(["--no-cache"])) # Namespace(cache=False, colour=True)
print(parser.parse_args(["--no-colour"])) # Namespace(cache=True, colour=False)Global flags shared by every subcommand
Without parents=, a flag declared on the top-level parser must be typed before the subcommand, which nobody remembers. Declare shared flags once in a help-less parent and inherit them so they work in the position users expect.
import argparse
# A parser with add_help=False exists only to be inherited.
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--profile", default="default", help="credentials profile")
common.add_argument("--dry-run", action="store_true", help="print, change nothing")
parser = argparse.ArgumentParser(prog="deploy")
sub = parser.add_subparsers(dest="command", required=True)
# parents= copies the arguments in, so each subcommand accepts them *after* the verb:
# deploy push billing --dry-run
sub.add_parser("push", parents=[common]).add_argument("service")
sub.add_parser("status", parents=[common])
args = parser.parse_args(["push", "billing", "--dry-run"])
print(args.command, args.service, args.dry_run, args.profile)Shipping it as an installed command
An entry point replaces `python -m mytool.cli` or a path to a script file, and the generated wrapper already does the sys.exit(main()) wiring. This is what makes the tool callable identically from a developer's shell, a Dockerfile CMD and a CI step.
# pyproject.toml
[project]
name = "mytool"
version = "1.4.2"
[project.scripts]
# Creates a "deploy" executable on PATH whose wrapper calls sys.exit(main()),
# so main() returning 2 becomes an exit status of 2 with no extra work.
deploy = "mytool.cli:main"
Discussion