Building the CLI with argparse
The logic is done. What's missing is a way to drive it: python tracker.py add 12.50 food --note lunch instead of editing the script every time. Python ships argparse for exactly this, and it gives you argument parsing, type conversion, and a --help screen without any of it being hand-written.
Subcommands
add, list, and total each take different arguments, which is what subparsers are for:
import argparse
def build_parser():
parser = argparse.ArgumentParser(description="Track expenses from the terminal.")
sub = parser.add_subparsers(dest="command", required=True)
add = sub.add_parser("add", help="Record a new expense")
add.add_argument("amount", type=float)
add.add_argument("category")
add.add_argument("--note", default="")
sub.add_parser("list", help="Show every expense")
total = sub.add_parser("total", help="Show the total, optionally by category")
total.add_argument("--category")
return parserThree things are worth pausing on. type=float makes argparse do the conversion and reject twelve with a clear message rather than a traceback. Arguments prefixed with -- are optional flags; bare names are positional and required. And required=True on the subparsers means running the script with no arguments prints usage instead of failing later with a confusing None.
Dispatching to your functions
Parsing produces an object whose attributes are named after the arguments:
def main():
parser = build_parser()
args = parser.parse_args()
expenses = load_expenses()
if args.command == "add":
expense = add_expense(expenses, args.amount, args.category, args.note)
save_expenses(expenses)
print(f"Added ${expense['amount']:.2f} to {expense['category']}")
elif args.command == "list":
if not expenses:
print("No expenses recorded yet.")
for e in expenses:
print(f"{e['date']} {e['category']:<12} ${e['amount']:>8.2f} {e['note']}")
elif args.command == "total":
amount = total_for(expenses, args.category)
label = args.category or "all categories"
print(f"{label}: ${amount:.2f}")save_expenses is called only in the add branch — reading commands shouldn't rewrite your data file, so there's nothing to go wrong if the process is interrupted mid-list.
Failing without a traceback
add_expense raises ValueError on a zero or negative amount. Right now that dumps a stack trace at whoever typed the command, which is noise to anyone who isn't the author. Catch it and report it properly:
import sys
def main():
parser = build_parser()
args = parser.parse_args()
expenses = load_expenses()
try:
if args.command == "add":
expense = add_expense(expenses, args.amount, args.category, args.note)
save_expenses(expenses)
print(f"Added ${expense['amount']:.2f} to {expense['category']}")
# ... other branches unchanged
except ValueError as err:
print(f"error: {err}", file=sys.stderr)
return 1
return 0Two conventions are being followed here. Errors go to standard error via file=sys.stderr, so a user piping your output into another command still sees the message. And the function returns a non-zero number on failure, which is how every other command-line tool signals that something went wrong.
except ValueError catches the error you anticipated. A bare except: also swallows typos, keyboard interrupts, and genuine bugs, turning a five-second fix into an afternoon of debugging. Catch the specific exception you know how to handle and let the rest crash loudly.
The entry point
if __name__ == "__main__":
sys.exit(main())__name__ is "__main__" only when the file is run directly, so this block doesn't fire when another module imports your file — which is what makes the functions above reusable elsewhere. sys.exit() takes the return value from main() and makes it the process's exit code.
Using it
python tracker.py add 12.50 food --note "lunch with the team"
python tracker.py add 40 transport
python tracker.py list
python tracker.py total --category foodAnd the help screen you never had to write:
python tracker.py --help
python tracker.py add --helpWhat you built
Roughly eighty lines covering the pieces almost every Python program is made of: variables and types, dictionaries and lists, functions with defaults and return values, file I/O, JSON serialization, exception handling, and a command-line interface.
Three natural next steps, in increasing order of effort: add a delete subcommand (find by index, remove, save); group the total output by category using the dictionary pattern from part 3; or swap the JSON file for SQLite via the built-in sqlite3 module, which is where you'd go once the file grows large enough that rewriting all of it on every add starts to feel wasteful.