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"
Tres cosas clave:
-
requires-python = ">=3.14"— usamos type hints con sintaxisstr | None(PEP 604, disponible desde Python 3.10). 3.14 porque si ya hacemos esto, hagámoslo bien. -
hatchling— build backend moderno, rápido, sin configuraciones mágicas que te exploten en producción. -
[project.scripts]— definepytaskcomo 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
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}")
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)})")
@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)
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
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
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
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(","))
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)
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:
- Lee las tareas desde
tasks.json - Resetea tareas recurrentes si cambió el día
- Dispara tareas atrasadas (catch-up)
- Dispara tareas que coinciden con la hora actual
- 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}")
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
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
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
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")
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
)
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%
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
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)