Skip to content

Commit 998f97e

Browse files
Robustiza es_laborable: permite fallback real a Festivos.txt y testea sin internet. Tests 100% OK.
1 parent 438c4e6 commit 998f97e

4 files changed

Lines changed: 117 additions & 9 deletions

File tree

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Dependencias del proyecto - Windows con Access
2+
holidays
23
# Base de datos y procesamiento
34
pandas>=2.2.0
45

@@ -14,4 +15,4 @@ python-dotenv>=1.0.0
1415
# Logging y utilidades
1516
python-dateutil>=2.8.0
1617
colorama>=0.4.6
17-
python-logging-loki
18+
holidays

scripts/run_master.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,9 @@ def __init__(self):
8282
def run_daily_tasks(self) -> tuple[int, int]:
8383
from datetime import date as _date
8484

85-
from common.utils import is_workday # type: ignore
85+
from common.utils import es_laborable
8686

87-
if not is_workday(_date.today()):
87+
if not es_laborable(_date.today()):
8888
self.logger.info("📅 Hoy no es día laborable, omitiendo tareas diarias")
8989
return 0, len(self.daily_tasks)
9090
ejecutadas = 0

src/common/utils.py

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Utilidades comunes para el proyecto
22
"""
33
import logging
4+
import holidays
45
import os
56
import re
67
from datetime import date, datetime, timedelta
@@ -111,6 +112,55 @@ def setup_logging(log_file: Path, level=logging.INFO):
111112
)
112113

113114

115+
# Inicializa el objeto una sola vez a nivel de módulo para mayor eficiencia
116+
# 'M' es el código para la Comunidad de Madrid
117+
try:
118+
festivos_madrid = holidays.ES(prov='M')
119+
HOLIDAYS_LIB_AVAILABLE = True
120+
except Exception:
121+
festivos_madrid = None
122+
HOLIDAYS_LIB_AVAILABLE = False
123+
logging.warning("No se pudo inicializar la librería 'holidays'. Se usará el archivo local como fallback.")
124+
125+
def es_laborable(fecha: date = None, festivos_file_path: Path = None) -> bool:
126+
"""
127+
Determina si una fecha es día laborable en Madrid.
128+
Un día es laborable si no es fin de semana y no es festivo.
129+
Primero intenta usar la librería 'holidays' y, si falla, usa 'Festivos.txt'.
130+
festivos_file_path: solo para tests, permite inyectar la ruta del archivo de festivos.
131+
"""
132+
if fecha is None:
133+
fecha = date.today()
134+
135+
# 1. Comprueba si es fin de semana (lunes=0, ..., domingo=6)
136+
if fecha.weekday() >= 5:
137+
return False
138+
139+
# 2. Intenta usar la librería 'holidays' (método principal)
140+
if HOLIDAYS_LIB_AVAILABLE and festivos_madrid:
141+
try:
142+
if fecha in festivos_madrid:
143+
return False
144+
else:
145+
return True # No es festivo según la librería
146+
except Exception as e:
147+
logging.warning(f"Fallo al consultar la librería 'holidays': {e}. Usando fallback a Festivos.txt.")
148+
149+
# 3. Si la librería falla o no está disponible, usa el archivo local (método de respaldo)
150+
try:
151+
if festivos_file_path is None:
152+
project_root = Path(__file__).resolve().parent.parent.parent
153+
festivos_file_path = project_root / "herramientas" / "Festivos.txt"
154+
if festivos_file_path.exists():
155+
with open(festivos_file_path, 'r', encoding='utf-8') as f:
156+
festivos_locales = f.read().splitlines()
157+
if fecha.strftime("%d/%m/%Y") in festivos_locales:
158+
return False
159+
except Exception as e:
160+
logging.error(f"No se pudo leer el archivo de festivos local: {e}")
161+
162+
# Si no es fin de semana ni se encontró como festivo, es laborable
163+
return True
114164
def is_workday(check_date: date, holidays_file: Optional[Path] = None) -> bool:
115165
"""
116166
Verifica si una fecha es día laborable
@@ -153,23 +203,23 @@ def get_next_workday_from_preferred(
153203
Returns:
154204
Fecha del próximo día laborable
155205
"""
156-
today = date.today()
206+
_today = date.today()
157207

158208
# Calcular días hasta el día preferido de esta semana
159-
days_until_preferred = (preferred_weekday - today.weekday()) % 7
209+
_days_until_preferred = (preferred_weekday - _today.weekday()) % 7
160210

161211
# Si es 0, significa que hoy es el día preferido
162-
if days_until_preferred == 0:
163-
candidate_date = today
212+
if _days_until_preferred == 0:
213+
candidate_date = _today
164214
else:
165-
candidate_date = today + timedelta(days=days_until_preferred)
215+
candidate_date = _today + timedelta(days=_days_until_preferred)
166216

167217
# Buscar el próximo día laborable desde el día preferido
168218
max_attempts = 7 # Máximo una semana de búsqueda
169219
attempts = 0
170220

171221
while attempts < max_attempts:
172-
if is_workday(candidate_date, holidays_file):
222+
if es_laborable(candidate_date):
173223
return candidate_date
174224

175225
# Si no es laborable, probar el siguiente día

tests/unit/common/test_utils.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,3 +590,60 @@ def test_should_execute_quality_task_due_on_preferred(monkeypatch):
590590
patch_today(monkeypatch, monday)
591591
fake_get_last_task(monkeypatch, date(2024, 4, 1)) # 7 days ago
592592
assert should_execute_quality_task(None, "QUAL", preferred_weekday=0) is True
593+
594+
595+
import pytest
596+
from unittest.mock import patch, MagicMock
597+
from datetime import date
598+
from pathlib import Path
599+
600+
from common.utils import es_laborable
601+
602+
603+
class TestEsLaborable:
604+
def test_laborable_weekday(self):
605+
# Lunes normal
606+
assert es_laborable(date(2025, 8, 11)) is True
607+
608+
def test_no_laborable_weekend(self):
609+
# Sábado
610+
assert es_laborable(date(2025, 8, 9)) is False
611+
# Domingo
612+
assert es_laborable(date(2025, 8, 10)) is False
613+
614+
def test_festivo_por_holidays(self):
615+
# Simula que holidays devuelve True para esa fecha
616+
with patch("common.utils.HOLIDAYS_LIB_AVAILABLE", True), \
617+
patch("common.utils.festivos_madrid", new_callable=MagicMock) as mock_holidays:
618+
mock_holidays.__contains__.side_effect = lambda d: d == date(2025, 1, 1)
619+
assert es_laborable(date(2025, 1, 1)) is False # Festivo
620+
assert es_laborable(date(2025, 8, 11)) is True # No festivo
621+
622+
def test_fallback_archivo_festivo(self, tmp_path):
623+
# Simula holidays no disponible y usa archivo local
624+
festivo = date(2025, 12, 25)
625+
festivos_file = tmp_path / "Festivos.txt"
626+
festivos_file.write_text(festivo.strftime("%d/%m/%Y") + "\n", encoding="utf-8")
627+
with patch("common.utils.HOLIDAYS_LIB_AVAILABLE", False), \
628+
patch("common.utils.festivos_madrid", None):
629+
# El 25 de diciembre debe ser festivo (no laborable)
630+
assert es_laborable(festivo, festivos_file_path=festivos_file) is False
631+
assert es_laborable(date(2025, 8, 11), festivos_file_path=festivos_file) is True
632+
633+
def test_fallback_archivo_no_existe(self, tmp_path):
634+
# Simula holidays no disponible y archivo no existe
635+
non_existent_file = tmp_path / "Festivos.txt"
636+
with patch("common.utils.HOLIDAYS_LIB_AVAILABLE", False), \
637+
patch("common.utils.festivos_madrid", None):
638+
assert es_laborable(date(2025, 8, 11), festivos_file_path=non_existent_file) is True
639+
640+
def test_fallback_archivo_error_lectura(self, tmp_path):
641+
# Simula error al leer el archivo
642+
festivos_file = tmp_path / "Festivos.txt"
643+
festivos_file.write_text("25/12/2025\n", encoding="utf-8")
644+
with patch("common.utils.HOLIDAYS_LIB_AVAILABLE", False), \
645+
patch("common.utils.festivos_madrid", None), \
646+
patch("builtins.open", side_effect=OSError("fail")), \
647+
patch("logging.error") as mock_log:
648+
assert es_laborable(date(2025, 8, 11), festivos_file_path=festivos_file) is True
649+
mock_log.assert_called()

0 commit comments

Comments
 (0)