Proyecto completo
Del diseño a la implementación
Crear un proyecto completo en Python
¡Enhorabuena! Has recorrido todos los tutoriales anteriores y has adquirido una sólida base de conocimientos en Python. Ahora es el momento de poner en práctica todo lo que has aprendido desarrollando un proyecto completo de principio a fin.
En este tutorial vamos a construir una aplicación de gestión de tareas (todo list) que utilizará una interfaz de línea de comandos. Este proyecto integrará la mayoría de los conceptos que hemos abordado en esta serie de tutoriales, entre ellos:
- La programación orientada a objetos
- La gestión de excepciones
- La manipulación de archivos JSON
- Las estructuras de control
- Las bibliotecas estándar de Python
- Las buenas prácticas de desarrollo
Preparación del proyecto
Estructura del proyecto
Empecemos por crear una estructura de proyecto limpia y organizada:
todo_app/
│
├── todo/
│ ├── __init__.py
│ ├── models.py # Clases de datos (Task, TaskList)
│ ├── storage.py # Gestión del almacenamiento (JSON)
│ ├── commands.py # Implementación de los comandos
│ └── cli.py # Interfaz de línea de comandos
│
├── tests/ # Tests unitarios
│ ├── __init__.py
│ ├── test_models.py
│ └── test_storage.py
│
├── data/ # Carpeta para almacenar los datos
│ └── tasks.json
│
├── requirements.txt # Dependencias del proyecto
├── setup.py # Configuración de instalación
└── README.md # Documentación del proyecto
Configuración del entorno virtual
Creemos un entorno virtual para nuestro proyecto:
# Creación del entorno virtual
python -m venv venv
# Activación del entorno virtual
# En Windows
venv\Scripts\activate
# En macOS/Linux
source venv/bin/activate
# Instalación de las dependencias
pip install -e .
Definición de las dependencias
Creemos un archivo requirements.txt con las dependencias necesarias:
# requirements.txt
colorama>=0.4.4 # Para colorear la salida en el terminal
pytest>=6.2.5 # Para los tests unitarios
Y un archivo setup.py para que nuestro paquete sea instalable:
# 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",
],
},
)
Desarrollo del modelo de datos
Empecemos por definir nuestras clases de modelo en 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 que representa los estados posibles de una tarea."""
TODO = auto()
IN_PROGRESS = auto()
DONE = auto()
def __str__(self) -> str:
return self.name.replace("_", " ").title()
class Task:
"""Clase que representa una tarea."""
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:
"""Actualiza los atributos de la tarea."""
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]:
"""Convierte la tarea en un diccionario para la serialización."""
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':
"""Crea una instancia de Task a partir de un diccionario."""
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:
"""Clase que representa una lista de tareas."""
def __init__(self):
self.tasks: Dict[str, Task] = {}
def add_task(self, task: Task) -> None:
"""Añade una tarea a la lista."""
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:
"""Recupera una tarea por su 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:
"""Actualiza una tarea existente."""
task = self.get_task(task_id)
task.update(**kwargs)
return task
def delete_task(self, task_id: str) -> None:
"""Elimina una tarea de la lista."""
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]:
"""Filtra las tareas por estado y/o etiqueta."""
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]:
"""Convierte la lista de tareas en un diccionario para la serialización."""
return {
"tasks": [task.to_dict() for task in self.tasks.values()]
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'TaskList':
"""Crea una instancia de TaskList a partir de un diccionario."""
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
Gestión del almacenamiento
Ahora creemos el módulo de almacenamiento para persistir nuestras tareas en JSON:
# todo/storage.py
import json
import os
from pathlib import Path
from typing import Optional
from .models import TaskList
class StorageError(Exception):
"""Excepción lanzada ante errores de almacenamiento."""
pass
class Storage:
"""Clase que gestiona el almacenamiento de las tareas en JSON."""
def __init__(self, file_path: Optional[str] = None):
if file_path:
self.file_path = Path(file_path)
else:
# Ruta por defecto
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:
"""Guarda la lista de tareas en el archivo JSON."""
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:
"""Carga la lista de tareas desde el archivo JSON."""
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}")
Implementación de los comandos
Creemos el módulo de comandos para gestionar las operaciones sobre las tareas:
# 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):
"""Excepción lanzada ante errores de comando."""
pass
class TodoCommands:
"""Clase que implementa los comandos de la aplicación."""
def __init__(self, storage: Optional[Storage] = None):
self.storage = storage or Storage()
try:
self.task_list = self.storage.load()
except StorageError as e:
# En caso de error, se empieza con una lista vacía
print(f"Attention : {e}")
self.task_list = TaskList()
def _save(self) -> None:
"""Guarda el estado actual de las tareas."""
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:
"""Añade una nueva tarea."""
# Validación de las entradas
if not title:
raise CommandError("Le titre ne peut pas être vide")
# Conversión de la fecha de vencimiento
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")
# Procesamiento de las etiquetas
tag_list = []
if tags:
tag_list = [tag.strip() for tag in tags.split(',')]
# Creación y adición de la tarea
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]:
"""Lista las tareas con filtrado opcional."""
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:
"""Actualiza una tarea existente."""
# Tratamiento especial para determinados campos
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:
"""Elimina una tarea."""
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:
"""Recupera los detalles de una tarea concreta."""
try:
return self.task_list.get_task(task_id)
except KeyError:
raise CommandError(f"Aucune tâche trouvée avec l'ID {task_id}")
Interfaz de línea de comandos
Por último, creemos la interfaz de línea de comandos en 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 le parser d'arguments."""
parser = argparse.ArgumentParser(
description="Application de gestion de tâches en ligne de commande"
)
subparsers = parser.add_subparsers(dest="command", help="Commandes disponibles")
# Commande "add"
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")
# Commande "list"
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")
# Commande "update"
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")
# Commande "get"
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")
# Commande "delete"
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):
"""Formate une tâche pour l'affichage."""
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}"
# Affichage détaillé
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():
"""Point d'entrée principal du programme."""
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: # Autoriser les descriptions vides
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: # Autoriser les listes de tags vides
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()
Creación del archivo de inicialización
Para terminar, creemos el archivo todo/__init__.py para que nuestro paquete sea utilizable:
# todo/__init__.py
"""
TodoApp - Une application de gestion de tâches en ligne de commande
"""
__version__ = "0.1.0"
Uso de la aplicación
Ahora que nuestra aplicación está creada, veamos cómo utilizarla:
# Installer l'application en mode développement
pip install -e .
# Ajouter une nouvelle tâche
todo add "Implémenter la fonctionnalité X" --description "Cette tâche consiste à..." --due "31/12/2023" --tags "dev,urgent"
# Lister toutes les tâches
todo list
# Lister les tâches avec un statut spécifique
todo list --status TODO
# Lister les tâches avec un tag spécifique
todo list --tag urgent
# Afficher les détails d'une tâche
todo get abc123 # Remplacer par un ID réel
# Mettre à jour une tâche
todo update abc123 --status IN_PROGRESS
# Marquer une tâche comme terminée
todo update abc123 --status DONE
# Supprimer une tâche
todo delete abc123
Pruebas de la aplicación
Para garantizar la calidad de nuestra aplicación, creemos una prueba unitaria sencilla para la clase Task en 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 de la création d'une tâche avec des valeurs par défaut."""
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 de la création d'une tâche avec tous les champs spécifiés."""
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 de la mise à jour d'une tâche."""
task = Task("Initial Title")
old_updated_at = task.updated_at
# Petite pause pour garantir que updated_at sera différent
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 de la sérialisation et désérialisation d'une tâche."""
original_task = Task(
title="Serialization Test",
description="Testing serialization",
tags=["test", "serialization"]
)
# Convertir en dict puis recréer la tâche
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()
Para ejecutar las pruebas:
python -m pytest tests/
Conclusión
¡Enhorabuena! Has creado una aplicación Python completa que integra numerosos conceptos importantes:
- Estructura de proyecto: organización modular con separación de responsabilidades.
- POO: uso de clases para representar los datos y el comportamiento.
- Gestión de excepciones: captura y tratamiento adecuados de los errores.
- Persistencia de datos: almacenamiento de las tareas en formato JSON.
- Interfaz de línea de comandos: API de usuario clara y coherente.
- Pruebas: verificación del correcto funcionamiento de los componentes.
Este proyecto te ha permitido poner en práctica las competencias adquiridas a lo largo de esta serie de tutoriales. Ahora puedes enriquecerlo añadiendo nuevas funcionalidades, como:
- Una interfaz gráfica con una biblioteca como Tkinter o PyQt
- Una API web con Flask o FastAPI
- Una sincronización con servicios cloud
- Recordatorios para las tareas cuya fecha límite se acerca
- Estadísticas sobre las tareas completadas
No dudes en explorar estas ampliaciones para seguir desarrollando tus habilidades en Python.
Esta serie de tutoriales llega a su fin, pero tu viaje con Python no ha hecho más que empezar. Sigue practicando, explorando y creando proyectos que te apasionen. ¡Feliz programación!
Comentarios
Los comentarios funcionan con GitHub Discussions
Inicia sesión con GitHub para participar en la discusión