Command Palette

Search for a command to run...

Hectal
PHASE 15Intermediate ~26 min· topic 7 of 10

Topic 15.7

Command-Line Tools: argparse and Typer

In one line

A command-line tool reads its settings from the words typed after its name. Python's argparse module turns sys.argv into a clean object, builds --help for free, rejects bad input with a usage message, and lets the program report success or failure through its exit code.

Think of it like this

Ordering at a coffee counter with a short, fixed script. "One latte, large, no sugar." The barista knows the order of the words: the drink first, then the options. If you say "large latte purple", they do not guess; they tell you what they can make. A command-line tool works the same way: you type its name, then the thing to work on, then options, and it either does the job or says exactly how you should have asked.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Command line
The text line you type in a terminal to run a program.
Argument
A word you type after the program name to tell it what to do.
sys.argv
The raw list of command-line words, as strings. The first is the script name.
Positional argument
A required argument identified by its position.
Option / flag
An optional argument that starts with dashes. A flag has no value; it is on or off.
Subcommand
A named action inside a tool, such as git commit.
Exit code
The number a program returns when it ends. 0 means success, anything else means failure.
stdout / stderr
The normal output stream and the error stream. They can be redirected separately.
Typer
A library that builds a command-line tool from a function's type hints.

Step by step

01Start with sys.argv

Save a file and run it with extra words after its name. Everything arrives as strings in a list. This is what argparse starts from.

argv.pywhole filepython
import sys

print(sys.argv)

02Declare what the tool accepts

Each add_argument call is one entry on the menu. A name without dashes is positional and required. A name with dashes is an option. store_true makes a flag whose value is False unless it appears.

main.pywhole filepython
import argparse

parser = argparse.ArgumentParser(description="Greet someone.")
parser.add_argument("name", help="who to greet")
parser.add_argument("--times", type=int, default=1, help="how many greetings")
parser.add_argument("--shout", action="store_true", help="use capital letters")

03Parse, and read the result

parse_args() with no argument reads sys.argv[1:]. In a test (and on this page) you can pass a list instead: parse_args(["Asha", "--times", "2"]). The result is a Namespace, an object whose attributes are the argument names (a dash in --dry-run becomes dry_run).

main.pywhole filepython
args = parser.parse_args(["Asha", "--times", "2", "--shout"])
print(args.name, args.times, args.shout)   # Asha 2 True

04Help and errors come free

argparse reads your declarations to write the --help page. On a bad command line it prints the usage plus one clear sentence and exits with code 2. The second runnable example below catches that SystemExit so you can see the code.

terminalwhole filebash
$ python main.py
usage: main.py [-h] [--times TIMES] [--shout] name
main.py: error: the following arguments are required: name
$ echo $?
2

05Subcommands

add_subparsers(dest="command", required=True) creates the choice of actions. Each add_parser returns a parser for that action's own arguments. Afterwards args.command says which one the user picked.

Subcommandsdiagram
Rendering diagram…

06main() returns the exit code

Put the work in a function that takes the argument list and returns an integer. Then the real entry point is raise SystemExit(main()). Tests call main(["add", "milk"]) directly and check the return value, with no subprocess needed.

main.pywhole filepython
def main(argv=None) -> int:
    args = build_parser().parse_args(argv)
    ...
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

07The Typer way

In Typer you write one function; the type hints define the interface: a plain parameter is a positional argument, a parameter with a default becomes an option, and a bool becomes a --flag/--no-flag pair. The block in the code section shows it with a real run.

Try it yourself

  1. 1

    Add a flag

    In the first example add parser.add_argument("--polite", action="store_true") and print "Please" before the greeting when it is set. Parse a list that includes "--polite".

  2. 2

    Cause a usage error

    In the second example change the last call to main(["add"]) (no title) and read the message argparse prints. Then try --priority medium and notice that the error lists the allowed choices.

  3. 3

    Return a failure code

    Make main return 1 when a task id is negative, and print exit code: for it. The code is yours to define: choose 0 for success and non-zero for problems, and document it.

