Saltar al contenido
🐍 Curso de Python
/

Módulo 2 · Lección 7 de 28

Spinners y barras de progreso

Cómo decirle a quien espera que el programa sigue vivo: spinners, porcentajes, tiempo transcurrido y estimación de lo que falta.

⏱ 19 min de lectura

Un programa que tarda treinta segundos sin escribir nada es indistinguible de un programa colgado. Quien lo está usando no sabe si esperar o matarlo con Ctrl + C, y en la duda lo mata. Eso no es un problema estético: es un programa que no se puede usar.

La solución cuesta muy poco. Con el \r y el flush de la lección de salida en pantalla ya tienes todo lo necesario para escribir en una sola línea que se va actualizando, y con eso se hacen tanto un spinner que gira como una barra con su porcentaje y su tiempo restante.

El spinner#

Un spinner son cuatro caracteres que se van sustituyendo en el mismo sitio. Estos son los cuatro clásicos:

python
for marco in "|/-\\":
        print(f"{marco} Procesando...")
Salida en pantalla
| Procesando...
/ Procesando...
- Procesando...
\ Procesando...

Ese "|/-\\" lleva la barra invertida duplicada porque, como ya sabes de la lección de cadenas, una sola empezaría una secuencia de escape.

Puestos uno encima de otro en vez de uno debajo de otro, esos cuatro fotogramas parecen una línea que gira. Y para poner uno encima de otro solo hace falta \r, no bajar de línea y vaciar el búfer en cada vuelta:

python
import itertools
import time

for marco in itertools.cycle("|/-\\"):
        print(f"\r{marco} Procesando...", end="", flush=True)
    time.sleep(0.1)

itertools.cycle da vueltas a la cadena para siempre, así que ese bucle no termina nunca; dentro de un momento le pondremos una condición de salida. En la pantalla se ve una sola línea, | Procesando..., con el primer carácter girando diez veces por segundo.

También hay juegos de fotogramas más finos, como los puntos braille, que son diez y dan una sensación de giro mucho más suave:

python
print(" ".join("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"))
Salida en pantalla
⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏

Son caracteres Unicode normales, uno solo cada uno, así que no descuadran nada. Necesitan una tipografía que los tenga, que es lo habitual en las terminales de hoy.

Una barra de progreso, pieza a pieza#

El spinner dice "estoy vivo". La barra dice además "voy por aquí y me queda esto", y para eso hace falta saber el total. Son cuatro cálculos:

python
actual = 7
total = 40
ancho = 30

fraccion = actual / total
llenos = round(ancho * fraccion)
dibujo = "█" * llenos + "░" * (ancho - llenos)
print(f"[{dibujo}] {fraccion:6.1%}  {actual}/{total}")
Salida en pantalla
[█████░░░░░░░░░░░░░░░░░░░░░░░░░]  17.5%  7/40
  • La fracción es una simple división, y el formato % de las f-strings la convierte en porcentaje sin tener que multiplicar por cien.
  • Los bloques llenos salen de multiplicar el ancho por la fracción, redondeando. Con int() en vez de round() la barra iría siempre un poco por detrás.
  • Los vacíos son el resto, ancho - llenos, y así la barra mide siempre lo mismo, que es imprescindible para que no baile al reescribirse.
  • El 6.1% reserva seis columnas para el porcentaje. Sin ese ancho, la línea se movería al pasar de 9.9% a 10.0%.

Los caracteres █ y ░ son los de dibujo de la lección anterior. Si tu terminal no los tiene, # y - hacen el mismo papel.

El tiempo transcurrido y el que falta#

Para el tiempo hay que apuntar cuándo se empezó y restar. La función que lo mide es time.monotonic, y no time.time:

python
import time

inicio = time.monotonic()
# ... trabajo ...
transcurrido = time.monotonic() - inicio

La diferencia importa. time.time devuelve la hora del reloj del sistema, que puede cambiar de golpe si el ordenador se sincroniza por red o cambia la hora, y entonces tu barra diría que lleva menos veinte segundos. time.monotonic es un contador que solo avanza, y sirve exactamente para esto: medir intervalos.

Los segundos se presentan en minutos y segundos con una función de dos líneas:

python
def reloj(segundos: float) -> str:
        """Formatea unos segundos como mm:ss."""
    minutos, restantes = divmod(int(segundos), 60)
    return f"{minutos:02d}:{restantes:02d}"
