DEV Community

jib2004
jib2004

Posted on

Building a CLI tool with Python(Click)

Not every useful tool needs a web interface, a database server, or a login page. Sometimes the fastest way to solve a problem is a small command-line tool you can run from your terminal in two seconds. That's exactly what I built with Penny a lightweight command-line expense tracker written in Python, powered by the Click library.

Penny lets you add expenses, list them, see a spending summary by category, and clear everything out all from the terminal, with no external database, no server, and no setup beyond installing one dependency. In this post, I'll walk through exactly how it works, file by file, and explain the Click concepts that make it all click.

Why Click?

Python's built-in argparse module can build command-line tools too, but it gets verbose fast. Click takes a different approach: you write regular Python functions and decorate them with @click.command() and @click.option(), and Click handles all the parsing, help text, and validation for you.

The result is CLI code that reads almost like plain Python, which is exactly what you'll see in Penny's commands.py.

Project Structure

Penny is organized into three main pieces:

penny/
├── commands.py # Each command's logic
├── penny.py # Groups the commands into one CLI
└── pyproject.toml # Packaging + entry point config

This separation matters: commands.py defines what each command does, while penny.py defines how they're exposed together as a single penny command. Let's go through each one.

commands.py: Where the Logic Lives
Reading and writing expenses as JSON

Penny doesn't use a database it stores everything in a simple expenses.json file, where each expense is a dictionary with an amount, category, and note:

expense_file = Path("expenses.json")
Enter fullscreen mode Exit fullscreen mode

Every command opens this file, reads the existing list of expenses with json.load(), does something with it, and for commands that change data writes it back with json.dump(). This is a pattern you'll see repeated across almost every command: read the JSON, modify it in Python, write the JSON back.

The add command

@click.command()
@click.option("--amount", type=int, default=0)
@click.option("--category", default="")
@click.option("--note", default="")
def add(amount, category, note):
        if not expense_file.exists():
        file_path = dir_path / expense_file
        file_path.touch(exist_ok=True)
        expense_file.write_text("[]")

    with open("expenses.json","r") as f:
        expenses = json.load(f)

    expenses.append({"amount":amount,"category":category,"note":note})
    with open("expenses.json","w") as f:
        json.dump(expenses,f,indent=4)


    click.echo( click.style("Added expense",fg="green"))
Enter fullscreen mode Exit fullscreen mode

Each @click.option() decorator turns a function argument into a command-line flag. This means running:

bash

penny add --amount 25 --category food --note "lunch"
Enter fullscreen mode Exit fullscreen mode

calls add(amount=25, category="food", note="lunch") automatically. Click handles converting --amount into an actual integer because of type=int, and supplies sensible defaults if a flag is skipped.

Inside the function, if expenses.json doesn't exist yet, it's created and initialized with an empty list ("[]"). Then the new expense is appended to the list and the whole thing is saved back to disk.

Finally, click.echo(click.style("Added expense", fg="green")) prints a colored confirmation message one of Click's nice built-in touches: click.style() lets you add terminal colors without pulling in a separate library.

The list command

@click.command()
def list():
    ...
Enter fullscreen mode Exit fullscreen mode

This command loads all saved expenses and prints them in a simple table format, using nl=False to keep multiple click.echo() calls on the same line, and nl=True on the last one to move to a new line:

click.echo(f"{expense['amount']}    ", nl=False)
click.echo(f"{expense['category']}    ", nl=False)
click.echo(f"{expense['note']}    ", nl=True)
Enter fullscreen mode Exit fullscreen mode

If there are no expenses yet, it prints a red warning instead (click.style("No expenses added", fg="red")) and exits early using exit(0).

The summary command

@click.command()
def summary():
    ...
Enter fullscreen mode Exit fullscreen mode

This is where the tool becomes genuinely useful. It loops through every expense, tracks the amount per category in a dictionary, and uses functools.reduce() to add up the total:

total_amount = reduce(lambda x, y: x + y, amt, 0)

reduce() here is just a compact way of summing a list equivalent to using sum(amt), but it's a nice demonstration of functional-style Python. The category totals and the grand total are then combined into one dictionary and printed as formatted JSON:

click.echo(json.dumps(summary_note, indent=4))

The clear command

@click.command()
def clear():
    if click.confirm("Are you sure you want to delete all expenses?"):
        ...
Enter fullscreen mode Exit fullscreen mode

This one demonstrates another handy Click feature: click.confirm(), which prompts the user with a yes/no question and only proceeds if they confirm. It's a small safety net before wiping out expenses.json by overwriting it with an empty list.

penny.py: Tying the Commands Together

@click.group()
def cli():
    pass

cli.add_command(command.add)
cli.add_command(command.list)
cli.add_command(command.summary)
cli.add_command(command.clear)
Enter fullscreen mode Exit fullscreen mode

This is where Click's group concept comes in. A @click.group() acts as a parent command that individual subcommands attach to. Once everything is registered, users interact with one unified tool:

bash
penny add --amount 40 --category transport --note "bus fare"
penny list
penny summary
penny clear

Each of those is really just calling the matching function back in commands.py, but from the user's perspective, it feels like one polished CLI application with multiple built-in features not four separate scripts.

pyproject.toml: Making It Installable
toml
[project.scripts]
penny = "penny.penny:cli"

This line is what turns Penny from "a Python file you run with python penny.py" into an actual installed command you can type as penny from anywhere in your terminal. Combined with flit_core as the build backend, this config allows the whole tool to be installed with a simple pip install ., after which penny becomes available as a real command just like git or ls.

What I Learned Building This

A few things stood out while building Penny:

Click decorators remove a lot of boilerplate. Compare @click.option("--amount", type=int) to manually parsing sys.argv Click's declarative style is dramatically less error-prone.
JSON is a perfectly fine "database" for small tools. For a single-user CLI tracking a personal list of expenses, there's no need for SQLite or Postgres a JSON file does the job with zero setup.
Small UX details matter. Things like colored output (click.style) and confirmation prompts (click.confirm) make a CLI tool feel considered rather than thrown together.
Grouping commands makes a tool feel like a real product. The jump from "a script" to "a CLI application" really happens at the @click.group() step, where scattered functions become one cohesive interface.

Top comments (0)