DEV Community

Cover image for # Cómo construí un asistente de recordatorios en Python (y por qué lo subí a GitLab en vez de GitHub)
Enrique Lazo Bello
Enrique Lazo Bello

Posted on

# Cómo construí un asistente de recordatorios en Python (y por qué lo subí a GitLab en vez de GitHub)

Construí pyTask, un asistente de recordatorios y timers desde la terminal con Python, Click, y notificaciones nativas de Windows.
Código fuente aquí.

1. El porque

Una conversación con un amigo. Estábamos comparando plataformas.

GitLab venía pisando fuerte con su CI/CD nativo. Mientras tanto, GitHub acumulaba brechas de seguridad y el ecosistema de Actions, aunque potente, requería más piezas externas: Codecov para cobertura, SonarCloud para calidad, Dependabot para dependencias.

GitLab lo ofrece todo integrado desde el primer git push. Los pipelines, el registro de contenedores, el escaneo de secretos, la cobertura de código, la calidad del código, el registro de dependencias. Sin configurar 20 servicios
externos.

Para un proyecto pequeño como este, la diferencia es enorme.

2. La confirmación obligatoria

Empecé este proyecto porque me cansé de ignorar mis propios recordatorios.

Google Calendar suena, lo cierras, la reunión no existió. Las alarmas del celular: snooze, snooze, snooze. Los timers de cocina: suenan un rato y se apagan aunque nadie haya ido a la cocina.

El problema de fondo es que todos esos sistemas te dejan ignorarlos cómodamente. Necesitaba algo diferente: una alerta que no desaparezca hasta que confirmes que hiciste la tarea. Y que si no confirmas, te siga molestando cada N minutos.

Eso es pyTask. Un recordatorio pasivo-agresivo hecho en Python.

3. Estructura del proyecto

Arrancamos con un pyproject.toml moderno usando Hatchling como build backend.

[project]
name = "pytask"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
    "click>=8.0",
    "plyer>=2.0",
]

[project.scripts]
pytask = "pytask.cli:cli"