Code & diagrams

argparse: arguments, defaults and --help New tab

The prog name in the usage line is main.py. parse_args receives a fixed list so the example is repeatable.

Sign in to run this example in your browser.

Expected output

Namespace(name='Asha', times=2, shout=True)
HELLO, ASHA!
HELLO, ASHA!
Namespace(name='Ben', times=1, shout=False)

usage: main.py [-h] [--times TIMES] [--shout] name

Greet someone.

positional arguments:
  name           who to greet

options:
  -h, --help     show this help message and exit
  --times TIMES  how many greetings
  --shout        use capital letters
Subcommands, a main() that returns an exit code, and SystemExit New tab

The bad call (task id 'three') makes argparse print to the error stream and raise SystemExit(2).

Sign in to run this example in your browser.

Expected output

added 'buy milk' with priority high
exit code: 0
finished task 3
exit code: 0
usage: todo done [-h] task_id
todo done: error: argument task_id: invalid int value: 'three'
SystemExit code: 2
The same tool with Typer (needs pip install typer)python

Not runnable here: Typer is not in the browser. Verified on a real machine (Python 3.12, a recent Typer). The --help page is drawn with boxes and varies with the terminal width, so only the working calls are shown.

# pip install typer
import typer

app = typer.Typer()


@app.command()
def greet(name: str, times: int = 1, shout: bool = False):
    """Greet NAME."""
    for _ in range(times):
        text = f"Hello, {name}!"
        typer.echo(text.upper() if shout else text)


if __name__ == "__main__":
    app()

Output

$ python greet.py Asha --times 2 --shout
HELLO, ASHA!
HELLO, ASHA!

$ python greet.py --times 2
(prints a usage error: Missing argument 'name'.)
$ echo $?
2
Running tools without installing them (uv)bash

Not runnable here. These are standard uv commands; the tool names are examples, so check each tool's own page before using it.

# run a published command-line tool in a temporary isolated environment
uvx ruff --version

# install a tool permanently, available on your PATH
uv tool install ruff

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Leave out a required argument

Call parse_args with an empty list when a positional argument is required.

main.pywhole filepython
import argparse

parser = argparse.ArgumentParser(description="Greet someone.")
parser.add_argument("name")
parser.add_argument("--times", type=int, default=1)
args = parser.parse_args([])
print(args)
terminal
$ python main.py
── what you'll see ──
usage: main.py [-h] [--times TIMES] name
main.py: error: the following arguments are required: name

Myth vs fact

Myth

I can read sys.argv myself, argparse is overkill.

Fact

For one value it is fine. As soon as you have options, defaults, types and help, hand parsing is longer and worse than argparse, which also gives consistent errors and --help.

Myth

Printing an error message is enough to report failure.

Fact

Other programs and CI check the exit code, not your text. A tool that prints "failed" but exits with 0 looks successful to the shell.

Myth

Typer replaces argparse.

Fact

