14 - Problema resuelto: Compresor concurrente de archivos con Tkinter

Objetivo: crear una interfaz con Tkinter que permita seleccionar múltiples archivos, comprimirlos en un .zip y actualizar el progreso sin congelar la GUI. Se practica el patrón hilo de trabajo + queue.Queue + after() para comunicar avances al hilo principal.

14.1 Descripción general

  • Ventana con botones para Seleccionar archivos, Elegir carpeta destino, Comprimir y Cancelar.
  • Listbox para mostrar rutas elegidas, barra de progreso y etiqueta de estado.
  • La compresión corre en un threading.Thread. El hilo de la GUI sólo dibuja y procesa la cola de eventos.

14.2 Interfaz y experiencia

  • Progreso global: una barra ttk.Progressbar de 0 a N archivos.
  • Estado textual: mensajes tipo "Esperando archivos", "Comprimiendo 2 de 5: datos.csv", "Listo: comprimido.zip".
  • Botones seguros: mientras corre la compresión se deshabilita "Comprimir" y se habilita "Cancelar".

14.3 Flujo concurrente

  1. El usuario elige archivos y una carpeta de destino.
  2. Al presionar Comprimir, se lanza un hilo que crea el ZIP y escribe cada archivo.
  3. Tras cada archivo comprimido, el hilo publica en queue.Queue el avance (número, nombre, errores).
  4. La GUI lee la cola con root.after(...), actualiza progreso y detecta fin o cancelación.
  5. Si el usuario cancela, se fija un threading.Event y el hilo corta el bucle.

14.4 Pasos de implementación

  • Armar la ventana con botones, listbox, progressbar y etiqueta de estado.
  • Configurar funciones de selección de archivos y carpeta destino (filedialog).
  • Crear la cola de mensajes y un Event para cancelación.
  • Iniciar el hilo de trabajo que escribe el ZIP con zipfile.ZipFile y envía eventos.
  • En el hilo principal, consultar la cola periódicamente para reflejar progreso y reactivar controles.

14.5 Codificación

Guarda el siguiente archivo como compresor_gui.py. Usa solo biblioteca estándar.

import queue
import threading
import zipfile
from pathlib import Path
import tkinter as tk
from tkinter import filedialog, messagebox, ttk


