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.
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:
for marco in "|/-\\":
print(f"{marco} Procesando...")| 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:
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:
print(" ".join("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"))⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏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:
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}")[█████░░░░░░░░░░░░░░░░░░░░░░░░░] 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 deround()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 de9.9%a10.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:
import time
inicio = time.monotonic()
# ... trabajo ...
transcurrido = time.monotonic() - inicioLa 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:
def reloj(segundos: float) -> str:
"""Formatea unos segundos como mm:ss."""
minutos, restantes = divmod(int(segundos), 60)
return f"{minutos:02d}:{restantes:02d}"print(reloj(0), reloj(9.7), reloj(65), reloj(3600))00:00 00:09 01:05 60:00divmod 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:
restante = transcurrido / fraccion - transcurridoOcultar 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ódigo | Qué hace |
|---|---|
\033[?25l | Oculta el cursor |
\033[?25h | Lo vuelve a mostrar |
\033[2K | Borra 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:
"""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:
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))[░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 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:16En 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:
for actual, transcurrido in [(3, 1.0), (150, 50.0), (1000, 333.3)]:
print(formato_barra(actual, 1000, transcurrido, ancho=20))[░░░░░░░░░░░░░░░░░░░░] 0.3% 3/1000 00:01 < 05:32
[███░░░░░░░░░░░░░░░░░] 15.0% 150/1000 00:50 < 04:43
[████████████████████] 100.0% 1000/1000 05:33Usarlo en un trabajo de verdad#
Así se ve en un programa que procesa cuarenta ficheros:
"""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:
Encontrados 40 ficheros.
[██████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 35.0% 14/40 00:00 < 00:01La 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 elstartpor defecto, la barra acabaría en 39 de 40.- La barra se dibuja después del trabajo, no antes. Así el
1/40significa "uno hecho" y no "empezando el primero", que es lo que se espera cuando llega al40/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:
\r\033[2K[█░░░...] 2.5% 1/40\r\033[2K[███░░░...] 5.0% 2/40Cuarenta 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:
python3 copiar.py > registro.txt
cat registro.txtBuscando 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.