Capstone: Building a Real CLI Tool

Python Advanced
Course 3 ยท Chapter 8 ยท Capstone: Building a Real CLI Tool

๐ŸŽฌ Capstone: Building a Real CLI Tool

The final chapter of Python Advanced โ€” and of the whole structured Python track. We're building tasks, a small real command-line task tracker: add, list, complete, and delete tasks, persisted to a JSON file, installable as a real command. It combines OOP and type hints (Course 3, Chapters 1 & 5), JSON and file handling (Course 2, Chapters 4 & 7), packaging (Chapter 6), and testing (Course 2, Chapter 8) into one working tool โ€” deliberately mirroring the Rust track's own capstone (rust3-6), a task-tracker CLI built from the equivalent Rust pieces.

๐Ÿ“‹ The Data Model

# src/tasks/models.py
from dataclasses import dataclass

@dataclass
class Task:
    id: int
    title: str
    done: bool = False

@dataclass is a small, genuinely useful payoff of Course 2, Chapter 2's dunder methods: instead of hand-writing __init__, __repr__, and __eq__ the way that chapter's Point class did manually, @dataclass generates all three automatically from the type-hinted attributes above โ€” id: int, title: str, done: bool = False reuse this course's own Chapter 5 type-hint syntax directly, now doing real work rather than just documentation.

๐Ÿ’พ Storage: A TaskStorage Class

# src/tasks/storage.py
import json
from pathlib import Path
from .models import Task

class TaskStorage:
    def __init__(self, path: Path):
        self.path = path

    def load(self) -> list[Task]:
        if not self.path.exists():
            return []
        data = json.loads(self.path.read_text())
        return [Task(**item) for item in data]

    def save(self, tasks: list[Task]) -> None:
        data = [task.__dict__ for task in tasks]
        self.path.write_text(json.dumps(data, indent=2))

TaskStorage composes a Path (Course 2, Chapter 4) rather than inheriting from anything โ€” the same composition-over-inheritance instinct Course 3, Chapter 1 recommended. load() reuses pathlib's .exists()/.read_text() and json.loads() (Course 2, Chapter 7) together; Task(**item) unpacks each JSON object's keys directly as keyword arguments into the dataclass. save() mirrors it with .write_text() and json.dumps().

โŒจ๏ธ The CLI Interface: argparse

# src/tasks/cli.py
import argparse
from pathlib import Path
from .storage import TaskStorage
from .models import Task

def main():
    parser = argparse.ArgumentParser(prog="tasks")
    subparsers = parser.add_subparsers(dest="command", required=True)

    add_parser = subparsers.add_parser("add")
    add_parser.add_argument("title")

    subparsers.add_parser("list")

    complete_parser = subparsers.add_parser("complete")
    complete_parser.add_argument("id", type=int)

    args = parser.parse_args()
    storage = TaskStorage(Path.home() / ".tasks.json")
    tasks = storage.load()

    if args.command == "add":
        next_id = max((t.id for t in tasks), default=0) + 1
        tasks.append(Task(id=next_id, title=args.title))
        storage.save(tasks)
        print(f"Added task {next_id}: {args.title}")

    elif args.command == "list":
        for t in tasks:
            status = "x" if t.done else " "
            print(f"[{status}] {t.id}: {t.title}")

    elif args.command == "complete":
        for t in tasks:
            if t.id == args.id:
                t.done = True
        storage.save(tasks)
        print(f"Completed task {args.id}")

argparse โ€” a new tool for this course, part of the standard library โ€” parses command-line arguments into a structured args object. add_subparsers gives each subcommand (add, list, complete) its own arguments; args.command then drives a plain if/elif chain, the same branching from Course 1, Chapter 3, dispatching to whichever behavior the user asked for.

๐Ÿ“ฆ Packaging It Up

# pyproject.toml
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "tasks"
version = "0.1.0"

[project.scripts]
tasks = "tasks.cli:main"

This is Chapter 6's pyproject.toml, with one new piece: [project.scripts] registers a console entry point โ€” after pip install -e ., typing tasks add "Buy milk" at the command line runs main() from tasks/cli.py directly, no python prefix needed. This is what turns a script into a genuine installed command.

๐Ÿงช Testing the Tool

# tests/test_storage.py
from tasks.models import Task
from tasks.storage import TaskStorage

def test_save_and_load_round_trip(tmp_path):
    storage = TaskStorage(tmp_path / "tasks.json")
    original = [Task(id=1, title="Buy milk")]

    storage.save(original)
    loaded = storage.load()

    assert loaded == original

def test_load_missing_file_returns_empty_list(tmp_path):
    storage = TaskStorage(tmp_path / "does_not_exist.json")
    assert storage.load() == []

tmp_path is a built-in pytest fixture (Course 2, Chapter 8) โ€” no @pytest.fixture needed to define it, pytest provides it automatically, giving each test a fresh temporary directory so tests never touch a real ~/.tasks.json file. loaded == original works cleanly on the very first try because @dataclass already generated __eq__ โ€” the exact custom equality Course 2, Chapter 2 wrote by hand for Fraction, here provided for free.

Task Tracker: Python vs Rust (rust3-6)

PieceRust (rust3-6)Python (this chapter)
Data modelA Task struct with #[derive(...)] macrosA Task dataclass with @dataclass
Storage abstractionA Storage trait, implemented for a JSON backendA TaskStorage class, composing a Path
Serializationserde cratejson module (standard library)
CLI parsingclap crateargparse (standard library)
Testingcargo test, #[test]pytest, test_* functions

The shape of the two capstones is genuinely the same problem solved with each language's own idioms โ€” Rust reaches for third-party crates (serde, clap) for JSON and CLI parsing where Python's standard library already covers both natively, a real, concrete instance of the "batteries included" difference this whole course has touched on since Chapter 6.

@dataclass

Auto-generates __init__/__repr__/__eq__ from type-hinted attributes.

Composition

TaskStorage holds a Path, rather than inheriting from anything.

argparse subcommands

add_subparsers() gives each command (add/list/complete) its own arguments.

tmp_path fixture

A built-in pytest fixture โ€” isolates file-touching tests automatically.

๐Ÿ’ป Coding Challenges

Challenge 1: Add a delete Command

Extend the argparse setup with a delete subcommand taking an id, and the matching elif branch that removes the task with that ID from the list (using a list comprehension, Course 1/2) before calling storage.save().

Goal: Practice extending a real, structured CLI tool with a new command, following the existing pattern.

โ†’ Solution

Challenge 2: Test the complete Behavior

Write a pytest test using tmp_path that saves two tasks, "completes" one by directly mutating its done attribute and saving again, reloads, and asserts exactly one task now has done == True.

Goal: Practice testing behavior beyond a simple round trip, reusing the tmp_path fixture pattern.

โ†’ Solution

Challenge 3: A Custom Exception for a Missing Task

Define a custom exception TaskNotFoundError(Exception) (Course 2, Chapter 3). Write a method TaskStorage.find(tasks, task_id) that returns the matching Task or raises TaskNotFoundError with a descriptive message if no task has that ID.

Goal: Tie the capstone back to this course's own exception-handling chapter with a realistic use case.

โ†’ Solution

๐ŸŽ“ Course Complete!

That's all 8 chapters of Python Advanced โ€” advanced OOP, iterators and context managers, concurrency, async, type hints, packaging, performance, and this capstone โ€” and with it, the full three-course Python track (Fundamentals โ†’ Intermediate โ†’ Advanced, 25 chapters). Python Projects (py4) remains outlined as a lighter, project-based fourth course whenever you're ready to continue.