class CompresorGUI:
    def __init__(self):
        self.root = tk.Tk()
        self.root.title("Compresor concurrente")
        self.root.geometry("620x420")

        self.archivos = []
        self.destino = None
        self.trabajando = False
        self.cancel_event = threading.Event()
        self.mensajes = queue.Queue()

        self._build_widgets()
        self.root.after(150, self._procesar_cola)

    def _build_widgets(self):
        frm = ttk.Frame(self.root, padding=10)
        frm.pack(fill="both", expand=True)

        botones = ttk.Frame(frm)
        botones.pack(fill="x", pady=(0, 6))

        ttk.Button(botones, text="Seleccionar archivos", command=self.seleccionar_archivos).pack(side="left", padx=4)
        ttk.Button(botones, text="Elegir carpeta destino", command=self.seleccionar_destino).pack(side="left", padx=4)

        self.btn_comprimir = ttk.Button(botones, text="Comprimir", command=self.iniciar_compresion, state="disabled")
        self.btn_comprimir.pack(side="right", padx=4)

        self.btn_cancelar = ttk.Button(botones, text="Cancelar", command=self.cancelar, state="disabled")
        self.btn_cancelar.pack(side="right", padx=4)

        ttk.Label(frm, text="Archivos seleccionados:").pack(anchor="w")
        self.lista = tk.Listbox(frm, height=8)
        self.lista.pack(fill="both", expand=True, pady=4)

        barra = ttk.Frame(frm)
        barra.pack(fill="x", pady=6)
        self.progreso = ttk.Progressbar(barra, length=100, mode="determinate")
        self.progreso.pack(fill="x")

        self.estado = ttk.Label(frm, text="Esperando archivos")
        self.estado.pack(anchor="w")

    def seleccionar_archivos(self):
        rutas = filedialog.askopenfilenames(title="Elige archivos a comprimir")
        if rutas:
            self.archivos = list(rutas)
            self.lista.delete(0, tk.END)
            for ruta in self.archivos:
                self.lista.insert(tk.END, ruta)
            self.estado.config(text=f"{len(self.archivos)} archivo(s) listo(s).")
            self.btn_comprimir.config(state="normal")

    def seleccionar_destino(self):
        carpeta = filedialog.askdirectory(title="Carpeta destino del ZIP")
        if carpeta:
            self.destino = Path(carpeta)
            self.estado.config(text=f"Destino: {self.destino}")

    def iniciar_compresion(self):
        if not self.archivos:
            messagebox.showwarning("Sin archivos", "Selecciona al menos un archivo.")
            return
        if self.trabajando:
            return

        self.trabajando = True
        self.cancel_event.clear()
        self.progreso["value"] = 0
        self.progreso["maximum"] = len(self.archivos)
        self.estado.config(text="Iniciando compresi\u00f3n...")
        self.btn_comprimir.config(state="disabled")
        self.btn_cancelar.config(state="normal")

        destino = self.destino or Path(self.archivos[0]).parent
        nombre_zip = destino / "comprimido.zip"

        hilo = threading.Thread(
            target=self._trabajo_compresion,
            args=(self.archivos.copy(), nombre_zip, self.cancel_event, self.mensajes),
            daemon=True,
        )
        hilo.start()

    def cancelar(self):
        if self.trabajando:
            self.cancel_event.set()
            self.estado.config(text="Cancelando... espera al cierre limpio.")

    @staticmethod
    def _trabajo_compresion(archivos, ruta_zip, cancel_event, mensajes):
        errores = []
        try:
            with zipfile.ZipFile(ruta_zip, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
                for i, ruta in enumerate(archivos, start=1):
                    if cancel_event.is_set():
                        mensajes.put({"tipo": "cancelado", "procesados": i - 1})
                        return
                    try:
                        zf.write(ruta, arcname=Path(ruta).name)
                        mensajes.put({"tipo": "progreso", "procesados": i, "total": len(archivos), "actual": Path(ruta).name})
                    except Exception as exc:  # pragma: no cover - gui
                        errores.append(f"{ruta}: {exc}")
                        mensajes.put({"tipo": "progreso", "procesados": i, "total": len(archivos), "actual": Path(ruta).name, "error": True})
        except Exception as exc:  # pragma: no cover - gui
            mensajes.put({"tipo": "error_fatal", "detalle": str(exc)})
            return

        mensajes.put({"tipo": "final", "zip": str(ruta_zip), "errores": errores})

    def _procesar_cola(self):
        try:
            while True:
                msg = self.mensajes.get_nowait()
                self._manejar_mensaje(msg)
        except queue.Empty:
            pass
        finally:
            self.root.after(150, self._procesar_cola)

    def _manejar_mensaje(self, msg):
        tipo = msg.get("tipo")
        if tipo == "progreso":
            self.progreso["value"] = msg["procesados"]
            nombre = msg.get("actual", "")
            texto = f"Comprimiendo {msg['procesados']} de {msg['total']}: {nombre}"
            if msg.get("error"):
                texto += " (error, se omite)"
            self.estado.config(text=texto)
        elif tipo == "final":
            self.estado.config(text=f"Listo. ZIP en: {msg['zip']}")
            self._fin_trabajo()
            if msg["errores"]:
                messagebox.showwarning("Finalizado con errores", "\n".join(msg["errores"]))
        elif tipo == "cancelado":
            self.estado.config(text=f"Operaci\u00f3n cancelada. Procesados: {msg.get('procesados', 0)}")
            self._fin_trabajo()
        elif tipo == "error_fatal":
            self.estado.config(text="Error al crear el ZIP.")
            messagebox.showerror("Error", msg.get("detalle", ""))
            self._fin_trabajo()

    def _fin_trabajo(self):
        self.trabajando = False
        self.btn_comprimir.config(state="normal")
        self.btn_cancelar.config(state="disabled")

    def run(self):
        self.root.mainloop()


if __name__ == "__main__":
    app = CompresorGUI()
    app.run()
Interfaz del compresor concurrente con progreso y botones de control

14.6 Explicación de componentes

  • Hilo de trabajo: encapsula la compresión para no bloquear el event loop de Tkinter.
  • queue.Queue: canal seguro entre hilo de trabajo y GUI; evita tocar widgets desde el hilo secundario.
  • after(150,...): sondea la cola sin bloquear; 150 ms es suficiente para feedback fluido.
  • Cancelación: threading.Event se consulta en cada iteración; si está activado se abandona el bucle y se notifica.
  • Manejo de errores: cada fallo se acumula en errores; al finalizar se avisa al usuario sin detener toda la compresión.

14.7 Variantes y mejoras opcionales

  • Agregar barra de progreso por archivo (usando tamaño de bytes y zf.open con shutil.copyfileobj).
  • Permitir elegir "un zip por archivo" creando varios ZIP en el destino.
  • Registrar log en un archivo log.txt con logging para depuración.
  • UI: resaltar el archivo actual en el Listbox usando selection_set.

14.8 Qué se evalúa

  • Separación de hilos: ninguna operación pesada toca widgets.
  • Comunicación segura: la cola transporta progreso y errores; la GUI solo lee en su hilo.
  • Estados claros: botones deshabilitados durante el trabajo, mensajes consistentes.
  • Robustez: manejo de cancelación y errores parciales sin crashear la app.