DEV Community

Cover image for Build a CLI Task Manager in Python with argparse and Rich
Muhammad Saud
Muhammad Saud

Posted on Originally published at logicstack.org

Build a CLI Task Manager in Python with argparse and Rich

A command line interface, or CLI, is a program we control by typing commands in a terminal instead of clicking buttons in a window. Python ships with argparse, a module that parses those commands and prints help text for us. We build a task manager CLI with argparse and Rich tables, and we run each command to prove it works.

Key takeaways

  • argparse turns a few declarations into a full command parser, with help text, type checks and error messages included.
  • Subparsers give a tool git style commands, so tasks add, tasks list, tasks done and tasks delete each take their own arguments.
  • Rich draws readable tables in the terminal from a few lines of code, and it colours priorities and statuses without manual escape codes.
  • A JSON file is enough storage for a personal CLI tool, and it keeps the data human readable.
  • Running the tool end to end caught a real bug in our delete command, which we fixed before publishing.

What we build

We build tasks.py, a task manager that lives in the terminal. It stores tasks in a tasks.json file next to the script, and it offers four commands. add creates a task with a title and a priority of low, medium or high. list shows tasks in a Rich table, with an optional --status filter. done marks a task complete by its ID, and delete removes one. The whole tool is one file of about 150 lines, it needs no server and no database, and it runs on any machine with Python 3.12 or newer.

What do we need before we start?

We need Python 3.12 or newer and the Rich library. We ran this tutorial with Python 3.12.3 and Rich 15.0.0, installed with pip install rich inside a virtual environment. A virtual environment keeps Rich separate from the system Python, so the tutorial never touches packages other projects rely on. Rich is the only dependency. argparse needs no install because it ships with Python, and the JSON storage uses the standard library too. We keep the code in a folder called cli-task-manager so the script and its data file sit together.

Step 1: Set up the project and install Rich

We create the folder and install Rich in our virtual environment. Rich renders tables, colours and progress bars in the terminal, and it is the library behind the Textual terminal framework, according to the Rich documentation.

mkdir cli-task-manager
cd cli-task-manager
pip install rich
Enter fullscreen mode Exit fullscreen mode

Our install pulled in Rich 15.0.0 with its small dependencies, markdown-it-py and Pygments. We check the install with a one-liner that prints both versions, because every version number in this tutorial is one we measured, not one we guessed.

python -c "import sys; from importlib.metadata import version; print('Python', sys.version.split()[0]); print('rich', version('rich'))"
Enter fullscreen mode Exit fullscreen mode
Python 3.12.3
rich 15.0.0
Enter fullscreen mode Exit fullscreen mode

Step 2: How do subcommands work in argparse?

Subcommands are the pattern behind tools like git and docker, where the first word after the program name picks the action. argparse models this with add_subparsers(). We create one parser for the whole tool, then one subparser per command, and we attach a handler function to each subparser with set_defaults(func=...). After parsing, we call the attached function with the parsed arguments. Each subparser also gets its own help text, so tasks add --help explains only the add command.

One detail worth knowing: add_subparsers() takes required=True, which makes argparse print a clear error when we run tasks with no command at all instead of silently doing nothing. Without it, a bare run exits quietly and the user has no idea what went wrong.

def build_parser():
    parser = argparse.ArgumentParser(
        prog="tasks",
        description="A small task manager for the terminal.",
    )
    sub = parser.add_subparsers(dest="command", required=True)

    p_add = sub.add_parser("add", help="Add a new task")
    p_add.add_argument("title", help="What needs doing")
    p_add.add_argument("--priority", choices=("low", "medium", "high"),
                       default="medium", help="Task priority (default: medium)")
    p_add.set_defaults(func=cmd_add)

    p_list = sub.add_parser("list", help="List tasks")
    p_list.add_argument("--status", choices=("all", "todo", "done"),
                        default="all", help="Which tasks to show (default: all)")
    p_list.set_defaults(func=cmd_list)

    p_done = sub.add_parser("done", help="Mark a task as done")
    p_done.add_argument("task_id", type=int, help="ID of the task")
    p_done.set_defaults(func=cmd_done)

    p_delete = sub.add_parser("delete", help="Delete a task")
    p_delete.add_argument("task_id", type=int, help="ID of the task")
    p_delete.set_defaults(func=cmd_delete)

    return parser

