En esta página

Proyecto final: gestor de tareas CLI

25 min lectura TextoCap. 5 — Python para web

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á:

  1. Modelos de datos: TareaBase (dataclass abstracta) y Tarea (hereda de TareaBase)
  2. Repositorio: RepositorioTareas (persistencia JSON con pathlib)
  3. Servicio: ServicioTareas (lógica de negocio)
  4. CLI: GestorCLI (interfaz de línea de comandos con bucle REPL)
  5. Decoradores: registrar_accion, manejar_errores
  6. 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.py

Al iniciarse, verás el menú de ayuda y el prompt 📋 gestor>. Puedes:

  1. Crear tareas: Escribe nueva o n, luego ingresa el título, descripción, prioridad y etiquetas.
  2. Listar tareas: Escribe listar o l para ver todas, o pendiente para ver solo las activas.
  3. Completar: Escribe completar o c y el ID de la tarea.
  4. Buscar por etiqueta: Escribe buscar o b.
  5. Ver estadísticas: Escribe stats o s.
  6. Salir: Escribe salir, q o presiona Ctrl+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 | None en el modelo y alertas en el listado.
  • Exportar a CSV o Markdown: Nuevo comando exportar que genera un informe.
  • Interfaz web: Reemplaza GestorCLI por un app = FastAPI() con los mismos endpoints.
  • Sincronización remota: Usa httpx para sincronizar con una API REST.
  • Tests automatizados: Escribe tests con pytest para ServicioTareas y RepositorioTareas.
  • 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.

Este proyecto integra todos los conceptos del curso
Clases con herencia y dataclasses, persistencia JSON con pathlib, decoradores con @wraps, type hints completos, comprensiones de lista y diccionario, manejo de errores con excepciones personalizadas y un bucle REPL de CLI.
Extiende el proyecto para practicar más
Ideas para extender este gestor de tareas: agregar categorías jerárquicas, exportar a CSV/Markdown, agregar fechas de vencimiento con notificaciones, sincronizar con una API REST usando httpx, o agregar una interfaz web con FastAPI.
Ejecuta con Python 3.10 o superior
Este proyecto usa match/case (Python 3.10+), el operador | para union types (Python 3.10+), y sintaxis de dataclasses con field(). Asegúrate de tener Python 3.10+ instalado antes de ejecutarlo.