Capstone: Building a Real CLI Tool
๐ฌ Capstone: Building a Real CLI Tool
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)
| Piece | Rust (rust3-6) | Python (this chapter) |
|---|---|---|
| Data model | A Task struct with #[derive(...)] macros | A Task dataclass with @dataclass |
| Storage abstraction | A Storage trait, implemented for a JSON backend | A TaskStorage class, composing a Path |
| Serialization | serde crate | json module (standard library) |
| CLI parsing | clap crate | argparse (standard library) |
| Testing | cargo 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.
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.
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.
๐ 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.