En esta página
Proyecto final: gestor de tareas CLI
Proyecto final: Gestor de tareas CLI
En este proyecto final integrarás todos los conceptos aprendidos a lo largo del curso para construir un gestor de tareas completo que se ejecuta desde la línea de comandos. El programa tendrá persistencia de datos en JSON, una arquitectura orientada a objetos con herencia, decoradores para logging, type hints completos y manejo robusto de errores.
Arquitectura del proyecto
Antes de escribir código, definamos la arquitectura. El gestor tendrá:
- Modelos de datos:
TareaBase(dataclass abstracta) yTarea(hereda deTareaBase) - Repositorio:
RepositorioTareas(persistencia JSON con pathlib) - Servicio:
ServicioTareas(lógica de negocio) - CLI:
GestorCLI(interfaz de línea de comandos con bucle REPL) - Decoradores:
registrar_accion,manejar_errores - Excepciones: jerarquía personalizada
Código completo del proyecto
#!/usr/bin/env python3.14
"""
Gestor de Tareas CLI — Proyecto final del curso Python Esencial
Autor: David Morales Vega
Versión: 1.0.0
"""
import json
import sys
import logging
import functools
from datetime import datetime
from pathlib import Path
from dataclasses import dataclass, field, asdict
from typing import Callable, TypeVar, Any
from enum import Enum
# ════════════════════════════════════════════════════════════
# Configuración de logging
# ════════════════════════════════════════════════════════════
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s][%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
handlers=[
logging.FileHandler("gestor.log", encoding="utf-8"),
# Solo mostrar WARNING+ en consola para no molestar al usuario
logging.StreamHandler(sys.stderr),
]
)
logging.getLogger().handlers[1].setLevel(logging.WARNING)
# ════════════════════════════════════════════════════════════
# Tipos y variables del módulo
# ════════════════════════════════════════════════════════════
F = TypeVar("F", bound=Callable[..., Any])
ARCHIVO_DATOS = Path.home() / ".gestor_tareas" / "tareas.json"
# ════════════════════════════════════════════════════════════
# Excepciones personalizadas
# ════════════════════════════════════════════════════════════
class GestorError(Exception):
"""Error base del gestor de tareas."""
pass
class TareaNoEncontrada(GestorError):
def __init__(self, tarea_id: int) -> None:
super().__init__(f"Tarea con ID {tarea_id} no encontrada")
self.tarea_id = tarea_id
class TareaYaCompletada(GestorError):
def __init__(self, tarea_id: int) -> None:
super().__init__(f"La tarea {tarea_id} ya está completada")
self.tarea_id = tarea_id
class DatosInvalidos(GestorError):
pass
# ════════════════════════════════════════════════════════════
# Decoradores
# ════════════════════════════════════════════════════════════
def registrar_accion(func: F) -> F:
"""Registra cada acción en el archivo de log."""
@functools.wraps(func)
def envoltura(*args: Any, **kwargs: Any) -> Any:
logging.info(f"INICIO → {func.__name__}")
try:
resultado = func(*args, **kwargs)
logging.info(f"FIN OK → {func.__name__}")
return resultado
except GestorError as e:
logging.warning(f"FIN WARN → {func.__name__}: {e}")
raise
except Exception as e:
logging.error(f"FIN ERROR → {func.__name__}: {e}")
raise
return envoltura # type: ignore[return-value]
def manejar_errores(func: F) -> F:
"""Captura errores del gestor y muestra mensajes amigables al usuario."""
@functools.wraps(func)
def envoltura(*args: Any, **kwargs: Any) -> Any:
try:
return func(*args, **kwargs)
except TareaNoEncontrada as e:
print(f"❌ {e}")
except TareaYaCompletada as e:
print(f"⚠️ {e}")
except DatosInvalidos as e:
print(f"❌ Datos inválidos: {e}")
except GestorError as e:
print(f"❌ Error: {e}")
return envoltura # type: ignore[return-value]
# ════════════════════════════════════════════════════════════
# Enum de prioridad
# ════════════════════════════════════════════════════════════
class Prioridad(Enum):
BAJA = 1
MEDIA = 2
ALTA = 3
CRÍTICA = 4
@property
def etiqueta(self) -> str:
colores = {
Prioridad.BAJA: "🔵",
Prioridad.MEDIA: "🟡",
Prioridad.ALTA: "🟠",
Prioridad.CRÍTICA: "🔴",
}
return f"{colores[self]} {self.name}"
@classmethod
def desde_numero(cls, n: int) -> "Prioridad":
try:
return cls(n)
except ValueError:
raise DatosInvalidos(f"Prioridad debe ser 1-4, recibido: {n}")
# ════════════════════════════════════════════════════════════
# Modelos de datos
# ════════════════════════════════════════════════════════════
@dataclass
class TareaBase:
"""Clase base con datos comunes de una tarea."""
titulo: str
descripción: str = ""
prioridad: Prioridad = Prioridad.MEDIA
etiquetas: list[str] = field(default_factory=list)
def __post_init__(self) -> None:
if not self.titulo.strip():
raise DatosInvalidos("El título no puede estar vacío")
self.titulo = self.titulo.strip()
@dataclass
class Tarea(TareaBase):
"""Tarea concreta con ID y estado de completado."""
id: int = 0
completada: bool = False
creada_en: str = field(default_factory=lambda: datetime.now().isoformat())
completada_en: str | None = None
def completar(self) -> None:
if self.completada:
raise TareaYaCompletada(self.id)
self.completada = True
self.completada_en = datetime.now().isoformat()
def to_dict(self) -> dict:
d = asdict(self)
d["prioridad"] = self.prioridad.value
return d
@classmethod
def from_dict(cls, datos: dict) -> "Tarea":
datos = datos.copy()
datos["prioridad"] = Prioridad(datos["prioridad"])
return cls(**datos)
def resumen(self, ancho: int = 60) -> str:
estado = "✓" if self.completada else "○"
titulo = self.titulo[:45] + "..." if len(self.titulo) > 45 else self.titulo
return f"[{estado}] #{self.id:3d} | {self.prioridad.etiqueta:12} | {titulo}"
@dataclass
class TareaConSubtareas(Tarea):
"""Extensión de Tarea que soporta subtareas."""
subtareas: list["Tarea"] = field(default_factory=list)
def agregar_subtarea(self, titulo: str) -> "Tarea":
subtarea = Tarea(
titulo=titulo,
id=len(self.subtareas) + 1,
prioridad=self.prioridad
)
self.subtareas.append(subtarea)
return subtarea
@property
def progreso(self) -> float:
if not self.subtareas:
return 1.0 if self.completada else 0.0
completadas = sum(1 for s in self.subtareas if s.completada)
return completadas / len(self.subtareas)
# ════════════════════════════════════════════════════════════
# Repositorio — Persistencia JSON
# ════════════════════════════════════════════════════════════
class RepositorioTareas:
"""Gestiona la persistencia de tareas en un archivo JSON."""
def __init__(self, ruta: Path = ARCHIVO_DATOS) -> None:
self._ruta = ruta
self._ruta.parent.mkdir(parents=True, exist_ok=True)
self._tareas: dict[int, Tarea] = {}
self._siguiente_id = 1
self._cargar()
def _cargar(self) -> None:
"""Carga las tareas desde el archivo JSON."""
if not self._ruta.exists():
return
try:
contenido = self._ruta.read_text(encoding="utf-8")
datos = json.loads(contenido)
self._siguiente_id = datos.get("siguiente_id", 1)
for tarea_dict in datos.get("tareas", []):
tarea = Tarea.from_dict(tarea_dict)
self._tareas[tarea.id] = tarea
except (json.JSONDecodeError, KeyError, TypeError) as e:
logging.error(f"Error al cargar tareas: {e}")
print("⚠️ El archivo de tareas está corrupto. Se iniciará desde cero.")
def guardar(self) -> None:
"""Guarda las tareas en el archivo JSON."""
datos = {
"versión": "1.0",
"actualizado_en": datetime.now().isoformat(),
"siguiente_id": self._siguiente_id,
"tareas": [t.to_dict() for t in self._tareas.values()]
}
try:
self._ruta.write_text(
json.dumps(datos, ensure_ascii=False, indent=2),
encoding="utf-8"
)
except OSError as e:
raise GestorError(f"No se pudo guardar el archivo: {e}") from e
def obtener(self, tarea_id: int) -> Tarea:
if tarea_id not in self._tareas:
raise TareaNoEncontrada(tarea_id)
return self._tareas[tarea_id]
def listar(self) -> list[Tarea]:
return list(self._tareas.values())
def crear(self, tarea: Tarea) -> Tarea:
tarea.id = self._siguiente_id
self._tareas[tarea.id] = tarea
self._siguiente_id += 1
self.guardar()
return tarea
def actualizar(self, tarea: Tarea) -> None:
if tarea.id not in self._tareas:
raise TareaNoEncontrada(tarea.id)
self._tareas[tarea.id] = tarea
self.guardar()
def eliminar(self, tarea_id: int) -> None:
if tarea_id not in self._tareas:
raise TareaNoEncontrada(tarea_id)
del self._tareas[tarea_id]
self.guardar()
# ════════════════════════════════════════════════════════════
# Servicio — Lógica de negocio
# ════════════════════════════════════════════════════════════
class ServicioTareas:
"""Lógica de negocio para la gestión de tareas."""
def __init__(self, repositorio: RepositorioTareas) -> None:
self._repo = repositorio
@registrar_accion
def crear_tarea(
self,
titulo: str,
descripción: str = "",
prioridad: int = 2,
etiquetas: list[str] | None = None
) -> Tarea:
tarea = Tarea(
titulo=titulo,
descripción=descripción,
prioridad=Prioridad.desde_numero(prioridad),
etiquetas=etiquetas or []
)
return self._repo.crear(tarea)
@registrar_accion
def completar_tarea(self, tarea_id: int) -> Tarea:
tarea = self._repo.obtener(tarea_id)
tarea.completar()
self._repo.actualizar(tarea)
return tarea
@registrar_accion
def eliminar_tarea(self, tarea_id: int) -> None:
self._repo.eliminar(tarea_id)
def listar_tareas(
self,
solo_pendientes: bool = False,
solo_completadas: bool = False,
etiqueta: str | None = None,
prioridad_min: int | None = None
) -> list[Tarea]:
tareas = self._repo.listar()
# Comprensiones con múltiples filtros
filtros: list[Callable[[Tarea], bool]] = []
if solo_pendientes:
filtros.append(lambda t: not t.completada)
if solo_completadas:
filtros.append(lambda t: t.completada)
if etiqueta:
filtros.append(lambda t: etiqueta in t.etiquetas)
if prioridad_min is not None:
filtros.append(lambda t: t.prioridad.value >= prioridad_min)
for filtro in filtros:
tareas = [t for t in tareas if filtro(t)]
return sorted(tareas, key=lambda t: (-t.prioridad.value, t.id))
def estadísticas(self) -> dict:
tareas = self._repo.listar()
if not tareas:
return {"total": 0}
completadas = [t for t in tareas if t.completada]
pendientes = [t for t in tareas if not t.completada]
por_prioridad = {
p.name: len([t for t in tareas if t.prioridad == p])
for p in Prioridad
}
return {
"total": len(tareas),
"completadas": len(completadas),
"pendientes": len(pendientes),
"porcentaje_completado": round(len(completadas) / len(tareas) * 100, 1),
"por_prioridad": por_prioridad,
"etiquetas_únicas": len({tag for t in tareas for tag in t.etiquetas}),
}
# ════════════════════════════════════════════════════════════
# CLI — Interfaz de línea de comandos
# ════════════════════════════════════════════════════════════
class GestorCLI:
"""Interfaz de usuario por línea de comandos (REPL)."""
AYUDA = """
╔══════════════════════════════════════════════╗
║ GESTOR DE TAREAS CLI v1.0.0 ║
╠══════════════════════════════════════════════╣
║ nueva — Crear nueva tarea ║
║ listar — Ver todas las tareas ║
║ pendiente — Ver solo tareas pendientes ║
║ completar — Marcar tarea como completada ║
║ eliminar — Eliminar una tarea ║
║ buscar — Buscar por etiqueta ║
║ stats — Ver estadísticas ║
║ ayuda — Mostrar este menú ║
║ salir — Salir del programa ║
╚══════════════════════════════════════════════╝
"""
def __init__(self, servicio: ServicioTareas) -> None:
self._svc = servicio
def ejecutar(self) -> None:
"""Inicia el bucle REPL de la interfaz."""
print(self.AYUDA)
while True:
try:
entrada = input("\n📋 gestor> ").strip().lower()
except (EOFError, KeyboardInterrupt):
print("\n\n👋 ¡Hasta pronto!")
break
if not entrada:
continue
match entrada:
case "nueva" | "n":
self._cmd_nueva()
case "listar" | "l":
self._cmd_listar()
case "pendiente" | "p":
self._cmd_pendientes()
case "completar" | "c":
self._cmd_completar()
case "eliminar" | "e":
self._cmd_eliminar()
case "buscar" | "b":
self._cmd_buscar()
case "stats" | "s":
self._cmd_estadísticas()
case "ayuda" | "h" | "?":
print(self.AYUDA)
case "salir" | "q" | "exit":
print("👋 ¡Hasta pronto!")
break
case _:
print(f"Comando desconocido: '{entrada}'. Escribe 'ayuda' para ver los comandos.")
@manejar_errores
def _cmd_nueva(self) -> None:
print("\n── Nueva tarea ──────────────────")
titulo = input("Título: ").strip()
descripción = input("Descripción (opcional): ").strip()
print("Prioridad: 1=Baja 2=Media 3=Alta 4=Crítica")
prioridad_str = input("Prioridad [2]: ").strip() or "2"
etiquetas_str = input("Etiquetas (separadas por coma): ").strip()
try:
prioridad = int(prioridad_str)
except ValueError:
print("Prioridad inválida, usando Media (2)")
prioridad = 2
etiquetas = [e.strip() for e in etiquetas_str.split(",") if e.strip()] if etiquetas_str else []
tarea = self._svc.crear_tarea(titulo, descripción, prioridad, etiquetas)
print(f"✅ Tarea #{tarea.id} creada: '{tarea.titulo}'")
def _cmd_listar(self) -> None:
tareas = self._svc.listar_tareas()
self._mostrar_tareas(tareas, "Todas las tareas")
def _cmd_pendientes(self) -> None:
tareas = self._svc.listar_tareas(solo_pendientes=True)
self._mostrar_tareas(tareas, "Tareas pendientes")
@manejar_errores
def _cmd_completar(self) -> None:
tarea_id = self._pedir_id("ID de la tarea a completar: ")
tarea = self._svc.completar_tarea(tarea_id)
print(f"✅ Tarea #{tarea.id} completada: '{tarea.titulo}'")
@manejar_errores
def _cmd_eliminar(self) -> None:
tarea_id = self._pedir_id("ID de la tarea a eliminar: ")
confirmar = input(f"¿Eliminar tarea #{tarea_id}? (s/N): ").strip().lower()
if confirmar == "s":
self._svc.eliminar_tarea(tarea_id)
print(f"🗑️ Tarea #{tarea_id} eliminada")
else:
print("Operación cancelada")
def _cmd_buscar(self) -> None:
etiqueta = input("Etiqueta a buscar: ").strip()
tareas = self._svc.listar_tareas(etiqueta=etiqueta)
self._mostrar_tareas(tareas, f"Tareas con etiqueta '{etiqueta}'")
def _cmd_estadísticas(self) -> None:
stats = self._svc.estadísticas()
if stats["total"] == 0:
print("No hay tareas registradas")
return
print(f"\n── Estadísticas ─────────────────")
print(f" Total de tareas: {stats['total']}")
print(f" Completadas: {stats['completadas']}")
print(f" Pendientes: {stats['pendientes']}")
print(f" Progreso: {stats['porcentaje_completado']}%")
print(f" Etiquetas únicas: {stats['etiquetas_únicas']}")
print(f"\n Por prioridad:")
for nombre, cantidad in stats["por_prioridad"].items():
print(f" {nombre:10}: {cantidad}")
def _mostrar_tareas(self, tareas: list[Tarea], titulo: str) -> None:
print(f"\n── {titulo} ({'ninguna' if not tareas else len(tareas)}) ─────────")
if not tareas:
print(" (ninguna)")
return
for tarea in tareas:
print(f" {tarea.resumen()}")
if tarea.descripción:
print(f" {tarea.descripción[:60]}")
if tarea.etiquetas:
print(f" 🏷️ {', '.join(tarea.etiquetas)}")
@staticmethod
def _pedir_id(mensaje: str) -> int:
while True:
entrada = input(mensaje).strip()
try:
return int(entrada)
except ValueError:
print(f"Ingresa un número entero válido")
# ════════════════════════════════════════════════════════════
# Punto de entrada
# ════════════════════════════════════════════════════════════
def main() -> None:
"""Punto de entrada del programa."""
repo = RepositorioTareas()
servicio = ServicioTareas(repo)
cli = GestorCLI(servicio)
cli.ejecutar()
if __name__ == "__main__":
main()Cómo ejecutar el proyecto
Guarda el código anterior en un archivo llamado gestor_tareas.py y ejecútalo:
python3 gestor_tareas.pyAl iniciarse, verás el menú de ayuda y el prompt 📋 gestor>. Puedes:
- Crear tareas: Escribe
nuevaon, luego ingresa el título, descripción, prioridad y etiquetas. - Listar tareas: Escribe
listarolpara ver todas, opendientepara ver solo las activas. - Completar: Escribe
completarocy el ID de la tarea. - Buscar por etiqueta: Escribe
buscarob. - Ver estadísticas: Escribe
statsos. - Salir: Escribe
salir,qo presionaCtrl+C.
Los datos se guardan automáticamente en ~/.gestor_tareas/tareas.json y persisten entre sesiones.
Conceptos del curso aplicados
Este proyecto integra todos los temas del curso:
| Concepto | Dónde se aplica |
|---|---|
| Variables y tipos | Todos los dataclasses y anotaciones |
| Control de flujo | Bucle REPL con match/case y filtros |
| Funciones y scope | Servicio y métodos privados |
| Listas y comprensiones | listar_tareas() con múltiples filtros |
| Diccionarios y sets | estadísticas(), to_dict()/from_dict() |
| Comprensiones | Filtros de tareas, estadísticas() |
| Manejo de errores | Jerarquía de excepciones, @manejar_errores |
| Módulos | Imports de stdlib: json, pathlib, logging |
| Clases y herencia | TareaBase → Tarea → TareaConSubtareas |
| Archivos y JSON | RepositorioTareas con pathlib |
| Decoradores | @registrar_accion, @manejar_errores |
| Type hints | Anotaciones en todos los métodos |
Ideas para extender el proyecto
Una vez que tengas funcionando la versión base, puedes:
- Agregar fechas de vencimiento: Campo
vence_en: datetime | Noneen el modelo y alertas en el listado. - Exportar a CSV o Markdown: Nuevo comando
exportarque genera un informe. - Interfaz web: Reemplaza
GestorCLIpor unapp = FastAPI()con los mismos endpoints. - Sincronización remota: Usa
httpxpara sincronizar con una API REST. - Tests automatizados: Escribe tests con
pytestparaServicioTareasyRepositorioTareas. - Modo batch: Acepta comandos como argumentos de línea (
python gestor.py nueva "Mi tarea").
¡Felicidades por completar el curso Python Esencial! Ahora tienes las bases sólidas para construir desde scripts de automatización hasta APIs REST y aplicaciones de datos con Python.
Inicia sesión para guardar tu progreso