Typer builds on Click, not on argparse. Both are good. argparse needs no installation, which matters for scripts you share.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Keep parsing separate from work: build_parser() and a main(argv) that returns an int. This makes the tool easy to test (Phase 11) without launching a process.

  • ▸

    Declare the tool in pyproject.toml under [project.scripts] (for example todo = "mytool.cli:main"; Topic 6.7 covers pyproject.toml) and installing the package creates a real todo command. That is how ruff, pytest and many AI tools ship.

  • ▸

    Respect Unix conventions: results to stdout, diagnostics and progress to stderr, - for stdin, exit code 0/1/2 as documented, and --version. Keep --help accurate: it is the manual users actually read.

  • ▸

    Do not accept secrets as arguments (they appear in process lists and shell history, Topic 15.3); read them from the environment or a file.

  • ▸

    argparse behaviours that surprise people: it accepts any unambiguous prefix of a long option (--tim 3 works for --times), which can silently break scripts when you later add --timeout; pass allow_abbrev=False for tools others will script against. type= is called on a string, so type=int accepts "-3" and you still need your own range check (or a custom type function that raises argparse.ArgumentTypeError). For booleans, action=argparse.BooleanOptionalAction (3.9) gives a --cache/--no-cache pair, whereas type=bool is a trap because bool("False") is True. -- ends option parsing, so a value that starts with a dash can still be a positional argument.

  • ▸

    sys.exit("message") prints the message to stderr and exits with code 1, sys.exit(0) or sys.exit() exits with 0, and SystemExit is not caught by except Exception (it derives from BaseException). Conventions worth following: 0 success, 1 general failure, 2 usage error (argparse's choice), and 130 when the user pressed Ctrl+C. Catch KeyboardInterrupt at the top of main for a clean message, not a traceback.

Remember this

  1. 1

    A command-line interface (CLI) is a program you run from a terminal by typing a line such as python tool.py report.csv --top 5. The words after the program name are arguments. Python gives them to you in sys.argv, a plain list of strings whose first item is the script's name. You could parse that list by hand, but you would end up rewriting what argparse already does.

  2. 2

    **argparse** is in the standard library. You create an ArgumentParser, declare what the tool accepts with add_argument, and call parse_args(). A positional argument (name) is required and identified by its position. An option starts with dashes (--times 3, or a short form -t 3), is optional, and can have a default. A flag is an option with no value (--shout), declared with action="store_true".

  3. 3

    type=int converts the text to a number and rejects three with a clear message. choices=["low", "high"] limits the allowed words. default= sets the value when the option is missing. help= text appears in the automatic --help page, which argparse builds for you, together with the one-line usage summary.

  4. 4

    When the command line is wrong, argparse prints a usage message and an error to the *error stream* (stderr) and ends the program with exit code 2 by raising SystemExit. --help prints the help to the normal output and exits with code 0. This is why, inside a test or a notebook, you may need try: ... except SystemExit around parse_args (Topic 7.1 explains try and except).

  5. 5

    An exit code is a small number a program hands to the shell when it ends. 0 means success; anything else means failure. Scripts, CI systems and && chains depend on this. Make main() return an integer and finish with raise SystemExit(main()) (or sys.exit(main())). Print normal results to stdout and errors to stderr, so output can be piped to other programs without error text mixed in.

  6. 6

    Tools often have several actions, such as git add and git commit. In argparse these are subcommands, created with add_subparsers; each has its own arguments. For friendlier tools, Typer (built on type hints, so a function's parameters become the arguments and --help is generated) and Click (the library under Typer) are popular, and Rich adds coloured tables and progress bars. Install a ready-made tool without touching your project with uvx tool-name (it runs the tool in a temporary isolated environment). Many AI utilities are CLIs, such as small scripts that run an evaluation or index a folder.

Explain it without notes

01

What is the difference between a positional argument, an option and a flag in argparse?

02

What are exit codes, and what does argparse do on bad input?

03

How do you build a tool with subcommands, and how do you test it without a terminal?

04

Why do many tools read secrets from the environment rather than a --api-key option?

Practice

01

Write a parser for a tool repeat with a positional word and an option --count (integer, default 2), and print the word that many times on one line.

02

Add a --upper flag to it.

03

Write main(argv) that returns 0 on success and 1 if --count is below 1 (print an error to sys.stderr).

Trade-offs

  • ↔

    argparse versus Typer or Click. argparse is built in and fine for scripts you share. Typer and Click give nicer code and help for larger tools, at the cost of a dependency.

  • ↔

    Many small options versus a config file. Options are easy to discover and script; too many make the command unreadable. Move stable settings to a config file or environment variables (Topic 15.3) and keep options for what changes per run.

Done when you can

  • Done when you can explain what sys.argv holds.

  • Done when you can write an argparse parser with a positional argument, an option with a type and default, and a flag.

  • Done when you can add subcommands and read args.command.

  • Done when main() returns an exit code and you know 0 means success.

  • Done when you can say what Typer adds and how uvx runs a tool.