Tareas en segundo plano con Tcl/Tk (tkinter)

Tareas en segundo plano con Tcl/Tk (tkinter)



Descargas: descargar_archivo.zip.

Durante el desarrollo de una aplicación de escritorio con el módulo estándar tkinter, es usual llegar a la situación en la que una operación «pesada» (esto es, que tarda al menos unos segundos en ejecutarse) congela nuestra ventana y todos los controles, de modo que el usuario no puede seguir interactuando con ella, ni nuestro código puede realizar cambios (como aumentar el valor de una barra de progreso). Por ejemplo, cuando intentamos descargar un archivo vía HTTP, abrir un archivo pesado del sistema, enviar un mail vía SMTP, ejecutar un comando vía subprocess, etc.

Consideremos el siguiente código:

import tkinter as tk
from tkinter import ttk
from urllib.request import urlopen

def download_file():
    info_label["text"] = "Descargando archivo..."
    # Deshabilitar el botón mientras se descarga el archivo.
    download_button["state"] = "disabled"
    url = "https://www.python.org/ftp/python/3.7.2/python-3.7.2.exe"
    filename = "python-3.7.2.exe"
    # Abrir la dirección de URL.
    with urlopen(url) as r:
        with open(filename, "wb") as f:
            # Leer el archivo remoto y escribir el fichero local.
            f.write(r.read())
    info_label["text"] = "¡El archivo se ha descargado!"
    # Restablecer el botón.
    download_button["state"] = "normal"

root = tk.Tk()
root.title("Descargar archivo con Tcl/Tk")
info_label = ttk.Label(text="Presione el botón para descargar el archivo.")
info_label.pack()
download_button = ttk.Button(text="Descargar archivo", command=download_file)
download_button.pack()
root.mainloop()

Aquí tenemos una ventana con un botón para descargar un archivo vía el módulo estándar urllib.request, que al ser presionado ejecuta la función download_file(). Dentro de esta función, la operación pesada ocurra en la línea 14, cuando se llama al método r.read() que se encarga de descargar el contenido del archivo remoto. Nótese que en la línea 6, antes de descargar el archivo, el código deshabilita el botón de descarga, y luego en la 18, una vez descargado, se vuelve a habilitar. No obstante, si corremos el código, vemos que la ventana se congela durante el proceso de descarga, y el usuario nunca ve el botón deshabilitado:

Para notar la diferencia, he aquí cómo se ve un botón deshabilitado:

Ahora bien, ¿por qué ocurre esto? La respuesta es sencilla: como tkinter (específicamente la función mainloop()) y download_file() se ejecutan en el mismo hilo, mientras r.read() (o cualquier otra función pesada) tiene la atención del procesador, Tk no puede realizar sus operaciones cotidianas de responder a los eventos que ocurren en la ventana, de ahí que quede como congelada.

Puesto que Python incluye en su biblioteca estándar el módulo threading, que permite ejecutar nuevos hilos, podríamos usarlo para mover la función download_file() a un hilo independiente, y así no bloquearía el hilo principal del programa, donde corre Tk. Pero la cuestión no es tan sencilla: Tk solo permite modificar los controles de una interfaz desde el mismo hilo que corre la función mainloop() (o sea, no es thread-safe). Por ende, no podríamos cambiar el texto de la etiqueta info_label ni deshabilitar y rehabilitar el botón download_button desde el nuevo hilo.

Una solución para este problema es la siguiente: mover únicamente al nuevo hilo las líneas de download_file() que se relacionan con la descarga del archivo (fundamentalmente r.read(), que es la operación pesada), y dejar en el hilo principal aquellas que se relacionan con Tk (cambiar los estados de los controles). Pero, ¿cómo sabe el hilo principal en qué momento ha terminado la ejecución del hilo? Hay una función para eso: threading.Thread.is_alive(). Resulta, entonces, que podemos chequear cada determinado tiempo si el nuevo hilo ha terminado, para saber si hay que rehabilitar el botón (y otras operaciones que queramos ejecutar al finalizar la tarea pesada). No obstante, para realizar ese chequeo debemos usar la función after() de Tk, que permite programar la ejecución de una función dentro de unos segundos, para evitar emplear un bucle propio que acabe por bloquear igualmente la interfaz.

Sin más, el código es el siguiente:

import threading
import tkinter as tk
from tkinter import ttk
from urllib.request import urlopen


def download_file_worker():
    url = "https://www.python.org/ftp/python/3.7.2/python-3.7.2.exe"
    filename = "python-3.7.2.exe"
    # Abrir la dirección de URL.
    with urlopen(url) as r:
        with open(filename, "wb") as f:
            # Leer el archivo remoto y escribir el fichero local.
            f.write(r.read())


def schedule_check(t):
    """
    Programar la ejecución de la función `check_if_done()` dentro de 
    un segundo.
    """
    root.after(1000, check_if_done, t)


def check_if_done(t):
    # Si el hilo ha finalizado, restaruar el botón y mostrar un mensaje.
    if not t.is_alive():
        info_label["text"] = "¡El archivo se ha descargado!"
        # Restablecer el botón.
        download_button["state"] = "normal"
    else:
        # Si no, volver a chequear en unos momentos.
        schedule_check(t)


def download_file():
    info_label["text"] = "Descargando archivo..."
    # Deshabilitar el botón mientras se descarga el archivo.
    download_button["state"] = "disabled"
    # Iniciar la descarga en un nuevo hilo.
    t = threading.Thread(target=download_file_worker)
    t.start()
    # Comenzar a chequear periódicamente si el hilo ha finalizado.
    schedule_check(t)


root = tk.Tk()
root.title("Descargar archivo con Tcl/Tk")
info_label = ttk.Label(text="Presione el botón para descargar el archivo.")
info_label.pack()
download_button = ttk.Button(text="Descargar archivo", command=download_file)
download_button.pack()
root.mainloop()

Vista previa:

Descargar archivo con tkinter y threading

Usando este código como base, puede moverse cualquier tarea pesada a la función download_file_worker() (considérese cambiar el nombre según corresponda), y luego indicar las operaciones que deben ejecutarse al finalizar dicha función en check_if_done().



1 comentario.

Deja una respuesta