python
print(reloj(0), reloj(9.7), reloj(65), reloj(3600))
Salida en pantalla
00:00 00:09 01:05 60:00

divmod devuelve de una vez el cociente y el resto, que es justo lo que hace falta. El 02d es el relleno con ceros de la lección de f-strings.

La estimación de lo que queda es una regla de tres: si en el tiempo transcurrido se ha hecho una fracción del trabajo, el total llevará transcurrido / fraccion, y lo que falta es eso menos lo ya gastado:

python
restante = transcurrido / fraccion - transcurrido

Ocultar el cursor#

Mientras la barra se redibuja, el cursor de la terminal se queda al final de la línea parpadeando, y con un spinner delante llama bastante la atención. Dos códigos ANSI de los de la lección anterior lo arreglan:

CódigoQué hace
\033[?25lOculta el cursor
\033[?25hLo vuelve a mostrar
\033[2KBorra la línea entera, sin mover el cursor

Ese \033[2K es la forma limpia de resolver el problema de los restos que dejaba \r: en vez de rellenar con espacios a mano, se borra la línea y se escribe encima.

El módulo progreso.py#

Con todo eso, el módulo reutilizable:

progreso.py
"""Spinners y barras de progreso con la biblioteca estándar."""

import itertools
import shutil
import sys
import time

LLENO = "█"
VACIO = "░"
GIROS = "|/-\\"
BRAILLE = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
OCULTAR_CURSOR = "\033[?25l"
MOSTRAR_CURSOR = "\033[?25h"
BORRAR_LINEA = "\r\033[2K"


def es_terminal() -> bool:
        """True si la salida es una terminal y no un fichero o una tubería."""
    return sys.stdout.isatty()


def reloj(segundos: float) -> str:
        """Formatea unos segundos como mm:ss."""
    minutos, restantes = divmod(int(segundos), 60)
    return f"{minutos:02d}:{restantes:02d}"


def formato_barra(actual: int, total: int, transcurrido: float, ancho: int = 30) -> str:
        """Construye la línea de la barra: bloques, porcentaje, contador y tiempos."""
    fraccion = actual / total if total else 1.0
    llenos = round(ancho * fraccion)
    dibujo = LLENO * llenos + VACIO * (ancho - llenos)
    cifras = f"{actual:>{len(str(total))}}/{total}"
    if actual and fraccion < 1:
                restante = transcurrido / fraccion - transcurrido
        tiempos = f"{reloj(transcurrido)} < {reloj(restante)}"
    else:
                tiempos = reloj(transcurrido)
    return f"[{dibujo}] {fraccion:6.1%}  {cifras}  {tiempos}"


def ancho_disponible(reservado: int = 28) -> int:
        """Columnas que le quedan a los bloques después del texto de la derecha."""
    return max(10, shutil.get_terminal_size().columns - reservado)


def barra(actual: int, total: int, inicio: float | None = None, ancho: int | None = None) -> None:
        """Redibuja la barra en la línea actual. Al llegar al total, salta de línea."""
    if not es_terminal():
                return
    if ancho is None:
                ancho = ancho_disponible()
    transcurrido = time.monotonic() - inicio if inicio else 0.0
    linea = formato_barra(actual, total, transcurrido, ancho)
    print(f"{BORRAR_LINEA}{linea}", end="", flush=True)
    if actual >= total:
                print()


def girar(mensaje: str, segundos: float, marcos: str = BRAILLE) -> None:
        """Enseña un spinner con el mensaje durante los segundos indicados."""
    if not es_terminal():
                print(mensaje)
        time.sleep(segundos)
        return
    final = time.monotonic() + segundos
    print(OCULTAR_CURSOR, end="")
    try:
                for marco in itertools.cycle(marcos):
                        if time.monotonic() >= final:
                                break
            print(f"\r{marco} {mensaje}", end="", flush=True)
            time.sleep(0.08)
    finally:
                print(f"{BORRAR_LINEA}{MOSTRAR_CURSOR}", end="", flush=True)

La separación entre formato_barra y barra no es un adorno. La primera construye una cadena y no imprime nada, así que se puede comprobar fotograma a fotograma sin montar ninguna animación:

python
from progreso import formato_barra

for actual, transcurrido in [(0, 0.0), (1, 0.4), (7, 2.8), (25, 10.0), (40, 16.0)]:
        print(formato_barra(actual, 40, transcurrido))
Salida en pantalla
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░]   0.0%   0/40  00:00
[█░░░░░░░░░░░░░░░░░░░░░░░░░░░░░]   2.5%   1/40  00:00 < 00:15
[█████░░░░░░░░░░░░░░░░░░░░░░░░░]  17.5%   7/40  00:02 < 00:13
[███████████████████░░░░░░░░░░░]  62.5%  25/40  00:10 < 00:06
[██████████████████████████████] 100.0%  40/40  00:16

En la terminal esos cinco fotogramas no son cinco líneas: son la misma línea, cambiando. Aquí se ven apilados porque es la única forma de imprimirlos en una página.

Fíjate en los dos extremos. En el primero no hay estimación, porque con cero unidades hechas no hay nada de lo que deducirla, y es también el caso que evita la división por cero. En el último tampoco, porque ya no queda nada: solo el tiempo total que ha costado.

El contador {actual:>{len(str(total))}}/{total} merece una mirada: reserva para el número actual tantas columnas como dígitos tenga el total, así que ni la barra ni el porcentaje se mueven al pasar de 9 a 10. Con un total de mil se ve mejor:

python
for actual, transcurrido in [(3, 1.0), (150, 50.0), (1000, 333.3)]:
        print(formato_barra(actual, 1000, transcurrido, ancho=20))
Salida en pantalla
[░░░░░░░░░░░░░░░░░░░░]   0.3%     3/1000  00:01 < 05:32
[███░░░░░░░░░░░░░░░░░]  15.0%   150/1000  00:50 < 04:43
[████████████████████] 100.0%  1000/1000  05:33

Usarlo en un trabajo de verdad#

Así se ve en un programa que procesa cuarenta ficheros:

copiar.py
"""Uso de progreso.py en un trabajo largo de verdad."""

import time

from progreso import barra, girar

ficheros = [f"informe-{numero:03d}.csv" for numero in range(1, 41)]

girar("Buscando ficheros", 1.5)
print(f"Encontrados {len(ficheros)} ficheros.")

inicio = time.monotonic()
for numero, fichero in enumerate(ficheros, start=1):
        time.sleep(0.05)  # aquí iría el trabajo de verdad
    barra(numero, len(ficheros), inicio)

print("Terminado.")

Durante el primer segundo y medio gira el spinner. Después, la barra avanza en una sola línea hasta llenarse, y al completarse salta de línea ella sola para que el Terminado. no la pise. Este es el aspecto de la pantalla en un momento cualquiera de la segunda fase, con una ventana de 80 columnas:

Salida en pantalla
Encontrados 40 ficheros.
[██████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░]  35.0%  14/40  00:00 < 00:01

La barra mide 52 bloques y no 30, porque ancho_disponible ha restado del ancho de la ventana lo que ocupan el porcentaje, el contador y los tiempos. En una ventana más estrecha se dibujaría más corta, sin descuadrarse.

Dos detalles de la llamada en el bucle:

  • enumerate(ficheros, start=1) da el número de fichero empezando por uno. Con el start por defecto, la barra acabaría en 39 de 40.
  • La barra se dibuja después del trabajo, no antes. Así el 1/40 significa "uno hecho" y no "empezando el primero", que es lo que se espera cuando llega al 40/40.

Nada de esto sirve si no hay terminal#

Es la misma historia que con el color, y por eso barra y girar empiezan preguntando por es_terminal(). Una barra de progreso redirigida a un fichero deja algo así dentro:

text
\r\033[2K[█░░░...]   2.5%   1/40\r\033[2K[███░░░...]   5.0%   2/40

Cuarenta fotogramas de basura, uno detrás de otro, porque \r no borra nada en un fichero: solo es un carácter más. Lo mismo ocurre en los registros de un servidor o de un sistema de integración continua, que es donde más molesta.

Con la comprobación puesta, el mismo programa sin terminal escribe lo justo:

bash
python3 copiar.py > registro.txt
cat registro.txt
Salida en pantalla
Buscando ficheros
Encontrados 40 ficheros.
Terminado.

El spinner se ha convertido en una línea de texto y la barra ha desaparecido por completo, que es exactamente lo que quiere quien lea ese fichero.