Complete project
From design to implementation
Build a Complete Python Project
Congratulations! You’ve worked through all the previous tutorials and built a solid foundation in Python. Now it’s time to put everything you’ve learned into practice by building a complete project from start to finish.
In this tutorial, we’ll build a task management application (a to-do list) with a command-line interface. This project brings together most of the concepts we’ve covered in this tutorial series, including:
- Object-oriented programming
- Exception handling
- Working with JSON files
- Control structures
- Python’s standard libraries
- Development best practices
Setting Up the Project
Project Structure
Let’s start by creating a clean, well-organized project structure:
todo_app/
│
├── todo/
│ ├── __init__.py
│ ├── models.py # Data classes (Task, TaskList)
│ ├── storage.py # Storage management (JSON)
│ ├── commands.py # Command implementation
│ └── cli.py # Command-line interface
│
├── tests/ # Unit tests
│ ├── __init__.py
│ ├── test_models.py
│ └── test_storage.py
│
├── data/ # Folder for storing data
│ └── tasks.json
│
├── requirements.txt # Project dependencies
├── setup.py # Installation configuration
└── README.md # Project documentation
Setting Up the Virtual Environment
Let’s create a virtual environment for our project:
# Create the virtual environment
python -m venv venv
# Activate the virtual environment
# On Windows
venv\Scripts\activate
# On macOS/Linux
source venv/bin/activate
# Install dependencies
pip install -e .
Defining Dependencies
Let’s create a requirements.txt file with the necessary dependencies:
# requirements.txt
colorama>=0.4.4 # For terminal coloring
pytest>=6.2.5 # For unit tests
And a setup.py file to make our package installable:
# setup.py
from setuptools import setup, find_packages
setup(
name="todo-app",
version="0.1.0",
packages=find_packages(),
install_requires=[
"colorama>=0.4.4",
],
entry_points={
"console_scripts": [
"todo=todo.cli:main",
],
},
)
Developing the Data Model
Let’s start by defining our model classes in todo/models.py:
# todo/models.py
from datetime import datetime
from enum import Enum, auto
from typing import List, Optional, Dict, Any
import uuid
class TaskStatus(Enum):
"""Enum representing the possible statuses of a task."""
TODO = auto()
IN_PROGRESS = auto()
DONE = auto()
def __str__(self) -> str:
return self.name.replace("_", " ").title()
class Task:
"""Class representing a task."""
def __init__(self, title: str, description: str = "",
status: TaskStatus = TaskStatus.TODO, due_date: Optional[datetime] = None,
tags: Optional[List[str]] = None, task_id: Optional[str] = None):
self.title = title
self.description = description
self.status = status
self.created_at = datetime.now()
self.updated_at = self.created_at
self.due_date = due_date
self.tags = tags or []
self.id = task_id or str(uuid.uuid4())
def update(self, **kwargs) -> None:
"""Updates the task's attributes."""
for key, value in kwargs.items():
if hasattr(self, key) and key not in ["id", "created_at"]:
setattr(self, key, value)
self.updated_at = datetime.now()
def to_dict(self) -> Dict[str, Any]:
"""Converts the task to a dictionary for serialization."""
return {
"id": self.id,
"title": self.title,
"description": self.description,
"status": self.status.name,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"due_date": self.due_date.isoformat() if self.due_date else None,
"tags": self.tags
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Task':
"""Creates a Task instance from a dictionary."""
status = TaskStatus[data["status"]]
due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None
task = cls(
title=data["title"],
description=data["description"],
status=status,
due_date=due_date,
tags=data["tags"],
task_id=data["id"]
)
task.created_at = datetime.fromisoformat(data["created_at"])
task.updated_at = datetime.fromisoformat(data["updated_at"])
return task
def __str__(self) -> str:
due_str = f", échéance: {self.due_date.strftime('%d/%m/%Y')}" if self.due_date else ""
tags_str = f", tags: {', '.join(self.tags)}" if self.tags else ""
return f"{self.title} ({self.status}){due_str}{tags_str}"
class TaskList:
"""Class representing a list of tasks."""
def __init__(self):
self.tasks: Dict[str, Task] = {}
def add_task(self, task: Task) -> None:
"""Adds a task to the list."""
if task.id in self.tasks:
raise ValueError(f"Une tâche avec l'ID {task.id} existe déjà")
self.tasks[task.id] = task
def get_task(self, task_id: str) -> Task:
"""Retrieves a task by its ID."""
if task_id not in self.tasks:
raise KeyError(f"Aucune tâche trouvée avec l'ID {task_id}")
return self.tasks[task_id]
def update_task(self, task_id: str, **kwargs) -> Task:
"""Updates an existing task."""
task = self.get_task(task_id)
task.update(**kwargs)
return task
def delete_task(self, task_id: str) -> None:
"""Deletes a task from the list."""
if task_id not in self.tasks:
raise KeyError(f"Aucune tâche trouvée avec l'ID {task_id}")
del self.tasks[task_id]
def filter_tasks(self, status: Optional[TaskStatus] = None,
tag: Optional[str] = None) -> List[Task]:
"""Filters tasks by status and/or tag."""
filtered_tasks = list(self.tasks.values())
if status:
filtered_tasks = [task for task in filtered_tasks if task.status == status]
if tag:
filtered_tasks = [task for task in filtered_tasks if tag in task.tags]
return filtered_tasks
def to_dict(self) -> Dict[str, Any]:
"""Converts the task list to a dictionary for serialization."""
return {
"tasks": [task.to_dict() for task in self.tasks.values()]
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'TaskList':
"""Creates a TaskList instance from a dictionary."""
task_list = cls()
for task_data in data.get("tasks", []):
task = Task.from_dict(task_data)
task_list.tasks[task.id] = task
return task_list
Storage Management
Now let’s create the storage module to persist our tasks as JSON:
# todo/storage.py
import json
import os
from pathlib import Path
from typing import Optional
from .models import TaskList
class StorageError(Exception):
"""Exception raised on storage errors."""
pass
class Storage:
"""Class that manages task storage in JSON."""
def __init__(self, file_path: Optional[str] = None):
if file_path:
self.file_path = Path(file_path)
else:
# Default path
data_dir = Path(__file__).parent.parent / "data"
data_dir.mkdir(exist_ok=True)
self.file_path = data_dir / "tasks.json"
def save(self, task_list: TaskList) -> None:
"""Saves the task list to the JSON file."""
try:
with open(self.file_path, 'w', encoding='utf-8') as f:
json.dump(task_list.to_dict(), f, indent=2, ensure_ascii=False)
except (IOError, PermissionError) as e:
raise StorageError(f"Erreur lors de la sauvegarde des tâches : {e}")
def load(self) -> TaskList:
"""Loads the task list from the JSON file."""
if not self.file_path.exists():
return TaskList()
try:
with open(self.file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return TaskList.from_dict(data)
except json.JSONDecodeError as e:
raise StorageError(f"Format JSON invalide : {e}")
except (IOError, PermissionError) as e:
raise StorageError(f"Erreur lors du chargement des tâches : {e}")
Implementing the Commands
Let’s create the commands module to handle operations on tasks:
# todo/commands.py
from datetime import datetime
from typing import List, Optional, Tuple
from .models import Task, TaskList, TaskStatus
from .storage import Storage, StorageError
class CommandError(Exception):
"""Exception raised on command errors."""
pass
class TodoCommands:
"""Class implementing the application's commands."""
def __init__(self, storage: Optional[Storage] = None):
self.storage = storage or Storage()
try:
self.task_list = self.storage.load()
except StorageError as e:
# On error, start with an empty list
print(f"Attention : {e}")
self.task_list = TaskList()
def _save(self) -> None:
"""Saves the current state of the tasks."""
try:
self.storage.save(self.task_list)
except StorageError as e:
raise CommandError(f"Erreur de sauvegarde : {e}")
def add(self, title: str, description: str = "",
due_date: Optional[str] = None, tags: Optional[str] = None) -> Task:
"""Adds a new task."""
# Input validation
if not title:
raise CommandError("Le titre ne peut pas être vide")
# Convert the due date
parsed_due_date = None
if due_date:
try:
parsed_due_date = datetime.strptime(due_date, "%d/%m/%Y")
except ValueError:
raise CommandError("Format de date invalide. Utilisez JJ/MM/AAAA")
# Process the tags
tag_list = []
if tags:
tag_list = [tag.strip() for tag in tags.split(',')]
# Create and add the task
new_task = Task(
title=title,
description=description,
due_date=parsed_due_date,
tags=tag_list
)
try:
self.task_list.add_task(new_task)
self._save()
return new_task
except ValueError as e:
raise CommandError(str(e))
def list(self, status: Optional[str] = None,
tag: Optional[str] = None) -> List[Task]:
"""Lists tasks with optional filtering."""
status_enum = None
if status:
try:
status_enum = TaskStatus[status.upper()]
except KeyError:
raise CommandError(f"Statut invalide : {status}")
return self.task_list.filter_tasks(status=status_enum, tag=tag)
def update(self, task_id: str, **kwargs) -> Task:
"""Updates an existing task."""
# Special handling for certain fields
if 'due_date' in kwargs and kwargs['due_date']:
try:
kwargs['due_date'] = datetime.strptime(kwargs['due_date'], "%d/%m/%Y")
except ValueError:
raise CommandError("Format de date invalide. Utilisez JJ/MM/AAAA")
if 'status' in kwargs and kwargs['status']:
try:
kwargs['status'] = TaskStatus[kwargs['status'].upper()]
except KeyError:
raise CommandError(f"Statut invalide : {kwargs['status']}")
if 'tags' in kwargs and kwargs['tags']:
kwargs['tags'] = [tag.strip() for tag in kwargs['tags'].split(',')]
try:
task = self.task_list.update_task(task_id, **kwargs)
self._save()
return task
except KeyError:
raise CommandError(f"Aucune tâche trouvée avec l'ID {task_id}")
def delete(self, task_id: str) -> None:
"""Deletes a task."""
try:
self.task_list.delete_task(task_id)
self._save()
except KeyError:
raise CommandError(f"Aucune tâche trouvée avec l'ID {task_id}")
def get(self, task_id: str) -> Task:
"""Retrieves the details of a specific task."""
try:
return self.task_list.get_task(task_id)
except KeyError:
raise CommandError(f"Aucune tâche trouvée avec l'ID {task_id}")
Command-Line Interface
Finally, let’s create the command-line interface in todo/cli.py:
# todo/cli.py
import argparse
import sys
from typing import List
from datetime import datetime
import colorama
from colorama import Fore, Style
from .commands import TodoCommands, CommandError
from .models import TaskStatus
def setup_argparse() -> argparse.ArgumentParser:
"""Configure the argument parser."""
parser = argparse.ArgumentParser(
description="Application de gestion de tâches en ligne de commande"
)
subparsers = parser.add_subparsers(dest="command", help="Commandes disponibles")
# "add" command
add_parser = subparsers.add_parser("add", help="Ajouter une nouvelle tâche")
add_parser.add_argument("title", help="Titre de la tâche")
add_parser.add_argument("-d", "--description", help="Description de la tâche")
add_parser.add_argument("--due", help="Date d'échéance (format JJ/MM/AAAA)")
add_parser.add_argument("--tags", help="Tags séparés par des virgules")
# "list" command
list_parser = subparsers.add_parser("list", help="Lister les tâches")
list_parser.add_argument("-s", "--status", help="Filtrer par statut (TODO, IN_PROGRESS, DONE)")
list_parser.add_argument("-t", "--tag", help="Filtrer par tag")
# "update" command
update_parser = subparsers.add_parser("update", help="Mettre à jour une tâche")
update_parser.add_argument("id", help="ID de la tâche à mettre à jour")
update_parser.add_argument("-t", "--title", help="Nouveau titre")
update_parser.add_argument("-d", "--description", help="Nouvelle description")
update_parser.add_argument("-s", "--status", help="Nouveau statut (TODO, IN_PROGRESS, DONE)")
update_parser.add_argument("--due", help="Nouvelle date d'échéance (format JJ/MM/AAAA)")
update_parser.add_argument("--tags", help="Nouveaux tags séparés par des virgules")
# "get" command
get_parser = subparsers.add_parser("get", help="Afficher les détails d'une tâche")
get_parser.add_argument("id", help="ID de la tâche")
# "delete" command
delete_parser = subparsers.add_parser("delete", help="Supprimer une tâche")
delete_parser.add_argument("id", help="ID de la tâche à supprimer")
return parser
def format_task_for_display(task, detailed=False):
"""Format a task for display."""
status_colors = {
TaskStatus.TODO: Fore.RED,
TaskStatus.IN_PROGRESS: Fore.YELLOW,
TaskStatus.DONE: Fore.GREEN
}
color = status_colors.get(task.status, Fore.WHITE)
if not detailed:
return f"{color}[{task.status}]{Style.RESET_ALL} {task.title} {Fore.BLUE}(ID: {task.id[:8]}){Style.RESET_ALL}"
# Detailed display
result = [
f"{Fore.CYAN}ID:{Style.RESET_ALL} {task.id}",
f"{Fore.CYAN}Titre:{Style.RESET_ALL} {task.title}",
f"{Fore.CYAN}Statut:{Style.RESET_ALL} {color}{task.status}{Style.RESET_ALL}",
f"{Fore.CYAN}Créée le:{Style.RESET_ALL} {task.created_at.strftime('%d/%m/%Y %H:%M')}",
f"{Fore.CYAN}Mise à jour:{Style.RESET_ALL} {task.updated_at.strftime('%d/%m/%Y %H:%M')}"
]
if task.description:
result.append(f"{Fore.CYAN}Description:{Style.RESET_ALL} {task.description}")
if task.due_date:
result.append(f"{Fore.CYAN}Échéance:{Style.RESET_ALL} {task.due_date.strftime('%d/%m/%Y')}")
if task.tags:
tags_str = ", ".join(f"{Fore.MAGENTA}{tag}{Style.RESET_ALL}" for tag in task.tags)
result.append(f"{Fore.CYAN}Tags:{Style.RESET_ALL} {tags_str}")
return "\n".join(result)
def main():
"""Main entry point of the program."""
colorama.init()
parser = setup_argparse()
args = parser.parse_args()
if not args.command:
parser.print_help()
return
commands = TodoCommands()
try:
if args.command == "add":
task = commands.add(
title=args.title,
description=args.description or "",
due_date=args.due,
tags=args.tags
)
print(f"{Fore.GREEN}Tâche ajoutée avec succès !{Style.RESET_ALL}")
print(format_task_for_display(task, detailed=True))
elif args.command == "list":
tasks = commands.list(
status=args.status,
tag=args.tag
)
if not tasks:
print(f"{Fore.YELLOW}Aucune tâche ne correspond aux critères.{Style.RESET_ALL}")
return
print(f"{Fore.CYAN}Tâches ({len(tasks)}):{Style.RESET_ALL}")
for i, task in enumerate(tasks, 1):
print(f"{i}. {format_task_for_display(task)}")
elif args.command == "update":
update_data = {}
if args.title:
update_data["title"] = args.title
if args.description is not None: # Allow empty descriptions
update_data["description"] = args.description
if args.status:
update_data["status"] = args.status
if args.due:
update_data["due_date"] = args.due
if args.tags is not None: # Allow empty tag lists
update_data["tags"] = args.tags
if not update_data:
print(f"{Fore.YELLOW}Aucune modification spécifiée.{Style.RESET_ALL}")
return
task = commands.update(args.id, **update_data)
print(f"{Fore.GREEN}Tâche mise à jour avec succès !{Style.RESET_ALL}")
print(format_task_for_display(task, detailed=True))
elif args.command == "get":
task = commands.get(args.id)
print(format_task_for_display(task, detailed=True))
elif args.command == "delete":
commands.delete(args.id)
print(f"{Fore.GREEN}Tâche supprimée avec succès !{Style.RESET_ALL}")
except CommandError as e:
print(f"{Fore.RED}Erreur : {e}{Style.RESET_ALL}")
sys.exit(1)
if __name__ == "__main__":
main()
Creating the Initialization File
Finally, let’s create the todo/__init__.py file to make our package usable:
# todo/__init__.py
"""
TodoApp - A command-line task management application
"""
__version__ = "0.1.0"
Using the Application
Now that our application is built, here is how to use it:
# Install the application in development mode
pip install -e .
# Add a new task
todo add "Implémenter la fonctionnalité X" --description "Cette tâche consiste à..." --due "31/12/2023" --tags "dev,urgent"
# List all tasks
todo list
# List tasks with a specific status
todo list --status TODO
# List tasks with a specific tag
todo list --tag urgent
# Show a task's details
todo get abc123 # Replace with a real ID
# Update a task
todo update abc123 --status IN_PROGRESS
# Mark a task as done
todo update abc123 --status DONE
# Delete a task
todo delete abc123
Testing the Application
To ensure the quality of our application, let’s create a simple unit test for the Task class in tests/test_models.py:
# tests/test_models.py
import unittest
from datetime import datetime
from todo.models import Task, TaskStatus
class TestTask(unittest.TestCase):
def test_task_creation(self):
"""Test creating a task with default values."""
task = Task("Test Task")
self.assertEqual(task.title, "Test Task")
self.assertEqual(task.description, "")
self.assertEqual(task.status, TaskStatus.TODO)
self.assertEqual(task.tags, [])
self.assertIsNone(task.due_date)
def test_task_with_all_fields(self):
"""Test creating a task with all fields specified."""
due_date = datetime(2023, 12, 31)
task = Task(
title="Test Task",
description="Test Description",
status=TaskStatus.IN_PROGRESS,
due_date=due_date,
tags=["test", "python"]
)
self.assertEqual(task.title, "Test Task")
self.assertEqual(task.description, "Test Description")
self.assertEqual(task.status, TaskStatus.IN_PROGRESS)
self.assertEqual(task.due_date, due_date)
self.assertEqual(task.tags, ["test", "python"])
def test_task_update(self):
"""Test updating a task."""
task = Task("Initial Title")
old_updated_at = task.updated_at
# Short pause to ensure updated_at will be different
import time
time.sleep(0.001)
task.update(
title="New Title",
description="New Description",
status=TaskStatus.DONE
)
self.assertEqual(task.title, "New Title")
self.assertEqual(task.description, "New Description")
self.assertEqual(task.status, TaskStatus.DONE)
self.assertNotEqual(task.updated_at, old_updated_at)
def test_to_dict_and_from_dict(self):
"""Test serializing and deserializing a task."""
original_task = Task(
title="Serialization Test",
description="Testing serialization",
tags=["test", "serialization"]
)
# Convert to dict, then recreate the task
task_dict = original_task.to_dict()
recreated_task = Task.from_dict(task_dict)
self.assertEqual(recreated_task.id, original_task.id)
self.assertEqual(recreated_task.title, original_task.title)
self.assertEqual(recreated_task.description, original_task.description)
self.assertEqual(recreated_task.status, original_task.status)
self.assertEqual(recreated_task.tags, original_task.tags)
if __name__ == "__main__":
unittest.main()
To run the tests:
python -m pytest tests/
Conclusion
Congratulations! You have built a complete Python application that brings together many important concepts:
- Project structure: A modular organization with a clear separation of responsibilities.
- OOP: Using classes to represent data and behavior.
- Exception handling: Properly catching and handling errors.
- Data persistence: Storing tasks in JSON format.
- Command-line interface: A clear and consistent user-facing API.
- Testing: Verifying that components work correctly.
This project gave you the chance to put into practice the skills you’ve acquired throughout this tutorial series. You can now extend it by adding new features, such as:
- A graphical interface using a library like Tkinter or PyQt
- A web API with Flask or FastAPI
- Synchronization with cloud services
- Reminders for tasks with approaching due dates
- Statistics on completed tasks
Feel free to explore these extensions to keep building your Python skills.
This tutorial series is coming to an end, but your journey with Python is just beginning. Keep practicing, exploring, and building projects you’re passionate about. Happy coding!
Comments
Comments are powered by GitHub Discussions
Sign in with GitHub to join the discussion