Building a CLI: parse and dispatch argv (argparse/typer preview)
Turn argument lists into commands with a testable dispatcher.
From arguments to commands
Every real tool you use, git, pytest, pip, uv, is a CLI: it reads a list of strings the shell hands it and runs the matching command. When you write one, the valuable skill is not memorizing a library. It is keeping the parsing separate from the logic so you can test the logic without launching a whole process. That separation is exactly what this lesson drills.
A CLI is a function from strings to a result
When you type mytool add 2 3, Python receives sys.argv, a list of strings: ["mytool", "add", "2", "3"]. sys.argv[0] is the program name; the real arguments start at index 1. Everything arrives as text, even "2". A CLI does three things with that list:
- Collect the raw string arguments.
- Parse them into typed values (
"2"to2). - Dispatch the command name to the function that handles it.
Two ways to parse
The stdlib argparse builds the parser declaratively. parser.parse_args() reads sys.argv[1:] for you and applies each type= converter:
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("command")
parser.add_argument("a", type=int)
parser.add_argument("b", type=int)
args = parser.parse_args() # args.a is an int
typer (built on click) turns a function's type hints into the CLI, so a: int becomes a required, int-converted argument:
import typer
app = typer.Typer()
@app.command()
def add(a: int, b: int):
print(a + b)
The part worth isolating: dispatch
Underneath any parser, a CLI maps a command name to a function. Write that core as a plain function that takes argv as a parameter instead of reaching for sys.argv itself:
def run(argv):
command, a, b = argv[0], int(argv[1]), int(argv[2])
if command == "add":
return a + b
if command == "mul":
return a * b
return 0
print(run(["add", "2", "3"])) # 5
Because run receives its input, a test can call run(["mul", "4", "5"]) and assert it returns 20, with no subprocess and no shell.
Pitfalls
- Arguments are strings. Skip
int()andargv[1]is"2", not2. Then"2" + "3"is"23"and"2" * "3"raisesTypeError. Convert at the boundary. - Off-by-one on
argv. In a realmain, the command issys.argv[1], notsys.argv[0](that is the program name). Slicingsys.argv[1:]avoids the mistake. - An unknown command should do something defined (here, return
0), not fall through and crash.
Interview nuance: this is the "thin shell, pure core" pattern interviewers look for. Keep argument reading and I/O at the edge (sys.argv, print) and put the decision logic in a pure function that takes argv and returns a value. A pure function is deterministic and trivial to unit-test: you assert on its return value. Once the logic reads global state or prints instead of returning, testing it means patching sys.argv and capturing stdout, which is slower and more brittle than checking a returned value.
def run(argv):
command, a, b = argv[0], int(argv[1]), int(argv[2])
if command == "add":
return a + b
if command == "mul":
return a * b
return 0
print(run(["add", "2", "3"])) # 5Apply
Your turn
The task this lesson builds to.
Warm-up (one file): implement run(argv): argv is a list like ["add", "2", "3"]. Read the
command and two integer arguments and return add or mul of them.
run(["add", "2", "3"]) is 5; run(["mul", "4", "5"]) is 20.
3 hints and 4 automated checks are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, so it survives past today.
Implement run(argv) in cli/app.py: read the command name and two integer arguments from
argv, dispatch to the read-only add/mul commands, and return the result. Some tests are
hidden.
3 hints and 2 automated checks are waiting in the workspace.