Código fuente para dane_data._hooks
"""Ganchos de progreso y cancelación, y extracción de ZIPs, compartidos por los descargadores."""
from __future__ import annotations
import logging
import shutil
import time
import zipfile
from pathlib import Path
from typing import Callable, Optional
from urllib.parse import urljoin, urlparse
import requests
ProgressFn = Callable[[Optional[float], str], None]
[documentos]
class DownloadCancelled(Exception):
"""
La descarga se detuvo porque ``should_stop()`` devolvió True.
La lanzan ``dd.geih.download`` y ``dd.eam.download`` cuando se les pasa ``should_stop`` (la aplicación
lo usa para su botón de cancelar). Los meses o años que ya terminaron quedan en disco y la próxima
descarga los salta.
Ejemplos
--------
>>> import time
>>> import dane_data as dd
>>> limite = time.time() + 60
>>> try: # doctest: +SKIP
... dd.geih.download(2025, should_stop=lambda: time.time() > limite)
... except dd.DownloadCancelled:
... print("Se detuvo al minuto; lo descargado se conserva.")
"""
class ProgressMixin:
progress: ProgressFn | None = None
should_stop: Callable[[], bool] | None = None
def _emit(self, fraction: float | None, message: str) -> None:
if self.progress is not None:
self.progress(fraction, message)
def _check_stop(self) -> None:
if self.should_stop is not None and self.should_stop():
raise DownloadCancelled("Descarga cancelada por el usuario.")
class DescargadorANDA(ProgressMixin):
"""Lo común de los descargadores de la GEIH y la EAM: la sesión HTTP y la descarga de cada ZIP."""
base_url = "https://microdatos.dane.gov.co"
log = logging.getLogger("dane_data") # cada descargador pone el suyo
def _abrir_sesion(self) -> None:
self.session = requests.Session()
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
})
def download_file(self, url: str, dest_path: Path, max_retries: int = 3) -> bool:
"""
Descarga un archivo con streaming y verificación de integridad.
"""
log = self.log
if dest_path.exists() and dest_path.stat().st_size > 0:
if zip_valido(dest_path):
log.info(f"Archivo ya existente: {dest_path.name} ({dest_path.stat().st_size / 1024 / 1024:.1f} MB). Saltando descarga.")
return True
log.warning(f"{dest_path.name} en disco está dañado: se vuelve a descargar.")
dest_path.unlink()
temp_dest = dest_path.with_suffix(dest_path.suffix + ".part")
for attempt in range(1, max_retries + 1):
try:
log.info(f"Descargando {dest_path.name} (intento {attempt}/{max_retries})...")
with self.session.get(url, stream=True, timeout=45) as r:
r.raise_for_status()
total_size = int(r.headers.get("content-length", 0))
downloaded = 0
with open(temp_dest, "wb") as f:
for chunk in r.iter_content(chunk_size=65536):
self._check_stop()
if chunk:
f.write(chunk)
downloaded += len(chunk)
if total_size:
self._emit(None, f"{dest_path.name}: {downloaded / 1e6:.1f} / {total_size / 1e6:.1f} MB")
if total_size > 0 and downloaded < total_size:
log.warning(f"Descarga incompleta ({downloaded}/{total_size} bytes). Reintentando...")
continue
# Sin content-length, un corte o una página de error del ANDA no se notan arriba
if not zip_valido(temp_dest, dest_path):
log.warning(f"{dest_path.name}: lo descargado no es un ZIP válido. Reintentando...")
continue
temp_dest.replace(dest_path)
size_mb = dest_path.stat().st_size / 1024 / 1024
log.info(f"✓ Descarga completada: {dest_path.name} ({size_mb:.2f} MB)")
return True
except DownloadCancelled:
temp_dest.unlink(missing_ok=True)
raise
except Exception as e:
log.error(f"Error descargando {dest_path.name} (intento {attempt}): {e}")
temp_dest.unlink(missing_ok=True)
time.sleep(2 * attempt)
temp_dest.unlink(missing_ok=True)
return False
def url_del_anda(url: str, base: str = DescargadorANDA.base_url) -> str | None:
"""
El enlace de descarga sacado de una página del ANDA, si apunta al ANDA por https (los relativos se
completan con ``base``); ``None`` si apunta a otro sitio o por http. Así un enlace raro en la página
no hace descargar nada de otro servidor.
"""
completa = urljoin(base + "/", url.strip())
partes = urlparse(completa)
return completa if partes.scheme == "https" and partes.hostname == urlparse(base).hostname else None
def zip_valido(archivo: Path, nombre: Path | None = None) -> bool:
"""
``archivo`` es un ZIP legible, o no se espera que lo sea (``nombre``, por defecto el mismo
``archivo``, no termina en ``.zip``). Detecta descargas cortadas y páginas de error del servidor.
"""
return (nombre or archivo).suffix.lower() != ".zip" or zipfile.is_zipfile(archivo)
def ya_extraido(member: zipfile.ZipInfo, destino: Path) -> bool:
"""``destino`` ya tiene el archivo completo (mismo tamaño que dentro del ZIP)."""
return destino.is_file() and destino.stat().st_size == member.file_size
def extraer_miembro(z: zipfile.ZipFile, member: zipfile.ZipInfo, destino: Path) -> None:
"""
Copia ``member`` a ``destino`` por partes, primero con otro nombre (``.part``) y renombrando
al terminar: si se interrumpe (Ctrl+C, disco lleno), no queda un archivo a medias con el
nombre final que la próxima descarga daría por bueno.
"""
tmp = destino.with_name(destino.name + ".part")
try:
with z.open(member) as src, open(tmp, "wb") as dst:
shutil.copyfileobj(src, dst, 1 << 20)
tmp.replace(destino)
finally:
tmp.unlink(missing_ok=True)