0%
Build a Complete Python Project

Complete project

From design to implementation

10-15 min

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:

  1. Project structure: A modular organization with a clear separation of responsibilities.
  2. OOP: Using classes to represent data and behavior.
  3. Exception handling: Properly catching and handling errors.
  4. Data persistence: Storing tasks in JSON format.
  5. Command-line interface: A clear and consistent user-facing API.
  6. 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

Link copied!