~/TechPurAI
~/tutorials/python-from-scratch/building-the-cli
beginner·part 6 of 6·3 min read

Building the CLI with argparse

Updated Aug 11, 2026Python

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:

python
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 parser

Three 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:

python
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:

python
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 0

Two 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.

Catch narrowly

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

python
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

bash
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 food

And the help screen you never had to write:

bash
python tracker.py --help
python tracker.py add --help

What 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.

VK

Vijay Kumar

Founder of TechPurAI — writing hands-on tutorials and honest tool breakdowns.

LinkedIn ↗
← previous5. Saving data to a file with JSON