Skip to main content

Building a CLI: parse and dispatch argv (argparse/typer preview)

Level 3: Level 3: Patternsmedium18 minclidispatchargumentscommands

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

Check yourself
At a shell you run: python mytool.py add 2 3. Inside the program, what is sys.argv[0]?

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:

  1. Collect the raw string arguments.
  2. Parse them into typed values ("2" to 2).
  3. 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
Check yourself
The parser above declares parser.add_argument('a', type=int). A user runs the tool passing abc where a number was expected. What happens?

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

Check yourself
Someone writes run so it does command, a, b = argv[0], argv[1], argv[2], with no int() anywhere. They call run(['add', '2', '3']). What comes back?
  • Arguments are strings. Skip int() and argv[1] is "2", not 2. Then "2" + "3" is "23" and "2" * "3" raises TypeError. Convert at the boundary.
  • Off-by-one on argv. In a real main, the command is sys.argv[1], not sys.argv[0] (that is the program name). Slicing sys.argv[1:] avoids the mistake.
  • An unknown command should do something defined (here, return 0), not fall through and crash.
Check yourself
Two designs for the same tool. Design A: run(argv) takes the list as a parameter and returns a number. Design B: main() reads sys.argv itself and prints the result. Which is easier to unit-test, and why?

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.

Worked example (Python)
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

Apply

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.