10xdev Book: Programming from Zero to Pro with Python
Chapter 14: Final Project: Building a To-Do List App with Modern Python
Chapter Introduction
This is it – the grand finale! You’ve journeyed through the essentials of Python 3.14, from basic syntax and data types to functions, OOP, error handling, file I/O with pathlib, and modern project management with uv. You’ve collected all the tools. Now, it’s time to put them all together and build something tangible, something real. 🚀
This chapter is your capstone project: a command-line To-Do List application. We’ll apply everything you’ve learned – OOP for modeling tasks, pathlib and json for saving data, modules for organization, and uv for managing the environment. Building this won’t just solidify your skills; it’ll show you the thought process behind constructing a complete application, turning concepts into a working tool.
Let’s build your first full Python 3.14 project!
1. Planning the Project: Think First, Code Later 🧠
Before writing a single line of code, good developers plan. It saves countless hours later. What features do we need? How will we organize the files?
A. Application Features
Our To-Do app should let the user:
- View all tasks.
- Add a new task.
- Mark a task as done.
- Delete a task.
- Save tasks persistently (so they aren’t lost when the app closes).
- Exit the app.
B. Project Structure (Separation of Concerns)
We’ll follow the principle of Separation of Concerns, splitting our code based on responsibility, similar to the scalable structure discussed previously.
todo\_project/
│
├── .venv/ \# Virtual environment (managed by uv)
├── task.py \# Defines the 'Task' object (OOP Model)
├── storage.py \# Handles saving/loading tasks (Persistence Layer)
├── main.py \# User interface and main logic (Application Layer)
├── pyproject.toml \# Project config and dependencies (managed by uv)
└── uv.lock \# Exact dependency versions (managed by uv)
task.py: The blueprint for a single task using a class.storage.py: Functions to read/write the task list to a JSON file usingpathlib.main.py: The main script the user runs, handling input/output and orchestrating calls to the other modules.
2. Building the Task “Blueprint” (task.py)
This module defines what a task is. Create task.py:
# File: task.py
import datetime
from typing import Self # For type hinting __str__ method return
class Task:
"""Represents a single task with title, status, and creation time."""
def __init__(self, title: str, is_done: bool = False):
"""Initializes a new task."""
if not title: # Basic validation
raise ValueError("Task title cannot be empty.")
self.title: str = title
self.is_done: bool = is_done
self.created_at: datetime.datetime = datetime.datetime.now()
def mark_as_done(self):
"""Marks the task as completed."""
self.is_done = True
def __str__(self) -> str:
"""Returns a user-friendly string representation of the task."""
status_icon = "✅" if self.is_done else "🔘"
# Use a simpler date format
formatted_date = self.created_at.strftime("%Y-%m-%d")
return f"[{status_icon}] {self.title} (Added: {formatted_date})"
# Optional: For debugging or more detailed representation
def __repr__(self) -> str:
return f"Task(title='{self.title}', is_done={self.is_done}, created_at='{self.created_at.isoformat()}')"
Key Points:
- Uses OOP to encapsulate task data and behavior.
- Includes type hints for clarity.
__init__sets initial state, including the creation timestamp.mark_as_donemodifies the object’s state.__str__provides a nice representation forprint().
3. Building the Storage Unit (storage.py)
This module handles saving our list of Task objects to a file and loading them back. It uses json for serialization and pathlib for robust path handling. Create storage.py:
# File: storage.py
import json
import datetime
from pathlib import Path
from typing import List, Dict, Any # For type hints
from task import Task # Import our Task class
# Define storage file path using pathlib
STORAGE_FILENAME = "tasks.json"
STORAGE_PATH = Path(STORAGE_FILENAME)
# Type alias for clarity
TaskDict = Dict[str, Any]
TaskList = List[Task]
def save_tasks(tasks: TaskList):
"""Serializes a list of Task objects to a JSON file."""
list_to_save: List[TaskDict] = []
for task_obj in tasks:
task_dict: TaskDict = {
"title": task_obj.title,
"is_done": task_obj.is_done,
"created_at": task_obj.created_at.isoformat() # Convert datetime to string
}
list_to_save.append(task_dict)
try:
# Use Path object's write_text method with json.dumps for more control
json_string = json.dumps(list_to_save, indent=4, ensure_ascii=False)
STORAGE_PATH.write_text(json_string, encoding='utf-8')
except IOError as e:
print(f"Error: Could not save tasks to {STORAGE_PATH}: {e}")
except TypeError as e:
print(f"Error: Could not serialize tasks: {e}")
def load_tasks() -> TaskList:
"""Deserializes tasks from the JSON file into a list of Task objects."""
if not STORAGE_PATH.exists():
return [] # Return empty list if file doesn't exist
try:
json_string = STORAGE_PATH.read_text(encoding='utf-8')
# Handle empty file case
if not json_string.strip():
return []
list_of_dicts: List[TaskDict] = json.loads(json_string)
loaded_tasks: TaskList = []
for task_dict in list_of_dicts:
try:
# Recreate Task object
task_obj = Task(task_dict['title'], task_dict.get('is_done', False)) # Use .get for safety
# Convert date string back to datetime object
task_obj.created_at = datetime.datetime.fromisoformat(task_dict['created_at'])
loaded_tasks.append(task_obj)
except (KeyError, ValueError, TypeError) as e:
print(f"Warning: Skipping invalid task data: {task_dict}. Error: {e}")
return loaded_tasks
except (json.JSONDecodeError, IOError) as e:
print(f"Error: Could not load or parse tasks from {STORAGE_PATH}: {e}")
print("Starting with an empty task list.")
return [] # Return empty list on error
Key Points:
- Uses
pathlib.Pathfor reliable file path handling. - Converts
Taskobjects to dictionaries for JSON serialization (saving). - Converts dictionaries back to
Taskobjects during deserialization (loading). - Handles
datetimeconversion to/from ISO format strings. - Includes robust
try...exceptblocks for file errors and JSON errors.
4. Building the Main Interface (main.py)
This is the user-facing script. It loads tasks, presents a menu, takes input, calls the appropriate functions/methods, and saves changes. Create main.py:
# File: main.py
from storage import save_tasks, load_tasks
from task import Task
from typing import List
# Type alias for the list of tasks
TaskList = List[Task]
def display_menu():
"""Prints the main menu options."""
print("\n--- Python To-Do App ---")
print("1. View Tasks")
print("2. Add Task")
print("3. Mark Task Done")
print("4. Delete Task")
print("5. Exit")
def display_tasks(tasks: TaskList):
"""Displays all tasks, numbered."""
print("\n--- Your Tasks ---")
if not tasks:
print("No tasks yet. Add one!")
else:
for i, task in enumerate(tasks, start=1):
print(f"{i}. {task}") # Relies on Task.__str__
def get_task_index(prompt: str, max_index: int) -> int | None:
"""Safely gets a valid 1-based task index from the user."""
while True:
try:
num_str = input(prompt).strip()
if not num_str: # Handle empty input
return None # Allow cancelling
task_num = int(num_str)
if 1 <= task_num <= max_index:
return task_num - 1 # Convert to 0-based index
else:
print(f"Invalid number. Please enter between 1 and {max_index}.")
except ValueError:
print("Invalid input. Please enter a number.")
def main():
"""Main function to run the application."""
tasks: TaskList = load_tasks() # Load tasks at start
while True:
display_menu()
choice = input("Enter choice (1-5): ").strip()
if choice == '1': # View
display_tasks(tasks)
elif choice == '2': # Add
title = input("Enter new task title: ").strip()
if title:
try:
tasks.append(Task(title))
save_tasks(tasks)
print("Task added.")
except ValueError as e: # Catch title validation error from Task.__init__
print(f"Error: {e}")
else:
print("Task title cannot be empty.")
elif choice == '3': # Mark Done
display_tasks(tasks)
if tasks:
index = get_task_index("Enter number of task to mark done (or Enter to cancel): ", len(tasks))
if index is not None:
tasks[index].mark_as_done()
save_tasks(tasks)
print("Task marked as done.")
else:
print("No tasks to mark.")
elif choice == '4': # Delete
display_tasks(tasks)
if tasks:
index = get_task_index("Enter number of task to delete (or Enter to cancel): ", len(tasks))
if index is not None:
removed_task = tasks.pop(index)
save_tasks(tasks)
print(f"Task '{removed_task.title}' deleted.")
else:
print("No tasks to delete.")
elif choice == '5': # Exit
print("Saving tasks and exiting. Goodbye!")
save_tasks(tasks) # Ensure saving on exit too
break
else:
print("Invalid choice. Please enter a number between 1 and 5.")
# --- Run the main application ---
if __name__ == "__main__":
# Setup uv environment first if needed:
# uv init . (if first time)
# uv sync (to install dependencies if any were added)
main()
Key Points:
- Separates UI logic (display functions, input handling) from core task/storage logic.
- Uses a main
whileloop to keep the application running. - Calls
load_tasks()at the start andsave_tasks()after every modification to ensure persistence. - Includes input validation and uses
try...exceptfor converting user input to numbers. - Uses helper functions (
display_menu,display_tasks,get_task_index) for better organization.
5. Running Your Application & Book Conclusion 🎓
You’re ready!
- Set up Environment (if not done): Open your terminal in the
todo_projectfolder.# Only need to run init once per project # uv init . # Install any dependencies if you added external ones (we didn't here) # uv sync - Run the App:
uv run main.py(Or just
python main.pyif your environment is manually activated).
Play around with your new To-Do list! Add items, mark them done, delete them. Close the app and restart it – your tasks are saved in tasks.json!
Congratulations! You’ve successfully built a complete Python application, integrating all the major concepts from this book – from basic syntax and types, through control flow, functions, data structures, robust error handling, OOP, file persistence with pathlib, and modern project organization practices suitable for Python 3.14+.
This project is more than just a To-Do list; it’s a demonstration of your ability to think like a programmer – to plan, structure, code, and persist data. This foundation prepares you for tackling much larger and more complex challenges.
Where to go from here? The possibilities are endless:
- Explore web frameworks (Flask, Django).
- Dive into data science (Pandas, NumPy, Scikit-learn).
- Build GUIs (Tkinter, PyQt, Kivy).
- Work with databases (SQLAlchemy).
- Experiment with AI/ML libraries (TensorFlow, PyTorch).
The journey of learning programming never truly ends, but you’ve built a fantastic launchpad. Keep coding, keep building, and keep solving problems. Best of luck!