π Lesson 12: Capstone β CLI Task Manager
It's time to bring everything together. You'll build a fully featured command-line task manager from scratch β combining OOP, SQLite, API integration, proper project structure, error handling, and a test suite. This is your proof that you've leveled up.
π― What You'll Build
A CLI application called TaskFlow that lets users:
- Create, list, update, and delete tasks with priorities and due dates
- Organize tasks into categories
- Search and filter tasks by status, priority, or keyword
- Fetch an inspirational quote from a public API
- Export tasks to JSON
- View statistics about their productivity
Estimated Time: 90 minutes
Skills Used: OOP (Lessons 1β3), File I/O (Lesson 4), Error Handling (Lesson 5), Regex (Lesson 6), Generators (Lesson 7), Project Structure (Lesson 9), APIs (Lesson 10), SQLite (Lesson 11)
In This Lesson
πΊοΈ Project Overview & Architecture
TaskFlow is built with a layered architecture β each layer has a clear responsibility and talks only to the layers next to it. This makes the code easy to understand, test, and extend.
cli.pyUser interaction, menus, formatting"] --> MGR["βοΈ Manager Layer
manager.pyBusiness logic, validation"] MGR --> DB["ποΈ Database Layer
database.pySQL queries, connection"] MGR --> MODEL["π¦ Model Layer
models.pyTask dataclass"] CLI --> API["π API Layer
api.pyExternal API calls"] DB --> FILE["π tasks.db
SQLite file"] style CLI fill:#eff6ff,stroke:#3b82f6,color:#1e293b style MGR fill:#f0fdf4,stroke:#22c55e,color:#1e293b style DB fill:#fefce8,stroke:#f59e0b,color:#1e293b style MODEL fill:#faf5ff,stroke:#a855f7,color:#1e293b style API fill:#fff1f2,stroke:#ef4444,color:#1e293b style FILE fill:#f8fafc,stroke:#64748b,color:#1e293b
| Layer | File | Responsibility |
|---|---|---|
| Model | models.py |
Define the Task data structure with validation |
| Database | database.py |
All SQL interactions β create tables, CRUD queries |
| Manager | manager.py |
Business logic β orchestrates database and model layers |
| API | api.py |
Fetch external data (inspirational quotes) |
| CLI | cli.py |
User-facing menus, input, and output formatting |
| Entry point | __main__.py |
Launches the app |
| Tests | tests/ |
Verifies everything works correctly |
π‘ Why Separate Layers?
Separating concerns means you can change the database without touching the CLI, swap the API without affecting business logic, or write tests that check the manager without needing user input. This is how professional Python projects are structured.
π Project Structure
taskflow/
βββ taskflow/
β βββ __init__.py # Package marker
β βββ __main__.py # Entry point: python -m taskflow
β βββ models.py # Task dataclass
β βββ database.py # SQLite operations
β βββ manager.py # Business logic
β βββ api.py # External API calls
β βββ cli.py # Command-line interface
βββ tests/
β βββ __init__.py
β βββ test_models.py # Test the Task model
β βββ test_database.py # Test database operations
β βββ test_manager.py # Test business logic
βββ requirements.txt # Dependencies
βββ README.md # Project documentation
Create this structure now:
mkdir -p taskflow/taskflow tests
touch taskflow/taskflow/__init__.py
touch taskflow/taskflow/__main__.py
touch taskflow/taskflow/models.py
touch taskflow/taskflow/database.py
touch taskflow/taskflow/manager.py
touch taskflow/taskflow/api.py
touch taskflow/taskflow/cli.py
touch taskflow/tests/__init__.py
touch taskflow/tests/test_models.py
touch taskflow/tests/test_database.py
touch taskflow/tests/test_manager.py
And your requirements.txt:
requests>=2.31.0
π Package Layout Reminder
The inner taskflow/ directory with __init__.py makes it a Python package. The outer taskflow/ is the project root. This is the standard layout we covered in Lesson 9. The __main__.py file lets you run the package with python -m taskflow.
ποΈ Step 1 β The Database Layer (database.py)
This module owns all SQL. No other file should contain raw SQL queries. This keeps the database logic centralized and easy to maintain.
"""
database.py β SQLite database operations for TaskFlow.
"""
import sqlite3
from pathlib import Path
DEFAULT_DB = Path.home() / ".taskflow" / "tasks.db"
class Database:
"""Manages the SQLite connection and all CRUD operations."""
def __init__(self, db_path=None):
self.db_path = db_path or DEFAULT_DB
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
def _get_connection(self):
"""Create a new connection with row_factory enabled."""
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def _init_db(self):
"""Create tables if they don't exist."""
with self._get_connection() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'todo'
CHECK(status IN ('todo', 'in_progress', 'done')),
priority TEXT NOT NULL DEFAULT 'medium'
CHECK(priority IN ('low', 'medium', 'high')),
category TEXT DEFAULT 'general',
due_date TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
completed_at TEXT
)
""")
# ββ Create βββββββββββββββββββββββββββββββββββββββββββββββ
def insert_task(self, title, description="", priority="medium",
category="general", due_date=None):
"""Insert a new task and return its id."""
with self._get_connection() as conn:
cursor = conn.execute("""
INSERT INTO tasks (title, description, priority, category, due_date)
VALUES (?, ?, ?, ?, ?)
""", (title, description, priority, category, due_date))
return cursor.lastrowid
# ββ Read βββββββββββββββββββββββββββββββββββββββββββββββββ
def get_task(self, task_id):
"""Get a single task by id. Returns dict or None."""
with self._get_connection() as conn:
row = conn.execute(
"SELECT * FROM tasks WHERE id = ?", (task_id,)
).fetchone()
return dict(row) if row else None
def get_all_tasks(self, status=None, priority=None, category=None):
"""Get tasks with optional filters. Returns list of dicts."""
query = "SELECT * FROM tasks WHERE 1=1"
params = []
if status:
query += " AND status = ?"
params.append(status)
if priority:
query += " AND priority = ?"
params.append(priority)
if category:
query += " AND category = ?"
params.append(category)
query += " ORDER BY CASE priority WHEN 'high' THEN 1 "
query += "WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END, created_at DESC"
with self._get_connection() as conn:
rows = conn.execute(query, params).fetchall()
return [dict(r) for r in rows]
def search_tasks(self, keyword):
"""Search tasks by title or description."""
pattern = f"%{keyword}%"
with self._get_connection() as conn:
rows = conn.execute("""
SELECT * FROM tasks
WHERE title LIKE ? OR description LIKE ?
ORDER BY created_at DESC
""", (pattern, pattern)).fetchall()
return [dict(r) for r in rows]
# ββ Update βββββββββββββββββββββββββββββββββββββββββββββββ
def update_task(self, task_id, **fields):
"""Update specific fields of a task. Returns True if updated."""
if not fields:
return False
set_clause = ", ".join(f"{k} = ?" for k in fields)
values = list(fields.values()) + [task_id]
with self._get_connection() as conn:
cursor = conn.execute(
f"UPDATE tasks SET {set_clause} WHERE id = ?", values
)
return cursor.rowcount > 0
def mark_complete(self, task_id):
"""Mark a task as done and record the completion time."""
with self._get_connection() as conn:
cursor = conn.execute("""
UPDATE tasks
SET status = 'done', completed_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (task_id,))
return cursor.rowcount > 0
# ββ Delete βββββββββββββββββββββββββββββββββββββββββββββββ
def delete_task(self, task_id):
"""Delete a task by id. Returns True if deleted."""
with self._get_connection() as conn:
cursor = conn.execute(
"DELETE FROM tasks WHERE id = ?", (task_id,)
)
return cursor.rowcount > 0
# ββ Stats ββββββββββββββββββββββββββββββββββββββββββββββββ
def get_stats(self):
"""Return task statistics as a dict."""
with self._get_connection() as conn:
total = conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0]
by_status = conn.execute("""
SELECT status, COUNT(*) as count
FROM tasks GROUP BY status
""").fetchall()
by_priority = conn.execute("""
SELECT priority, COUNT(*) as count
FROM tasks GROUP BY priority
""").fetchall()
by_category = conn.execute("""
SELECT category, COUNT(*) as count
FROM tasks GROUP BY category ORDER BY count DESC
""").fetchall()
return {
"total": total,
"by_status": {r["status"]: r["count"] for r in by_status},
"by_priority": {r["priority"]: r["count"] for r in by_priority},
"by_category": {r["category"]: r["count"] for r in by_category},
}
β Design Decisions
- Class-based β the
Databaseclass encapsulates the connection path and initialization, making it easy to create test databases with a different path - All SQL is here β other modules never write raw SQL
WHERE 1=1pattern β makes it easy to appendANDconditions dynamically- Returns dicts β using
sqlite3.Row+dict()keeps the data layer clean - Parameterized queries everywhere β no SQL injection
π¦ Step 2 β The Task Model (models.py)
The model defines what a Task is and validates its data. We use Python's dataclasses module for a clean, concise definition:
"""
models.py β Task data model for TaskFlow.
"""
from dataclasses import dataclass, field
from datetime import datetime
import re
VALID_STATUSES = ("todo", "in_progress", "done")
VALID_PRIORITIES = ("low", "medium", "high")
DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
@dataclass
class Task:
"""Represents a single task with validation."""
title: str
description: str = ""
status: str = "todo"
priority: str = "medium"
category: str = "general"
due_date: str | None = None
id: int | None = None
created_at: str | None = None
completed_at: str | None = None
def __post_init__(self):
"""Validate fields after initialization."""
if not self.title or not self.title.strip():
raise ValueError("Task title cannot be empty")
self.title = self.title.strip()
if self.status not in VALID_STATUSES:
raise ValueError(
f"Invalid status '{self.status}'. "
f"Must be one of: {VALID_STATUSES}"
)
if self.priority not in VALID_PRIORITIES:
raise ValueError(
f"Invalid priority '{self.priority}'. "
f"Must be one of: {VALID_PRIORITIES}"
)
if self.due_date and not DATE_PATTERN.match(self.due_date):
raise ValueError(
f"Invalid due_date '{self.due_date}'. "
f"Use YYYY-MM-DD format."
)
@classmethod
def from_dict(cls, data):
"""Create a Task from a database row dict."""
return cls(
id=data.get("id"),
title=data["title"],
description=data.get("description", ""),
status=data.get("status", "todo"),
priority=data.get("priority", "medium"),
category=data.get("category", "general"),
due_date=data.get("due_date"),
created_at=data.get("created_at"),
completed_at=data.get("completed_at"),
)
def to_dict(self):
"""Convert to a plain dict (useful for JSON export)."""
return {
"id": self.id,
"title": self.title,
"description": self.description,
"status": self.status,
"priority": self.priority,
"category": self.category,
"due_date": self.due_date,
"created_at": self.created_at,
"completed_at": self.completed_at,
}
@property
def is_overdue(self):
"""Check if the task is past its due date and not done."""
if not self.due_date or self.status == "done":
return False
try:
due = datetime.strptime(self.due_date, "%Y-%m-%d").date()
return due < datetime.now().date()
except ValueError:
return False
@property
def priority_icon(self):
"""Return an emoji for the priority level."""
icons = {"high": "π΄", "medium": "π‘", "low": "π’"}
return icons.get(self.priority, "βͺ")
@property
def status_icon(self):
"""Return an emoji for the status."""
icons = {"todo": "β¬", "in_progress": "π", "done": "β
"}
return icons.get(self.status, "β")
def __str__(self):
overdue = " β οΈ OVERDUE" if self.is_overdue else ""
due = f" (due {self.due_date})" if self.due_date else ""
return (
f"{self.status_icon} {self.priority_icon} "
f"[{self.id}] {self.title}{due}{overdue}"
)
π§ Skills Spotcheck
- Dataclasses (Lesson 1) β clean class definition with type hints
- Magic methods (Lesson 3) β
__post_init__for validation,__str__for display - Regex (Lesson 6) β
DATE_PATTERNvalidates the date format - Properties β
is_overdue,priority_icon,status_iconare computed attributes @classmethodβfrom_dict()is a factory method for creating Tasks from database rows
βοΈ Step 3 β The Task Manager (manager.py)
The manager is the brain of the application. It sits between the CLI and the database, handling validation, business rules, and data transformation:
"""
manager.py β Business logic for TaskFlow.
"""
import json
from pathlib import Path
from .database import Database
from .models import Task
class TaskManager:
"""Orchestrates task operations between the CLI and database."""
def __init__(self, db_path=None):
self.db = Database(db_path)
def add_task(self, title, description="", priority="medium",
category="general", due_date=None):
"""Validate and create a new task. Returns the Task object."""
# Validate by creating a Task object first
task = Task(
title=title,
description=description,
priority=priority,
category=category,
due_date=due_date,
)
task_id = self.db.insert_task(
title=task.title,
description=task.description,
priority=task.priority,
category=task.category,
due_date=task.due_date,
)
task.id = task_id
return task
def get_task(self, task_id):
"""Get a task by id. Returns Task or None."""
data = self.db.get_task(task_id)
return Task.from_dict(data) if data else None
def list_tasks(self, status=None, priority=None, category=None):
"""Get filtered list of Task objects."""
rows = self.db.get_all_tasks(
status=status, priority=priority, category=category
)
return [Task.from_dict(r) for r in rows]
def search(self, keyword):
"""Search tasks by keyword. Returns list of Task objects."""
rows = self.db.search_tasks(keyword)
return [Task.from_dict(r) for r in rows]
def update_task(self, task_id, **fields):
"""Update a task's fields after validation."""
# Validate new values by checking constraints
if "status" in fields and fields["status"] not in ("todo", "in_progress", "done"):
raise ValueError(f"Invalid status: {fields['status']}")
if "priority" in fields and fields["priority"] not in ("low", "medium", "high"):
raise ValueError(f"Invalid priority: {fields['priority']}")
return self.db.update_task(task_id, **fields)
def complete_task(self, task_id):
"""Mark a task as done."""
return self.db.mark_complete(task_id)
def delete_task(self, task_id):
"""Delete a task."""
return self.db.delete_task(task_id)
def get_stats(self):
"""Get task statistics."""
return self.db.get_stats()
def export_tasks(self, filepath="tasks_export.json"):
"""Export all tasks to a JSON file."""
tasks = self.list_tasks()
data = [t.to_dict() for t in tasks]
path = Path(filepath)
with open(path, "w") as f:
json.dump(data, f, indent=2, default=str)
return path, len(data)
π Step 4 β API Integration (api.py)
Let's add a motivational feature β fetch a random inspirational quote when the user asks for one. This uses the skills from Lesson 10:
"""
api.py β External API integrations for TaskFlow.
"""
import requests
QUOTE_API_URL = "https://dummyjson.com/quotes/random"
TIMEOUT = 5 # seconds
def get_random_quote():
"""
Fetch a random inspirational quote.
Returns a dict with 'quote' and 'author', or None on failure.
"""
try:
response = requests.get(QUOTE_API_URL, timeout=TIMEOUT)
response.raise_for_status()
data = response.json()
return {
"quote": data.get("quote", "Keep going!"),
"author": data.get("author", "Unknown"),
}
except requests.exceptions.Timeout:
return {"quote": "Patience is a virtue.", "author": "Proverb"}
except requests.exceptions.RequestException:
return None
π‘ Graceful Degradation
The API call is a "nice to have" β if it fails, the app still works perfectly. That's graceful degradation. We provide a fallback quote on timeout and return None on other errors so the CLI can handle it. Never let a non-essential feature crash your application.
π₯οΈ Step 5 β The CLI Interface (cli.py)
The CLI is the user-facing layer. It handles menus, input parsing, output formatting, and delegates all logic to the manager:
"""
cli.py β Command-line interface for TaskFlow.
"""
from .manager import TaskManager
from .api import get_random_quote
COMMANDS = {
"add": "Add a new task",
"list": "List tasks (with optional filters)",
"view": "View a task's details",
"done": "Mark a task as complete",
"update": "Update a task's fields",
"delete": "Delete a task",
"search": "Search tasks by keyword",
"stats": "View productivity statistics",
"export": "Export tasks to JSON",
"quote": "Get an inspirational quote",
"help": "Show this help message",
"quit": "Exit TaskFlow",
}
class TaskFlowCLI:
"""Interactive command-line interface for TaskFlow."""
def __init__(self, db_path=None):
self.manager = TaskManager(db_path)
def run(self):
"""Main application loop."""
print("\nπ Welcome to TaskFlow!")
print("Type 'help' to see available commands.\n")
while True:
try:
command = input("taskflow> ").strip().lower()
except (KeyboardInterrupt, EOFError):
print("\nGoodbye! π")
break
if not command:
continue
method = getattr(self, f"cmd_{command}", None)
if method:
try:
method()
except Exception as e:
print(f"β Error: {e}")
else:
print(f"Unknown command: '{command}'. Type 'help' for options.")
# ββ Commands βββββββββββββββββββββββββββββββββββββββββββββ
def cmd_help(self):
"""Show available commands."""
print("\nπ Available Commands:")
for cmd, desc in COMMANDS.items():
print(f" {cmd:<10} {desc}")
print()
def cmd_add(self):
"""Add a new task."""
title = input(" Title: ").strip()
if not title:
print(" Title is required.")
return
description = input(" Description (optional): ").strip()
priority = input(" Priority [low/medium/high] (medium): ").strip().lower() or "medium"
category = input(" Category (general): ").strip() or "general"
due_date = input(" Due date YYYY-MM-DD (optional): ").strip() or None
task = self.manager.add_task(
title=title,
description=description,
priority=priority,
category=category,
due_date=due_date,
)
print(f" β
Created: {task}")
def cmd_list(self):
"""List tasks with optional filters."""
print(" Filter by (press Enter to skip):")
status = input(" Status [todo/in_progress/done]: ").strip().lower() or None
priority = input(" Priority [low/medium/high]: ").strip().lower() or None
category = input(" Category: ").strip() or None
tasks = self.manager.list_tasks(
status=status, priority=priority, category=category
)
if not tasks:
print("\n π No tasks found.")
return
print(f"\n π Tasks ({len(tasks)}):")
for task in tasks:
print(f" {task}")
print()
def cmd_view(self):
"""View detailed information about a task."""
try:
task_id = int(input(" Task ID: "))
except ValueError:
print(" Invalid ID.")
return
task = self.manager.get_task(task_id)
if not task:
print(f" Task {task_id} not found.")
return
overdue = " β οΈ OVERDUE" if task.is_overdue else ""
print(f"\n {'β' * 40}")
print(f" {task.priority_icon} {task.title}{overdue}")
print(f" {'β' * 40}")
print(f" ID: {task.id}")
print(f" Status: {task.status_icon} {task.status}")
print(f" Priority: {task.priority_icon} {task.priority}")
print(f" Category: {task.category}")
print(f" Due Date: {task.due_date or 'β'}")
print(f" Created: {task.created_at}")
if task.completed_at:
print(f" Completed: {task.completed_at}")
if task.description:
print(f" Description: {task.description}")
print(f" {'β' * 40}\n")
def cmd_done(self):
"""Mark a task as complete."""
try:
task_id = int(input(" Task ID: "))
except ValueError:
print(" Invalid ID.")
return
if self.manager.complete_task(task_id):
print(f" β
Task {task_id} marked as done!")
else:
print(f" Task {task_id} not found.")
def cmd_update(self):
"""Update a task's fields."""
try:
task_id = int(input(" Task ID: "))
except ValueError:
print(" Invalid ID.")
return
task = self.manager.get_task(task_id)
if not task:
print(f" Task {task_id} not found.")
return
print(f" Updating: {task.title}")
print(" (Press Enter to keep current value)\n")
fields = {}
title = input(f" Title [{task.title}]: ").strip()
if title:
fields["title"] = title
desc = input(f" Description [{task.description or 'β'}]: ").strip()
if desc:
fields["description"] = desc
priority = input(f" Priority [{task.priority}]: ").strip().lower()
if priority:
fields["priority"] = priority
category = input(f" Category [{task.category}]: ").strip()
if category:
fields["category"] = category
due = input(f" Due date [{task.due_date or 'β'}]: ").strip()
if due:
fields["due_date"] = due
if fields:
self.manager.update_task(task_id, **fields)
print(f" β
Task {task_id} updated.")
else:
print(" No changes made.")
def cmd_delete(self):
"""Delete a task."""
try:
task_id = int(input(" Task ID: "))
except ValueError:
print(" Invalid ID.")
return
task = self.manager.get_task(task_id)
if not task:
print(f" Task {task_id} not found.")
return
confirm = input(f" Delete '{task.title}'? (y/n): ").strip().lower()
if confirm == "y":
self.manager.delete_task(task_id)
print(f" ποΈ Task {task_id} deleted.")
else:
print(" Cancelled.")
def cmd_search(self):
"""Search tasks by keyword."""
keyword = input(" Search: ").strip()
if not keyword:
return
results = self.manager.search(keyword)
if not results:
print(f" π No tasks matching '{keyword}'.")
return
print(f"\n π Found {len(results)} result(s):")
for task in results:
print(f" {task}")
print()
def cmd_stats(self):
"""Show productivity statistics."""
stats = self.manager.get_stats()
print(f"\n π TaskFlow Statistics")
print(f" {'β' * 30}")
print(f" Total tasks: {stats['total']}")
if stats["by_status"]:
print(f"\n By Status:")
icons = {"todo": "β¬", "in_progress": "π", "done": "β
"}
for status, count in stats["by_status"].items():
print(f" {icons.get(status, ' ')} {status}: {count}")
if stats["by_priority"]:
print(f"\n By Priority:")
icons = {"high": "π΄", "medium": "π‘", "low": "π’"}
for priority, count in stats["by_priority"].items():
print(f" {icons.get(priority, ' ')} {priority}: {count}")
if stats["by_category"]:
print(f"\n By Category:")
for cat, count in stats["by_category"].items():
print(f" π {cat}: {count}")
# Completion rate
done = stats["by_status"].get("done", 0)
if stats["total"] > 0:
rate = (done / stats["total"]) * 100
print(f"\n π― Completion rate: {rate:.0f}%")
print()
def cmd_export(self):
"""Export tasks to JSON."""
filename = input(" Filename [tasks_export.json]: ").strip()
filename = filename or "tasks_export.json"
path, count = self.manager.export_tasks(filename)
print(f" π Exported {count} tasks to {path}")
def cmd_quote(self):
"""Display an inspirational quote."""
result = get_random_quote()
if result:
print(f'\n π¬ "{result["quote"]}"')
print(f" β {result['author']}\n")
else:
print(" Could not fetch a quote right now. Keep going anyway! πͺ")
def cmd_quit(self):
"""Exit the application."""
print("Goodbye! π")
raise SystemExit(0)
π§ Command Dispatch Pattern
Notice the getattr(self, f"cmd_{command}", None) pattern. Instead of a long if/elif chain, we dynamically look up a method named cmd_<command>. This means adding a new command is as simple as defining a new method β no need to modify the dispatch logic. This is a common pattern in CLI frameworks.
πͺ Step 6 β The Entry Point (__main__.py)
This tiny file makes the package runnable with python -m taskflow:
"""
__main__.py β Entry point for `python -m taskflow`.
"""
from .cli import TaskFlowCLI
def main():
app = TaskFlowCLI()
app.run()
if __name__ == "__main__":
main()
And the package's __init__.py:
"""TaskFlow β A CLI task manager built with Python."""
__version__ = "1.0.0"
β Running Your App
# From the outer taskflow/ directory
cd taskflow
pip install requests
python -m taskflow
π§ͺ Step 7 β Writing Tests
Tests verify that your code works correctly and keeps working as you make changes. Python's built-in unittest module provides everything we need.
tests/test_models.py
"""Tests for the Task model."""
import unittest
from taskflow.models import Task
class TestTask(unittest.TestCase):
"""Test Task creation and validation."""
def test_create_valid_task(self):
task = Task(title="Buy groceries")
self.assertEqual(task.title, "Buy groceries")
self.assertEqual(task.status, "todo")
self.assertEqual(task.priority, "medium")
def test_title_stripped(self):
task = Task(title=" Whitespace title ")
self.assertEqual(task.title, "Whitespace title")
def test_empty_title_raises(self):
with self.assertRaises(ValueError):
Task(title="")
def test_blank_title_raises(self):
with self.assertRaises(ValueError):
Task(title=" ")
def test_invalid_status_raises(self):
with self.assertRaises(ValueError):
Task(title="Test", status="invalid")
def test_invalid_priority_raises(self):
with self.assertRaises(ValueError):
Task(title="Test", priority="urgent")
def test_invalid_due_date_raises(self):
with self.assertRaises(ValueError):
Task(title="Test", due_date="tomorrow")
def test_valid_due_date(self):
task = Task(title="Test", due_date="2026-12-31")
self.assertEqual(task.due_date, "2026-12-31")
def test_priority_icon(self):
self.assertEqual(Task(title="T", priority="high").priority_icon, "π΄")
self.assertEqual(Task(title="T", priority="medium").priority_icon, "π‘")
self.assertEqual(Task(title="T", priority="low").priority_icon, "π’")
def test_status_icon(self):
self.assertEqual(Task(title="T", status="todo").status_icon, "β¬")
self.assertEqual(Task(title="T", status="done").status_icon, "β
")
def test_from_dict(self):
data = {"id": 1, "title": "Test", "status": "done", "priority": "high"}
task = Task.from_dict(data)
self.assertEqual(task.id, 1)
self.assertEqual(task.title, "Test")
self.assertEqual(task.status, "done")
def test_to_dict(self):
task = Task(title="Test", priority="low", id=5)
d = task.to_dict()
self.assertEqual(d["title"], "Test")
self.assertEqual(d["priority"], "low")
self.assertEqual(d["id"], 5)
def test_str_representation(self):
task = Task(title="Test", id=1, priority="high", status="todo")
result = str(task)
self.assertIn("Test", result)
self.assertIn("[1]", result)
if __name__ == "__main__":
unittest.main()
tests/test_database.py
"""Tests for the Database layer."""
import unittest
from pathlib import Path
from taskflow.database import Database
class TestDatabase(unittest.TestCase):
"""Test database CRUD operations using an in-memory database."""
def setUp(self):
"""Create a fresh in-memory database for each test."""
self.db = Database(db_path=Path(":memory:"))
def test_insert_and_get(self):
task_id = self.db.insert_task("Test task", priority="high")
task = self.db.get_task(task_id)
self.assertIsNotNone(task)
self.assertEqual(task["title"], "Test task")
self.assertEqual(task["priority"], "high")
self.assertEqual(task["status"], "todo")
def test_get_nonexistent(self):
result = self.db.get_task(999)
self.assertIsNone(result)
def test_get_all_empty(self):
tasks = self.db.get_all_tasks()
self.assertEqual(tasks, [])
def test_get_all_with_filter(self):
self.db.insert_task("Task A", priority="high")
self.db.insert_task("Task B", priority="low")
high = self.db.get_all_tasks(priority="high")
self.assertEqual(len(high), 1)
self.assertEqual(high[0]["title"], "Task A")
def test_search(self):
self.db.insert_task("Buy groceries")
self.db.insert_task("Clean house")
results = self.db.search_tasks("grocer")
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["title"], "Buy groceries")
def test_update(self):
task_id = self.db.insert_task("Old title")
updated = self.db.update_task(task_id, title="New title")
self.assertTrue(updated)
task = self.db.get_task(task_id)
self.assertEqual(task["title"], "New title")
def test_update_nonexistent(self):
result = self.db.update_task(999, title="Nope")
self.assertFalse(result)
def test_mark_complete(self):
task_id = self.db.insert_task("Finish project")
self.db.mark_complete(task_id)
task = self.db.get_task(task_id)
self.assertEqual(task["status"], "done")
self.assertIsNotNone(task["completed_at"])
def test_delete(self):
task_id = self.db.insert_task("Delete me")
deleted = self.db.delete_task(task_id)
self.assertTrue(deleted)
self.assertIsNone(self.db.get_task(task_id))
def test_delete_nonexistent(self):
result = self.db.delete_task(999)
self.assertFalse(result)
def test_stats(self):
self.db.insert_task("A", priority="high")
self.db.insert_task("B", priority="low")
self.db.insert_task("C", priority="high")
stats = self.db.get_stats()
self.assertEqual(stats["total"], 3)
self.assertEqual(stats["by_priority"]["high"], 2)
if __name__ == "__main__":
unittest.main()
tests/test_manager.py
"""Tests for the TaskManager business logic."""
import unittest
import tempfile
from pathlib import Path
from taskflow.manager import TaskManager
class TestTaskManager(unittest.TestCase):
"""Test the manager layer."""
def setUp(self):
"""Create a manager with a temporary database."""
self.tmp_dir = tempfile.mkdtemp()
self.db_path = Path(self.tmp_dir) / "test_tasks.db"
self.manager = TaskManager(db_path=self.db_path)
def test_add_and_get_task(self):
task = self.manager.add_task("Test", priority="high")
self.assertEqual(task.title, "Test")
self.assertEqual(task.priority, "high")
self.assertIsNotNone(task.id)
retrieved = self.manager.get_task(task.id)
self.assertEqual(retrieved.title, "Test")
def test_add_invalid_title(self):
with self.assertRaises(ValueError):
self.manager.add_task("")
def test_list_tasks(self):
self.manager.add_task("Task A", priority="high")
self.manager.add_task("Task B", priority="low")
tasks = self.manager.list_tasks()
self.assertEqual(len(tasks), 2)
def test_list_filtered(self):
self.manager.add_task("High task", priority="high")
self.manager.add_task("Low task", priority="low")
high = self.manager.list_tasks(priority="high")
self.assertEqual(len(high), 1)
self.assertEqual(high[0].title, "High task")
def test_complete_task(self):
task = self.manager.add_task("Finish this")
self.manager.complete_task(task.id)
updated = self.manager.get_task(task.id)
self.assertEqual(updated.status, "done")
def test_search(self):
self.manager.add_task("Buy groceries")
self.manager.add_task("Clean kitchen")
results = self.manager.search("groceries")
self.assertEqual(len(results), 1)
def test_delete_task(self):
task = self.manager.add_task("Delete me")
self.assertTrue(self.manager.delete_task(task.id))
self.assertIsNone(self.manager.get_task(task.id))
def test_export(self):
self.manager.add_task("Export test")
export_path = Path(self.tmp_dir) / "export.json"
path, count = self.manager.export_tasks(str(export_path))
self.assertEqual(count, 1)
self.assertTrue(path.exists())
def test_stats(self):
self.manager.add_task("A", category="work")
self.manager.add_task("B", category="work")
self.manager.add_task("C", category="personal")
stats = self.manager.get_stats()
self.assertEqual(stats["total"], 3)
self.assertEqual(stats["by_category"]["work"], 2)
if __name__ == "__main__":
unittest.main()
Running the Tests
# From the outer taskflow/ directory
python -m pytest tests/ -v
# or with unittest
python -m unittest discover tests/ -v
Expected Output:
tests/test_database.py::TestDatabase::test_delete β
tests/test_database.py::TestDatabase::test_delete_nonexistent β
tests/test_database.py::TestDatabase::test_get_all_empty β
tests/test_database.py::TestDatabase::test_get_all_with_filter β
tests/test_database.py::TestDatabase::test_get_nonexistent β
tests/test_database.py::TestDatabase::test_insert_and_get β
tests/test_database.py::TestDatabase::test_mark_complete β
tests/test_database.py::TestDatabase::test_search β
tests/test_database.py::TestDatabase::test_stats β
tests/test_database.py::TestDatabase::test_update β
tests/test_database.py::TestDatabase::test_update_nonexistent β
tests/test_models.py::TestTask::test_blank_title_raises β
tests/test_models.py::TestTask::test_create_valid_task β
...
======================== 30+ passed ========================
14 tests"] --> M["π¦ models.py"] TD["π§ͺ test_database.py
11 tests"] --> D["ποΈ database.py"] TMG["π§ͺ test_manager.py
9 tests"] --> MG["βοΈ manager.py"] MG --> D MG --> M style TM fill:#f0fdf4,stroke:#22c55e,color:#1e293b style TD fill:#f0fdf4,stroke:#22c55e,color:#1e293b style TMG fill:#f0fdf4,stroke:#22c55e,color:#1e293b style M fill:#faf5ff,stroke:#a855f7,color:#1e293b style D fill:#fefce8,stroke:#f59e0b,color:#1e293b style MG fill:#eff6ff,stroke:#3b82f6,color:#1e293b
π§ Testing Best Practices
setUp()runs before every test, giving each test a fresh database β tests never interfere with each other- In-memory databases (
:memory:) are fast and leave no files behind - Test one thing per method β each test has a clear name and checks one behavior
- Test happy paths AND edge cases β valid data, empty data, not-found, duplicates
- We test models, database, and manager independently β that's the payoff of layered architecture
βΆοΈ Running the Project
Setup
# 1. Navigate to the project
cd taskflow
# 2. Create virtual environment
python -m venv .venv
source .venv/bin/activate # Linux/Mac
# .venv\Scripts\activate # Windows
# 3. Install dependencies
pip install requests
# 4. Run the app
python -m taskflow
# 5. Run tests
python -m unittest discover tests/ -v
Sample Session:
π Welcome to TaskFlow!
Type 'help' to see available commands.
taskflow> add
Title: Build portfolio website
Description (optional): React + TypeScript + Tailwind
Priority [low/medium/high] (medium): high
Category (general): projects
Due date YYYY-MM-DD (optional): 2026-05-01
β
Created: β¬ π΄ [1] Build portfolio website (due 2026-05-01)
taskflow> add
Title: Buy groceries
Description (optional):
Priority [low/medium/high] (medium):
Category (general): personal
Due date YYYY-MM-DD (optional):
β
Created: β¬ π‘ [2] Buy groceries
taskflow> add
Title: Read Python docs on asyncio
Description (optional): Chapter on coroutines
Priority [low/medium/high] (medium): low
Category (general): learning
Due date YYYY-MM-DD (optional):
β
Created: β¬ π’ [3] Read Python docs on asyncio
taskflow> list
Filter by (press Enter to skip):
Status [todo/in_progress/done]:
Priority [low/medium/high]:
Category:
π Tasks (3):
β¬ π΄ [1] Build portfolio website (due 2026-05-01)
β¬ π‘ [2] Buy groceries
β¬ π’ [3] Read Python docs on asyncio
taskflow> done
Task ID: 2
β
Task 2 marked as done!
taskflow> stats
π TaskFlow Statistics
ββββββββββββββββββββββββββββββ
Total tasks: 3
By Status:
β¬ todo: 2
β
done: 1
By Priority:
π΄ high: 1
π‘ medium: 1
π’ low: 1
By Category:
π projects: 1
π personal: 1
π learning: 1
π― Completion rate: 33%
taskflow> quote
π¬ "The only way to do great work is to love what you do."
β Steve Jobs
taskflow> quit
Goodbye! π
π Extension Challenges
You've built a complete, working application. Want to push further? Here are challenges to try on your own:
π₯ Challenge 1: Due Date Reminders
Add a reminders command that shows tasks due within the next 3 days (or overdue). Use datetime to compare dates and highlight urgent items.
π₯ Challenge 2: Tags System
Add a tags table with a many-to-many relationship to tasks (via a task_tags junction table). Allow adding/removing tags and filtering tasks by tag.
π₯ Challenge 3: Import from JSON
Add an import command that reads a JSON file and bulk-inserts tasks. Validate each task before inserting, and report how many succeeded vs. failed.
π₯ Challenge 4: argparse Interface
Add an alternative non-interactive interface using argparse so users can run commands directly from the terminal:
python -m taskflow add "Buy groceries" --priority high --due 2026-04-20
python -m taskflow list --status todo
python -m taskflow done 3
π₯ Challenge 5: Rich Terminal Output
Install the rich library and replace plain print() output with beautiful tables, panels, and colored text. The rich library has a Table class that produces gorgeous terminal tables with almost no effort.
π Course Wrap-Up
π Congratulations!
You've completed the Intermediate Python course!
Let's take a moment to appreciate everything you've learned across 12 lessons:
What You've Mastered
Classes & Objects"] L2["Lesson 2
Inheritance"] L3["Lesson 3
Magic Methods"] end subgraph M2["π Module 2: Data"] L4["Lesson 4
File I/O"] L5["Lesson 5
Error Handling"] L6["Lesson 6
Regex"] end subgraph M3["β‘ Module 3: Pythonic"] L7["Lesson 7
Generators"] L8["Lesson 8
Itertools"] L9["Lesson 9
Environments"] end subgraph M4["π Module 4: Real-World"] L10["Lesson 10
APIs"] L11["Lesson 11
SQLite"] L12["Lesson 12
Capstone π"] end M1 --> M2 --> M3 --> M4 style L12 fill:#fef3c7,stroke:#f59e0b,color:#1e293b
| Module | Key Skills |
|---|---|
| OOP | Classes, inheritance, polymorphism, magic methods, operator overloading, dataclasses |
| Working with Data | File I/O, context managers, custom exceptions, logging, regular expressions |
| Pythonic Code | Comprehensions, generators, itertools, functools, decorators, virtual environments, project structure |
| Real-World | REST APIs, HTTP, JSON, authentication, SQLite, SQL, parameterized queries, testing, layered architecture |
Where to Go Next
You're now solidly intermediate. Here are paths to continue your Python journey:
- Web Development β Learn Flask or Django to build web applications with the OOP, SQL, and API skills you already have
- Data Science β Pick up pandas, NumPy, and matplotlib to analyze data with the file I/O and SQL foundations from this course
- Async Python β Explore
asyncioandaiohttpfor high-performance concurrent applications - Advanced Testing β Learn pytest, mocking, fixtures, and test-driven development (TDD)
- CLI Frameworks β Upgrade from
input()toclickortyperfor professional CLIs - Type Checking β Add
mypyto catch bugs before they happen with static type checking - Packaging β Learn to publish your own packages to PyPI with
pyproject.tomlandbuild
π Recommended Resources
- Python Official Documentation
- Real Python β Tutorials & Articles
- Python Morsels β Weekly Exercises
- Exercism Python Track
- The Algorithms β Python
π Keep Building!
The best way to cement these skills is to build something. Pick a project you're excited about and start coding. You have the tools β now go create.
π§ͺ Knowledge Check
Final quiz β test your understanding of the capstone concepts:
Question 1
In TaskFlow's layered architecture, which layer should contain raw SQL queries?
Question 2
Why does the Database class accept a db_path parameter instead of hardcoding the database file?
Question 3
What is the purpose of the setUp() method in a unittest.TestCase class?