Capstone: Building a Real CLI Tool

Python Advanced — 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.