[project.optional-dependencies]
dev = ["ruff", "mypy", "pytest", "pytest-cov", "bandit", "pre-commit"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Enter fullscreen mode Exit fullscreen mode

Tres cosas clave:

  1. requires-python = ">=3.14" — usamos type hints con sintaxis str | None (PEP 604, disponible desde Python 3.10). 3.14 porque si ya hacemos esto, hagámoslo bien.
  2. hatchling — build backend moderno, rápido, sin configuraciones mágicas que te exploten en producción.
  3. [project.scripts] — define pytask como comando ejecutable directamente desde la terminal.

La estructura de carpetas:

├── pyproject.toml
├── LICENSE
├── .gitlab-ci.yml
├── .pre-commit-config.yaml
├── README.md
├── pytask/
│   ├── __init__.py
│   ├── __main__.py
│   ├── cli.py
│   ├── core.py
│   └── service.py
└── tests/
    ├── __init__.py
    ├── conftest.py
    ├── test_core.py
    ├── test_cli.py
    └── test_service.py
Enter fullscreen mode Exit fullscreen mode

Cada archivo tiene una responsabilidad clara:

Archivo Responsabilidad
pytask/core.py Modelo Task, persistencia JSON, lógica de disparo
pytask/cli.py Interfaz de comandos (Click)
pytask/service.py Loop de servicio, notificaciones, re-alertas
tests/ Tests unitarios y de integración

4. CLI con Click: la interfaz de comandos

Usamos Click porque es la biblioteca estándar de facto para CLIs en Python. Mira qué limpio queda:

# pytask/cli.py — comandos principales
import re
from datetime import datetime
import click
from pytask.core import DAY_NAMES, Task, load_tasks, save_tasks, load_config, save_config


@click.group()
def cli():
    """pyTask - recordatorios y timers desde la terminal"""


def _validate_time_flags(time_str, in_minutes):
    """Valida que los flags de tiempo sean consistentes."""
    if in_minutes is not None and in_minutes < 1:
        return "Error: el timer debe ser al menos 1 minuto"
    if time_str and not re.match(r"^(?:[01]\d|2[0-3]):[0-5]\d$", time_str):
        return "Error: formato de hora invalido. Usa HH:MM (ej. 08:30, 14:00)"
    if time_str and in_minutes:
        return "Error: no puedes usar --at y --in juntos"
    if not time_str and not in_minutes:
        return "Error: usa --at HH:MM o --in Nmin"


def _validate_day_flags(days, weekdays, daily):
    """Valida que no haya flags de días contradictorios."""
    if days and weekdays:
        return "Error: no puedes usar --days y --weekdays juntos"
    if days and daily:
        return "Error: no puedes usar --days y --daily juntos"


def _validate_day_names(days):
    """Valida que los nombres de días sean correctos."""
    if not days:
        return
    DAY_NAMES = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"]
    invalid = [d.strip() for d in days.split(",") if d.strip() not in DAY_NAMES]
    if invalid:
        return f"Error: dias invalidos: {', '.join(invalid)}. Usa: lun,mar,mie,jue,vie,sab,dom"


def _resolve_days(days, weekdays, daily):
    """Resuelve los flags de recurrencia a un string canónico."""
    if daily:
        return "daily"
    if weekdays:
        return "weekdays"
    return days


@cli.command()
@click.argument("text")
@click.option("--at", "time_str", help="Hora fija formato HH:MM")
@click.option("--days", help="Dias: lun,mar,mie,jue,vie,sab,dom")
@click.option("--weekdays", is_flag=True, help="Lunes a viernes")
@click.option("--daily", is_flag=True, help="Todos los dias")
@click.option("--in", "in_minutes", type=int, help="Minutos desde ahora (timer)")
def add(text, time_str, days, weekdays, daily, in_minutes):
    """Agregar un recordatorio o timer"""
    err = _validate_time_flags(time_str, in_minutes)
    if err:
        click.echo(err)
        return
    err = _validate_day_flags(days, weekdays, daily)
    if err:
        click.echo(err)
        return
    err = _validate_day_names(days)
    if err:
        click.echo(err)
        return

    task = Task(
        text=text,
        at=time_str,
        days=_resolve_days(days, weekdays, daily),
        in_minutes=in_minutes,
        created_at=datetime.now().isoformat(),
    )
    tasks = load_tasks()
    tasks.append(task)
    save_tasks(tasks)
    click.echo(f"Tarea creada: {task.id} - {text}")
Enter fullscreen mode Exit fullscreen mode

Por qué separamos las validaciones: no es solo por legibilidad. Reduce la complejidad cognitiva del método add(). Cada función hace una cosa y la hace bien. La complejidad cognitiva mide lo difícil que es seguir el flujo de un método.

@cli.command(name="list")
def list_tasks():
    """Listar todas las tareas pendientes"""
    tasks = load_tasks()
    if not tasks:
        click.echo("No hay tareas pendientes")
        return
    for t in tasks:
        parts = []
        if t.at:
            parts.append(f"{t.at}")
        if t.days:
            label = {"daily": "todos los dias", "weekdays": "lun-vie"}.get(t.days, f"{t.days}")
            parts.append(label)
        if t.in_minutes:
            parts.append(f"timer {t.in_minutes}min")
        if t.done_today:
            parts.append("hecha hoy")
        click.echo(f"  {t.id}  {t.text}  ({', '.join(parts)})")
Enter fullscreen mode Exit fullscreen mode
@cli.command()
@click.argument("task_id")
def done(task_id):
    """Marcar tarea como completada"""
    tasks = load_tasks()
    found = None
    for t in tasks:
        if t.id == task_id:
            found = t
            break
    if not found:
        click.echo(f"Tarea {task_id} no encontrada")
        return
    if found.days:
        found.done_today = True
        click.echo(f"{found.text} marcada como hecha (reaparece manana)")
    else:
        tasks.remove(found)
        click.echo(f"{found.text} completada y eliminada")
    save_tasks(tasks)
Enter fullscreen mode Exit fullscreen mode

Aquí está la lógica de confirmación: si la tarea es recurrente, se marca como "hecha hoy" pero reaparece mañana. Si es de una sola vez, desaparece. Simple, pero es exactamente el comportamiento que quería.

5. El modelo Task: dataclasses, tipos, persistencia

El corazón del proyecto está en core.py. Un dataclass de Python para el modelo de datos:

# pytask/core.py
import json
import uuid
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path


@dataclass
class Task:
    id: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
    text: str = ""
    at: str | None = None        # "HH:MM" o None para timers
    days: str | None = None      # "daily", "weekdays", "lun,mar,..."
    in_minutes: int | None = None
    created_at: str = ""
    done_today: bool = False
    last_fired: str | None = None
Enter fullscreen mode Exit fullscreen mode

str | None es la sintaxis moderna (PEP 604).
En Python < 3.10 escribías Optional[str]. Desde 3.10, | None es más legible y ya es el estándar.

La lógica de disparo es la función más importante del sistema:

    def should_fire(self, now: datetime) -> bool:
        if self.done_today:
            return False
        if self.in_minutes is not None:
            return self._check_timer(now)
        if self.at:
            return self._check_scheduled(now)
        return False
Enter fullscreen mode Exit fullscreen mode

Extraída en sub-métodos para mantener la complejidad baja:

    def _check_timer(self, now: datetime) -> bool:
        if self.last_fired or not self.created_at:
            return False
        created = datetime.fromisoformat(self.created_at)
        return (now - created).total_seconds() / 60 >= self.in_minutes

    def _check_scheduled(self, now: datetime) -> bool:
        if now.strftime("%H:%M") != self.at:
            return False
        if self.days and not self.day_matches(now):
            return False
        if self.last_fired:
            try:
                last = datetime.fromisoformat(self.last_fired)
            except ValueError:
                return True
            if last.strftime("%Y-%m-%d %H:%M") == now.strftime("%Y-%m-%d %H:%M"):
                return False
        return True
Enter fullscreen mode Exit fullscreen mode

La recurrencia con day_matches:

    def day_matches(self, now: datetime) -> bool:
        DAY_NAMES = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"]
        if not self.days:
            return True
        if self.days == "daily":
            return True
        if self.days == "weekdays":
            return now.weekday() < 5
        today = DAY_NAMES[now.weekday()]
        return today in (d.strip() for d in self.days.split(","))
Enter fullscreen mode Exit fullscreen mode

Ese .strip() resuelve un bug común: si alguien escribe --days "lun, mar" con espacio, split(",") produce ["lun", " mar"]. Sin el .strip(), " mar" no coincidiría con "mar" y la tarea nunca se dispararía los martes.

La persistencia es archivo JSON con escritura atómica:

TASKS_FILE = Path.home() / ".pytask" / "tasks.json"

def load_tasks() -> list[Task]:
    if not TASKS_FILE.exists():
        return []
    try:
        data = json.loads(TASKS_FILE.read_text(encoding="utf-8"))
        return [Task(**{k: v for k, v in t.items() if k in Task.__dataclass_fields__})
                for t in data]
    except (json.JSONDecodeError, TypeError):
        return []


def save_tasks(tasks: list[Task]) -> None:
    TASKS_FILE.parent.mkdir(parents=True, exist_ok=True)
    data = [asdict(t) for t in tasks]
    tmp = TASKS_FILE.with_suffix(".tmp")
    tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
    tmp.replace(TASKS_FILE)
Enter fullscreen mode Exit fullscreen mode

Escritura atómica: primero escribimos a un .tmp, luego lo renombramos con replace(). Si el proceso muere a mitad, el .tmp queda basura pero tasks.json original está intacto. Sin esto, una escritura fallida a las 2am te borra todas las tareas del día. Pasó. Por eso está.

6. Servicio en segundo plano: el loop de notificaciones

El servicio corre en un while True durmiendo 30 segundos entre ciclos. Cada iteración:

  1. Lee las tareas desde tasks.json
  2. Resetea tareas recurrentes si cambió el día
  3. Dispara tareas atrasadas (catch-up)
  4. Dispara tareas que coinciden con la hora actual
  5. Re-alerta tareas no confirmadas
# pytask/service.py
import time
from datetime import datetime
from plyer import notification
from pytask.core import load_config, load_tasks, save_tasks


def show_toast(title: str, message: str):
    try:
        notification.notify(
            title=title, message=message, app_name="pyTask", timeout=10
        )
    except Exception as e:
        print(f"Error al mostrar notificacion: {e}")
Enter fullscreen mode Exit fullscreen mode

Plyer es una biblioteca cross-platform para notificaciones. En Windows usa el sistema nativo de toasts. En Linux usa notify-send. Una línea de código y funciona en ambos — sin if/else de plataforma.

La lógica del loop está en process_tasks(), extraída para poder testearla:

def process_tasks(tasks, cfg, now, prev_check_day):
    today_str = now.strftime("%Y-%m-%d")
    interval_min = cfg.get("interval", 5)

    tasks, reset_changed, prev_check_day = _reset_done_today(tasks, today_str, prev_check_day)
    catchup_changed = _catch_up_tasks(tasks, now)
    fire_changed = _fire_scheduled(tasks, now)
    ra_changed = _re_alert_tasks(tasks, now, interval_min)

    changed = reset_changed or catchup_changed or fire_changed or ra_changed
    return tasks, changed, prev_check_day
Enter fullscreen mode Exit fullscreen mode

Cada sub-función independiente:

def _reset_done_today(tasks, today_str, prev_check_day):
    if today_str == prev_check_day:
        return tasks, False, prev_check_day
    changed = False
    for t in tasks:
        if t.days and t.done_today:
            last_done = t.last_fired[:10] if t.last_fired else None
            if last_done != today_str:
                t.done_today = False
                changed = True
    return tasks, changed, today_str


def _catch_up_tasks(tasks, now):
    changed = False
    for t in tasks:
        if not t.at or t.last_fired or t.done_today:
            continue
        task_h, task_m = map(int, t.at.split(":"))
        time_passed = now.hour * 60 + now.minute >= task_h * 60 + task_m
        if time_passed and (not t.days or t.day_matches(now)):
            show_toast("pyTask", t.text)
            t.last_fired = now.isoformat()
            changed = True
    return changed


def _re_alert_tasks(tasks, now, interval_min):
    changed = False
    for t in tasks:
        if t.last_fired and not t.done_today:
            last = datetime.fromisoformat(t.last_fired)
            if (now - last).total_seconds() / 60 >= interval_min:
                show_toast("pyTask", t.text)
                t.last_fired = now.isoformat()
                changed = True
    return changed
Enter fullscreen mode Exit fullscreen mode

7. Tests: de 0 a 90% de cobertura

Sin tests, cualquier cambio rompe algo sin que te enteres hasta que ya estás en producción explicándole a alguien por qué su recordatorio de las 9am no sonó.

# tests/test_core.py — fragmento
from datetime import datetime
from pytask.core import Task


class TestShouldFire:
    def test_done_today_returns_false(self):
        t = Task(text="x", at="10:00", done_today=True)
        assert t.should_fire(datetime(2026, 5, 29, 10, 0, 0)) is False

    def test_timer_elapsed_returns_true(self):
        t = Task(text="x", in_minutes=5, created_at="2026-05-29T09:55:00")
        assert t.should_fire(datetime(2026, 5, 29, 10, 0, 0)) is True

    def test_scheduled_exact_time_returns_true(self):
        t = Task(text="x", at="10:00")
        assert t.should_fire(datetime(2026, 5, 29, 10, 0, 0)) is True

    def test_scheduled_wrong_time_returns_false(self):
        t = Task(text="x", at="10:00")
        assert t.should_fire(datetime(2026, 5, 29, 11, 0, 0)) is False
Enter fullscreen mode Exit fullscreen mode

Para aislar los tests del sistema de archivos real, conftest.py con un fixture que redirige todo a directorios temporales:

# tests/conftest.py
import pytest
from pytask import core


@pytest.fixture(autouse=True)
def isolate_storage(tmp_path, monkeypatch):
    monkeypatch.setattr(core, "TASKS_FILE", tmp_path / "tasks.json")
    monkeypatch.setattr(core, "CONFIG_FILE", tmp_path / "config.json")
Enter fullscreen mode Exit fullscreen mode

autouse=True corre esto en cada test automáticamente. tmp_path es un directorio temporal que pytest crea y destruye. Este fixture me ahorró muchos tests que fallaban por estado residual de otros tests — uno de esos bugs que te hacen perder una tarde.

Para notificaciones, mockeamos plyer:

# tests/test_service.py
from unittest.mock import patch
from pytask.service import show_toast


class TestShowToast:
    @patch("pytask.service.notification.notify")
    def test_show_toast_calls_notify(self, mock_notify):
        show_toast("pyTask", "test message")
        mock_notify.assert_called_once_with(
            title="pyTask", message="test message",
            app_name="pyTask", timeout=10
        )
Enter fullscreen mode Exit fullscreen mode

Mockear significa reemplazar una función real por una simulación. Así no necesitas tener un sistema de notificaciones funcionando para testear que el código que llama a las notificaciones funciona.

Resultado final:

Name                 Stmts   Miss  Cover
pytask/core.py          79      1    99%
pytask/cli.py          112     10    91%
pytask/service.py       69     13    81%
─────────────────────────────────────────
TOTAL                  263     27    90%
Enter fullscreen mode Exit fullscreen mode

8. CI/CD con GitLab: el pipeline completo

Aquí está la diferencia real con GitHub. Todo en un solo archivo, sin servicios externos.

# .gitlab-ci.yml
include:
  - template: Security/SAST.gitlab-ci.yml
  - template: Security/Secret-Detection.gitlab-ci.yml

image: python:3-slim

variables:
  PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache"

cache:
  key: "$CI_JOB_NAME"
  paths:
    - .pip-cache/

stages:
  - lint
  - test
  - coverage

ruff:
  stage: lint
  before_script:
    - python -m venv .venv
    - . .venv/bin/activate
    - pip install -e ".[dev]"
  script:
    - . .venv/bin/activate
    - ruff check pytask tests
    - ruff format --check pytask tests

pytest:
  stage: test
  before_script:
    - python -m venv .venv
    - . .venv/bin/activate
    - pip install -e ".[dev]"
  script:
    - . .venv/bin/activate
    - pytest tests -v --junitxml=report.xml
  artifacts:
    when: always
    reports:
      junit: report.xml

coverage:
  stage: coverage
  before_script:
    - python -m venv .venv
    - . .venv/bin/activate
    - pip install -e ".[dev]"
  script:
    - . .venv/bin/activate
    - pip install codecov-cli
    - pytest --cov=pytask --cov-report=xml --cov-report=term-missing
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml
Enter fullscreen mode Exit fullscreen mode

Lo que hace cada etapa:

Stage Herramienta ¿Qué verifica?
lint Ruff Formato del código, imports ordenados
test pytest + JUnit Tests pasan, reporte XML visible en GitLab

En GitHub Actions para lograr lo mismo habrías necesitado: actions/checkout@v4, actions/setup-python@v5, github/codeql-action@v3 para SAST, y Dependabot configurado por separado. En GitLab: include: - template: Security/SAST.gitlab-ci.yml. Una línea.

GitLab ofrece una plataforma unificada para SCM, CI/CD, container registry, escaneo de seguridad y dashboards de compliance, útil cuando quieres consolidar herramientas en vez de pegar juntos varios productos SaaS. Para proyectos grandes ese consolidado tiene valor real en mantenimiento y costos. Para un side project como este, el valor es diferente: que lo puedas montar en 30 minutos y que funcione.

9. Lo que aprendiste

Python moderno es genuinamente más legible. str | None, dataclasses, pathlib — cada uno de estos existe para que el código cuente su intención sin comentarios extra. Los aproveché todos.

El CI/CD integrado cambia el hábito. Cuando el pipeline corre solo, sin servicios externos que configurar, empiezas a usarlo en proyectos chicos también. Es el punto que marca si un side project tiene CI/CD o no.

Las pruebas de cobertura no mienten, pero tampoco lo dicen todo. Ver service.py en 81% me hizo identificar exactamente qué ramas no estaba cubriendo y por qué — y algunas de esas ramas eran precisamente los edge cases que me importaban. El número te da dirección, no garantía.

El .strip() es gratis. Úsalo.

El código completo está en GitLab:
https://gitlab.com/enlabe/pytask

PRs bienvenidos. Issues bienvenidos. Quejas sobre mi estilo de código, también — pero con argumento.

Top comments (0)