def main():
    parser = build_parser()
    args = parser.parse_args()
    args.func(args)
Enter fullscreen mode Exit fullscreen mode

Notice the two kinds of arguments. title and task_id are positional, so they are required and given without a flag. --priority and --status are optional flags with choices, which means argparse rejects anything outside the list before our code ever runs. type=int on task_id converts the text to a number and rejects done abc with a clear error.

Step 3: Store tasks in a JSON file

We keep storage simple. Each task is a dictionary with an ID, a title, a priority and a done flag, and the whole list lives in tasks.json. Two tiny functions handle the file, and a third hands out the next ID.

import argparse
import json
from pathlib import Path

from rich.console import Console
from rich.table import Table

DATA_FILE = Path(__file__).with_name("tasks.json")
console = Console()

PRIORITIES = ("low", "medium", "high")

def load_tasks():
    if not DATA_FILE.exists():
        return []
    return json.loads(DATA_FILE.read_text(encoding="utf-8"))

def save_tasks(tasks):
    DATA_FILE.write_text(json.dumps(tasks, indent=2), encoding="utf-8")

def next_id(tasks):
    return max((task["id"] for task in tasks), default=0) + 1
Enter fullscreen mode Exit fullscreen mode

We place the data file next to the script with Path(file).with_name("tasks.json") instead of a relative path, so the tool works no matter which folder we run it from. load_tasks returns an empty list when the file does not exist yet, which is the correct behaviour for a first run. JSON keeps the data readable in any text editor, and that matters when we debug the tool later.

Step 4: Add the add and list commands

The add command loads the tasks, appends a new dictionary and saves the list. The list command filters by status, then builds a Rich table with one row per task. Rich colours the priority and the status, so a quick scan shows what is urgent and what is finished.

def cmd_add(args):
    tasks = load_tasks()
    task = {
        "id": next_id(tasks),
        "title": args.title,
        "priority": args.priority,
        "done": False,
    }
    tasks.append(task)
    save_tasks(tasks)
    console.print(f"[green]Added task #{task['id']}:[/green] {task['title']} "
                  f"({task['priority']} priority)")

def cmd_list(args):
    tasks = load_tasks()
    if args.status == "done":
        tasks = [t for t in tasks if t["done"]]
    elif args.status == "todo":
        tasks = [t for t in tasks if not t["done"]]

    if not tasks:
        console.print("[yellow]No tasks found. Add one with:[/yellow] "
                      "python tasks.py add \"Buy milk\"")
        return

    table = Table(title="Tasks")
    table.add_column("ID", justify="right", style="cyan")
    table.add_column("Title", style="white")
    table.add_column("Priority", justify="center")
    table.add_column("Status", justify="center")
    for task in tasks:
        status = "[green]done[/green]" if task["done"] else "[yellow]todo[/yellow]"
        colour = {"high": "red", "medium": "yellow", "low": "dim"}.get(task["priority"], "white")
        table.add_row(str(task["id"]), task["title"],
                      f"[{colour}]{task['priority']}[/{colour}]", status)
    console.print(table)
Enter fullscreen mode Exit fullscreen mode

The square brackets in the strings are Rich markup, not plain text. [green]...[/green] colours the text inside, and console.print interprets it. When output is piped to a file, Rich strips the colours automatically, so the same code prints clean text in logs.

Step 5: Add done and delete, and fix a real bug

The done command finds a task by ID, flips its flag and saves. The delete command builds a new list without the task and saves that list instead. When we first ran the tool end to end, delete printed its success message but the task stayed in the file. The bug was one wrong variable name: the function filtered the list into kept and then saved the original tasks list. We fixed it by saving kept, and the retest below proves the fix.

