Learning Goals
3 minBy the end of this lesson you can:
- Create subcommands with
parser.add_subparsers(). - Give each subcommand its own arguments.
- Wire each subcommand to a handler function with
set_defaults(func=...). - Structure a real multi-command tool that's easy to extend.
Warm-Up · Tools With Verbs
5 minLook at the shape of the tools you use every day:
git add file.py git commit -m "msg" git push pip install requests pip list pip uninstall x docker build . docker run image docker ps
Each is one program with several subcommands, and each subcommand takes different arguments. git add wants files; git commit wants a message.
A subparser is "a parser inside a parser." The first word after the program name picks which sub-parser runs, and that sub-parser handles the rest of the line. argparse dispatches for you — you just describe each verb.
New Concept · Subparsers
14 minThe structure
import argparse parser = argparse.ArgumentParser(description="A todo tool.") sub = parser.add_subparsers(dest="command", required=True) # verb: add p_add = sub.add_parser("add", help="add a task") p_add.add_argument("task") # verb: list p_list = sub.add_parser("list", help="show all tasks") # verb: remove p_remove = sub.add_parser("remove", help="delete a task") p_remove.add_argument("number", type=int) args = parser.parse_args() print(args)
Each sub.add_parser("verb") returns a fresh parser you configure independently.
$ python todo.py add "buy milk" Namespace(command='add', task='buy milk') $ python todo.py remove 2 Namespace(command='remove', number=2) $ python todo.py list Namespace(command='list')
dest="command" stores which verb was chosen; required=True means the user must pick one.
The dispatch problem
You could branch on args.command with a big if/elif chain — but that gets messy as tools grow. The clean pattern attaches a handler function to each subparser:
def cmd_add(args): print(f"Adding: {args.task}") def cmd_list(args): print("Listing all tasks…") def cmd_remove(args): print(f"Removing task #{args.number}") p_add.set_defaults(func=cmd_add) p_list.set_defaults(func=cmd_list) p_remove.set_defaults(func=cmd_remove) args = parser.parse_args() args.func(args) # call the handler the chosen verb set
set_defaults(func=...) stores the right function on args. After parsing, args.func(args) runs exactly the handler for the verb the user picked. Adding a new command is now three lines: a subparser, a handler, and a set_defaults — no editing a giant if.
Per-subcommand options
Each subparser is a full parser, so it gets its own flags too:
p_list = sub.add_parser("list") p_list.add_argument("--done", action="store_true", help="only show completed tasks")
Now python todo.py list --done works, but python todo.py add --done correctly errors — --done belongs only to list.
Worked Example · A Working Todo CLI
12 minLet's make it real — tasks stored in a JSON file, so they survive between runs.
import argparse, json from pathlib import Path STORE = Path("tasks.json") def load(): return json.loads(STORE.read_text()) if STORE.exists() else [] def save(tasks): STORE.write_text(json.dumps(tasks, indent=2)) def cmd_add(args): tasks = load() tasks.append(args.task) save(tasks) print(f"Added: {args.task}") def cmd_list(args): tasks = load() if not tasks: print("No tasks yet.") for i, t in enumerate(tasks, start=1): print(f"{i}. {t}") def cmd_remove(args): tasks = load() if 1 <= args.number <= len(tasks): removed = tasks.pop(args.number - 1) save(tasks) print(f"Removed: {removed}") else: print(f"No task #{args.number}") parser = argparse.ArgumentParser(description="A tiny todo manager.") sub = parser.add_subparsers(dest="command", required=True) p_add = sub.add_parser("add", help="add a task") p_add.add_argument("task") p_add.set_defaults(func=cmd_add) p_list = sub.add_parser("list", help="show tasks") p_list.set_defaults(func=cmd_list) p_remove = sub.add_parser("remove", help="delete a task by number") p_remove.add_argument("number", type=int) p_remove.set_defaults(func=cmd_remove) args = parser.parse_args() args.func(args)
$ python todo.py add "buy milk" Added: buy milk $ python todo.py add "call dentist" Added: call dentist $ python todo.py list 1. buy milk 2. call dentist $ python todo.py remove 1 Removed: buy milk
Read the code
The whole tool is data plus three small handlers — and the dispatch trick keeps each verb self-contained. Want a done command next? Add a subparser, a handler, a set_defaults. The structure scales: this is exactly how professional CLIs are organised.
Try It Yourself
13 minBuild note.py with two subcommands: write <text> appends a line to notes.txt, and read prints the whole file. Use the dispatch pattern.
Add a --reverse flag to your read subcommand that prints the notes newest-first. Confirm write --reverse correctly errors.
Hint
p_read = sub.add_parser("read") p_read.add_argument("--reverse", action="store_true") p_read.set_defaults(func=cmd_read) def cmd_read(args): lines = open("notes.txt").read().splitlines() if args.reverse: lines.reverse() print("\n".join(lines))
Add a done <number> subcommand to the worked example that marks a task complete by prefixing it with [x]. You'll need to change the stored format to track done-ness.
Hint
# store dicts instead of strings: # {"text": "buy milk", "done": False} def cmd_done(args): tasks = load() tasks[args.number - 1]["done"] = True save(tasks) # in cmd_list: # mark = "[x]" if t["done"] else "[ ]" # print(f"{i}. {mark} {t['text']}")
Mini-Challenge · A Mini Package Manager
8 minBuild pkg.py that mimics pip's shape (no real installing — just a JSON "installed" list): pkg install <name> [--version V], pkg uninstall <name>, and pkg list. Each verb gets its own handler.
Show the install handler
def cmd_install(args): installed = load() installed[args.name] = args.version or "latest" save(installed) print(f"Installed {args.name}=={installed[args.name]}") p_install = sub.add_parser("install") p_install.add_argument("name") p_install.add_argument("--version") p_install.set_defaults(func=cmd_install)
Non-negotiables: three subcommands, per-verb arguments, JSON persistence, and the set_defaults dispatch.
Recap
3 minSubcommands turn one script into a suite of verbs. Call parser.add_subparsers(dest=..., required=True), then sub.add_parser("verb") for each command — each is a full parser with its own arguments. Attach a handler with p.set_defaults(func=...) and run args.func(args) after parsing to dispatch. Adding a new command stays a tidy three-line change, which is why every serious CLI is built this way.
Vocabulary Card
- subcommand
- A verb after the program name that selects a mode (
git commit). - subparser
- A parser nested inside the main one, owning one subcommand's arguments.
- set_defaults
- Attaches fixed values (like a handler function) to a parser's result.
- dispatch
- Choosing which function to run based on the parsed input.
Homework
4 minBuild contacts.py: a contact book with subcommands add <name> <phone>, find <name> (substring match), list, and delete <name>. Store contacts in contacts.json. Use the dispatch pattern and give list an optional --sort flag.
Sample · contacts.py (core)
import argparse, json from pathlib import Path DB = Path("contacts.json") load = lambda: json.loads(DB.read_text()) if DB.exists() else {} save = lambda d: DB.write_text(json.dumps(d, indent=2)) def cmd_add(a): d = load(); d[a.name] = a.phone; save(d) print(f"Saved {a.name}: {a.phone}") def cmd_find(a): for name, phone in load().items(): if a.name.lower() in name.lower(): print(f"{name}: {phone}") def cmd_list(a): items = load().items() if a.sort: items = sorted(items) for name, phone in items: print(f"{name}: {phone}") def cmd_delete(a): d = load() if d.pop(a.name, None) is not None: save(d); print(f"Deleted {a.name}") else: print("Not found") p = argparse.ArgumentParser(description="Contact book") sub = p.add_subparsers(dest="cmd", required=True) pa = sub.add_parser("add"); pa.add_argument("name"); pa.add_argument("phone") pa.set_defaults(func=cmd_add) pf = sub.add_parser("find"); pf.add_argument("name"); pf.set_defaults(func=cmd_find) pl = sub.add_parser("list"); pl.add_argument("--sort", action="store_true") pl.set_defaults(func=cmd_list) pd = sub.add_parser("delete"); pd.add_argument("name"); pd.set_defaults(func=cmd_delete) args = p.parse_args() args.func(args)
Non-negotiables: four subcommands, JSON persistence, dispatch via set_defaults, and a working --sort.