Why this matters
Tools like git, docker and pip are not one command but a family: git commit, git log, git push. Each subcommand has its own arguments, and all of them share state that persists between runs, such as a repository, an image store or an installed-package list. This final project builds a small tool in that style: a to-do manager whose data survives from one run to the next in a JSON file. Along the way you will design a data format, write files safely so a crash cannot destroy data, structure a CLI with argparse subparsers, and test a program across many invocations.
Designing the data
Before code, decide what lives on disk. A JSON document is a good fit for small personal data: human-readable, diffable, and the standard library reads and writes it.
{
"next_id": 4,
"tasks": [
{"done": true, "id": 1, "text": "buy milk"},
{"done": false, "id": 3, "text": "write report"}
]
}
Design decisions, each with a reason:
- Every task has a stable integer id. Users refer to tasks by id (
done 3). The id must not change when other tasks are removed, so it is stored, not computed from the position in the list. next_idis stored too. Usinglen(tasks) + 1would reuse ids after a removal. A counter that only grows makes ids unique for the life of the store.- A top-level object, not a bare list. An object leaves room to add fields later (
"version": 2, settings) without breaking old files. Formats that will live for years benefit from a version number from the start. sort_keys=True, indent=2when saving, so the file is stable and easy to read or edit by hand.
Persisting state safely
The naive save is:
with open(path, "w") as f:
json.dump(store, f)
Opening with "w" truncates the file immediately. If the program crashes, is killed, or the disk fills up before json.dump finishes, the file is left empty or half-written, and the next load fails with JSONDecodeError. All tasks are gone.
The standard remedy is write to a temporary file, then rename:
import json
import os
def save(path: str, store: dict) -> None:
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(store, f, indent=2, sort_keys=True)
f.write("\n")
os.replace(tmp, path)
os.replace renames tmp over path. On POSIX systems a rename within one file system is atomic: at every instant path is either the complete old file or the complete new one. If anything fails while writing the temp file, the real file is untouched. (For protection against power loss as well, programs also call f.flush() and os.fsync(f.fileno()) before closing; databases and editors do this.) os.replace overwrites an existing target on every platform, unlike os.rename, which fails on Windows if the target exists.
Loading treats a missing file as an empty store, so the first command just works:
def load(path: str) -> dict:
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return {"next_id": 1, "tasks": []}
Note what is not caught: a corrupt file raises JSONDecodeError. Silently returning an empty store there would make the next save overwrite the user's data. Failing loudly is the right default; a friendlier version would print error: todo.json is corrupt and exit 1 without touching the file.
Subcommands with argparse
add_subparsers creates a group of sub-parsers, one per command, each with its own arguments:
import argparse
parser = argparse.ArgumentParser(prog="todo")
parser.add_argument("--file", default="todo.json")
sub = parser.add_subparsers(dest="command", required=True)
p_add = sub.add_parser("add", help="add a task")
p_add.add_argument("words", nargs="+")
p_done = sub.add_parser("done", help="mark a task completed")
p_done.add_argument("id", type=int)
print(parser.parse_args(["add", "buy", "milk"]))
print(parser.parse_args(["--file", "w.json", "done", "3"]))
Namespace(file='todo.json', command='add', words=['buy', 'milk'])
Namespace(file='w.json', command='done', id=3)
dest="command"stores which subcommand was chosen.required=Truemakes a missing subcommand a usage error (exit status 2) instead of a Namespace withcommand=None.- Options of the main parser (
--file) go before the subcommand; each subcommand's arguments go after it. - Help is generated per level:
todo --helplists the commands,todo done --helpshows the arguments ofdone.
nargs="+" on add lets users type todo add buy milk without quotes; joining the words with spaces rebuilds the text.
Dispatching with set_defaults
A chain of if args.command == "add": ... elif ... grows with every command. Instead, attach the handler function to each sub-parser:
def cmd_add(store, args):
...
p_add.set_defaults(func=cmd_add)
p_done.set_defaults(func=cmd_done)
args = parser.parse_args()
status = args.func(store, args)
set_defaults(func=...) puts a func attribute on the Namespace when that subcommand is used. main becomes three lines: parse, load, dispatch. Adding a command means writing one function and registering one parser, and functions are first-class objects, so storing them in the Namespace is natural.
Handlers and exit statuses
Each handler takes the store and the parsed arguments, does its work, prints its result, and returns an exit status:
def cmd_done(store: dict, args) -> int:
task = find_task(store, args.id)
if task is None:
print(f"error: no task {args.id}", file=sys.stderr)
return 1
task["done"] = True
save(args.file, store)
print(f"completed {task['id']}: {task['text']}")
return 0
Errors go to stderr and return 1, and the store is saved only after a successful change, so a failed command never modifies the file. main returns the handler's status to sys.exit, so shell scripts can write todo done 3 && echo ok.
Testing a stateful program
A program whose output depends on earlier runs needs tests that run sequences. The check for the final exercise is a shell script that starts from an empty directory and runs command after command, comparing each stdout and exit status. In Python you would do the same with subprocess and a temporary directory:
import subprocess, sys, tempfile, unittest
from pathlib import Path
class TodoSession(unittest.TestCase):
def run_todo(self, *args):
return subprocess.run([sys.executable, str(self.script), "--file", str(self.store), *args],
capture_output=True, text=True)
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.store = Path(self.tmp.name) / "todo.json"
self.script = Path(__file__).with_name("todo.py")
def tearDown(self):
self.tmp.cleanup()
def test_add_then_list(self):
self.assertEqual(self.run_todo("add", "buy", "milk").stdout, "added 1: buy milk\n")
self.assertEqual(self.run_todo("list").stdout, "[ ] 1 buy milk\n")
def test_unknown_id(self):
r = self.run_todo("done", "9")
self.assertEqual(r.returncode, 1)
self.assertIn("no task 9", r.stderr)
Each test gets a fresh temporary directory, so tests cannot affect each other, and the --file option, which exists partly for exactly this reason, points the tool at it. Being able to redirect where state lives is a design feature that makes programs testable.
Common mistakes
AttributeError: 'Namespace' object has no attribute 'func': a subcommand was registered withoutset_defaults(func=...), or the subparsers group is notrequiredand no subcommand was given.- Putting
--fileafter the subcommand:todo add x --file w.jsonfails withunrecognized argumentsbecause--filebelongs to the main parser. (You could add it to each sub-parser instead, if you prefer that style.) - Comparing ids of different types:
args.idis a string unless you declaretype=int, and"3" == 3isFalse, so every lookup fails. - Saving after an error, or saving a store that failed to load: either can destroy data.
- Removing from a list while iterating over it (
for t in tasks: if ...: tasks.remove(t)) skips elements. Find the task first, then remove it, or build a new list. - Writing in place with
"w"for important data: use a temporary file andos.replace.
Recap
- Design the on-disk format first: stable ids, a stored
next_id, a top-level object with room to grow, sorted and indented JSON. - Save atomically by writing a temp file and
os.replace-ing it over the original; treat a missing file as empty, but never silently ignore a corrupt one. add_subparsers(dest="command", required=True)gives git-style commands;set_defaults(func=handler)dispatches without an if/elif chain.- Handlers print results to stdout, errors to stderr, and return exit statuses that
mainpasses tosys.exit. - Stateful programs are tested by running sequences of commands against a fresh, redirectable store.
Going further: add a due date per task (store ISO strings, parse with date.fromisoformat) and a list --overdue filter, then replace the JSON file with the standard library's sqlite3 module and notice how little of the CLI layer has to change.