def cmd_done(args):
    tasks = load_tasks()
    for task in tasks:
        if task["id"] == args.task_id:
            if task["done"]:
                console.print(f"[yellow]Task #{args.task_id} is already done.[/yellow]")
                return
            task["done"] = True
            save_tasks(tasks)
            console.print(f"[green]Done:[/green] {task['title']}")
            return
    console.print(f"[red]No task with ID {args.task_id}.[/red]")

def cmd_delete(args):
    tasks = load_tasks()
    kept = [t for t in tasks if t["id"] != args.task_id]
    if len(kept) == len(tasks):
        console.print(f"[red]No task with ID {args.task_id}.[/red]")
        return
    save_tasks(kept)
    console.print(f"[green]Deleted task #{args.task_id}.[/green]")
Enter fullscreen mode Exit fullscreen mode

Both commands handle a missing ID with a plain message instead of crashing. That is a small choice with a large effect on how the tool feels: a tool that says "No task with ID 99" is one we trust, and a tool that dumps a traceback is one we stop using.

Step 6: Run the whole tool end to end

We delete the data file and run a full session: add three tasks, complete one, delete one, and list what is left. This is the real output from our run.

python tasks.py add "Write the weekly report" --priority high
python tasks.py add "Buy printer paper"
python tasks.py add "Review pull request 42" --priority low
python tasks.py done 2
python tasks.py delete 3
Enter fullscreen mode Exit fullscreen mode
Added task #1: Write the weekly report (high priority)
Added task #2: Buy printer paper (medium priority)
Added task #3: Review pull request 42 (low priority)
Done: Buy printer paper
Deleted task #3.
Enter fullscreen mode Exit fullscreen mode

The final list shows only the two tasks that remain, and the JSON file on disk agrees with it. The file holds two entries, task 1 still open and task 2 marked done, which is exactly the state the commands produced.

python tasks.py list
Enter fullscreen mode Exit fullscreen mode
Tasks
┏━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━┓
┃ ID ┃ Title                   ┃ Priority ┃ Status ┃
┡━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━┩
│  1 │ Write the weekly report │   high   │  todo  │
│  2 │ Buy printer paper       │  medium  │  done  │
└────┴─────────────────────────┴──────────┴────────┘
Enter fullscreen mode Exit fullscreen mode

Two checks confirm the validation works. Filtering to done tasks shows only task 2, and an invalid priority is rejected by argparse before our code runs, with exit code 2.

python tasks.py list --status done
python tasks.py add "x" --priority urgent
Enter fullscreen mode Exit fullscreen mode
Tasks
┏━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━┓
┃ ID ┃ Title             ┃ Priority ┃ Status ┃
┡━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━┩
│  2 │ Buy printer paper │  medium  │  done  │
└────┴───────────────────┴──────────┴────────┘
tasks add: error: argument --priority: invalid choice: 'urgent' (choose from 'low', 'medium', 'high')
Enter fullscreen mode Exit fullscreen mode

The help text is free. python tasks.py --help lists all four commands, and python tasks.py add --help explains only the add command, because argparse generates both from the declarations we wrote in step 2.

usage: tasks [-h] {add,list,done,delete} ...

A small task manager for the terminal.

positional arguments:
  {add,list,done,delete}
    add                 Add a new task
    list                List tasks
    done                Mark a task as done
    delete              Delete a task

options:
  -h, --help            show this help message and exit
Enter fullscreen mode Exit fullscreen mode

What to try next

First, add due dates with a --due flag and sort the table by date, which teaches custom type functions in argparse. Second, add an edit subcommand that changes a task title by ID, which reuses the same load, change and save pattern as done. Third, package the tool with a pyproject.toml entry point so tasks runs from anywhere without typing python tasks.py first.


Originally published on LogicStack. Run the code examples right in your browser with our free interactive Python and SQL playgrounds — no setup, no sign-up.

Top comments (0)