react tqdm python monkey patching

Python: A little hack to display the progression on your User Interface (UI)

How I use monkey patching to easily display a real-time progress indicator. Spoiler alert: it's tqdm which is monkey-patched.

This time I will talk about the result before explaining things.

Displaying the progression

The User Interface

(It is a Conversational User Interface to be more specific)

Screenshot by the author

Screenshot by the author

You can see:

  • a first task “loading sd-legacy…” that has already been completed and lasted 10s.
  • a second task “Generating image” still in progress. It started 17s ago, is expected to end in 22s. The blue progress bar is updated every time a step (the image generation was configured to use 50 steps) has been completed. The light blue progress bar below is refreshed more often and represents the ‘real time’ progression based on the elapsed time and the expected remaining time.

Adding the progression bar

As for how I add a progress bar in my process, it is as easy as replacing…

image = pipe(
    prompt,
    num_inference_steps=params.num_inference_steps,
    guidance_scale=params.guidance_scale,
    negative_prompt=negative_prompt,
    height=height,
    width=width,
).images[0]

… by:

async with chat_context.add_tqdm_progression(
    context, "Generating image", "agent:system"
):
    image = pipe(
        prompt,
        num_inference_steps=params.num_inference_steps,
        guidance_scale=params.guidance_scale,
        negative_prompt=negative_prompt,
        height=height,
        width=width,
    ).images[0]

For the record, I used this in a personal project to test running stable diffusion from my project (about task orchestration and knowledge management) as a way to find ideas to improve the project itself. It runs on a M1 Mac-mini with 16Gb of unified memory.

The explanation

You most probably have already seen a progression indicator on your terminal when launching some scripts.

Example of a progression indicator visible in a terminal console.

Example of a progression indicator visible in a terminal console.

Do you know that most of the time (maybe always) it is the same library that is used to display those progress indicators?

TQDM..

…is the name of this library. It is so easy to use that is as become quite a standard.

TQDM is easy to use, all it takes is an iterable!

TQDM is easy to use, all it takes is an iterable!

There are many options you can use like a description, but that’s not what we want to cover here.

Monkey patching…

…is the act of changing the behavior of a code during its execution.

If you know that a library will be used, you can change its behavior in order to achieve something else.

A year ago (septembre 2024) some user encountered the same kind of needs that I had and proposed a solution to hack tqdm to display the progression in streamlit.

streamlit is a python framework to easily build a web application it is mostly used for data related task and is not really intended to be use in a production context.

The idea he proposed was quite simple and elegant: to monkey patch tqdm in order to be notified when the progression changed and so update the UI in response.

Well, I quite did the same thing.

My own version

I use a context manager to both activate/deactivate the monkey patching.

You won’t be able to use this code directly on your project but it can inspire you to create your version based on your own needs.

The context manager

import asyncio
from threading import Thread
from types import TracebackType
from typing import Protocol, TYPE_CHECKING, Optional, Any

from applications.chat.models.chat_progress_message import ChatProgressMessage

if TYPE_CHECKING:
    from tqdm import tqdm
    from core.context.context import Context
    from applications.chat.context.chat_context import ChatContext

class TQDMCallback(Protocol):
    def __call__(
        self, progress: float, elapsed: float, remaining: Optional[float], done: bool
    ): ...

class ProgressionContext:
    """
    Represents a context manager for handling chat progression updates within an asynchronous
    environment.

    This class encapsulates the logic required to manage and update progression messages
    in a chat interface. It integrates functionality for real-time feedback regarding
    the update state of a process using mechanisms such as the `tqdm` library, while
    ensuring that all updates are efficiently handled and displayed to the user.

    Attributes:
        context (Context): The main context that this progression context is associated with.
        chat_context (ChatContext): Cast version of the provided context specifically
            adapted for handling chat-related information and processes.
        text (str): The initial text content for the progression message.
        from_user (str): Identifier for the user initiating the progression context.
        kwargs (dict): Additional keyword arguments providing extended customization
            for the progression message handling logic.
        message (Optional[ChatProgressMessage]): Represents the message being updated
            throughout the lifecycle of the context manager.
    """
    def __init__(self, context: "Context", text: str, from_user: str, **kwargs: Any):
        from applications.chat.context.chat_context import ChatContext
        self.context = context
        self.chat_context: "ChatContext" = context.cast_as(ChatContext)
        self.text = text
        self.from_user = from_user
        self.kwargs = kwargs
        self.message: Optional[ChatProgressMessage] = None

    async def __aenter__(self):
        """
        Manages the asynchronous context for progression handling and updating chat messages.

        This method sets up a chat message instance for progression updates and links it to
        the chat context. It also modifies the behavior of the `tqdm` library to provide
        real-time updates on progression events within the asynchronous environment.

        Returns:
            The instance itself to ensure it can be properly used in an asynchronous
            context management workflow.

        Raises:
            Any exceptions emitted during the setup or execution of this method will
            propagate as they are not explicitly handled within this block.
        """
        self.message = ChatProgressMessage(
            chat_id=self.chat_context.chat_id,
            text=self.text,
            message_index=self.chat_context.message_count,
            from_user=self.from_user,
            **self.kwargs,
        )

        await self.chat_context.add_message(self.context, self.message, True)

        def callback(
            progress: float, elapsed: float, remaining: Optional[float], done: bool
        ):
            # TODO: use a single thread and an event queue
            async def _emit_progression():
                self.message.update_progress(progress, elapsed, remaining, done)

                await self.chat_context.update_message(self.context, self.message)

            def target():
                asyncio.run(_emit_progression())

            t = Thread(target=target, daemon=True)

            t.start()

        from tqdm.auto import tqdm

        self._original_update = tqdm.update
        self._original_del = tqdm.__del__

        def patched_update(tqdm_instance: "tqdm", n: float = 1):
            if tqdm_instance.n is not None and tqdm_instance.total is not None:
                rate = tqdm_instance.format_dict["rate"]

                elapsed = tqdm_instance.format_dict["elapsed"]
                remaining = (
                    (tqdm_instance.total - tqdm_instance.n) / rate if rate and tqdm_instance.total else None
                )

                callback(tqdm_instance.n / tqdm_instance.total, elapsed, remaining, False)

            return self._original_update(tqdm_instance, n)

        def __del__(tqdm: "tqdm"):
            callback(1, tqdm.format_dict["elapsed"], 0, True)
            self._original_del(tqdm)

        tqdm.update = patched_update
        tqdm.__del__ = __del__

        return self

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: TracebackType | None,
    ):
        """
        Manages the asynchronous context exit workflow for the object. This method is
        called when the asynchronous context manager is exited. It handles setting
        errors to a message object if an exception occurs, as well as restoring
        original functionality to overridden `tqdm` methods.

        Args:
            exc_type (type[BaseException] | None): The type of the exception being
                handled, or None if no exception occurred.
            exc_val (BaseException | None): The exception instance being handled,
                or None if no exception occurred.
            exc_tb (TracebackType | None): The traceback object associated with the
                current exception, or None if no exception occurred.
        """
        message = self.message
        if exc_val is not None and message:
            # if an exception occurred, we set the error message and update the message
            message.set_error(str(exc_val))
            await self.chat_context.update_message(self.context, message)

        from tqdm.auto import tqdm

        tqdm.update = self._original_update
        tqdm.__del__ = self._original_del

Preparing the context manager

class ChatContext(Context):
    def add_tqdm_progression(
        self,
        context: "Context",
        message: str,
        from_user: str,
        **kwargs,
    ) -> ProgressionContext:

        return ProgressionContext(context, message, from_user, **kwargs)

This method is not required anymore but is kept due to a planned change in my todo list about how the contexts are handled.

The ChatProgressMessage

It defines what is persisted and transmitted to the user interface, either directly using a web-socket or indirectly in worker mode using first a RabbitMQ broker.

import time
from enum import StrEnum
from typing import Mapping, Any, Optional, Dict

from pydantic import Field

from applications.chat.models.a_chat_message import AChatMessage

class ProgressionStatus(StrEnum):
    IDLE = "idle"
    RUNNING = "running"
    ENDED = "ended"
    FAILED = "failed"

class ChatProgressMessage(AChatMessage):
    META_MODEL = "chat_progress_message"

    type: str = "progress"
    text: str
    progress: Optional[float] = 0

    started_at: Optional[float] = Field(None, alias="startedAt")
    ends_at: Optional[float] = Field(None, alias="endsAt")

    progression_status: ProgressionStatus = Field(
        ProgressionStatus.IDLE, alias="progressionStatus"
    )
    error: Optional[str] = None

    def update_progress(
        self,
        progress: float,
        elapsed_time: float,
        remaining_time: Optional[float],
        finished: bool,
    ):
        self.progress = progress
        if finished:
            if self.progression_status != ProgressionStatus.FAILED:
                self.progression_status = ProgressionStatus.ENDED
        else:
            if self.progression_status == ProgressionStatus.IDLE:
                self.started_at = time.time() - elapsed_time
                self.progression_status = ProgressionStatus.RUNNING
            if remaining_time is not None:
                self.ends_at = time.time() + remaining_time

    def set_error(self, error: str):
        self.error = error
        self.ends_at = time.time()
        self.progression_status = ProgressionStatus.FAILED

    def as_dict(self, **kwargs) -> Mapping[str, Any]:
        # TODO: remove this implementation to use the default one from AModel
        dict_value: Dict[str, Any] = {
            **super().as_dict(**kwargs),
            "text": self.text,
            "progressionStatus": self.progression_status.value,
        }

        if self.progress is not None:
            dict_value["progress"] = self.progress
        if self.error:
            dict_value["error"] = self.error
        if self.started_at is not None:
            dict_value["startedAt"] = self.started_at
        if self.ends_at is not None:
            dict_value["endsAt"] = self.ends_at

        return dict_value

What is interesting here is that instead of transmitting the elapsed and remaining durations, what we work with are timestamps: numbers representing:

  • when the task started,
  • when the task ended/is expected to end.

The React part (user interface)

When the task is running (and only when the task is running), an interval will trigger a re-render every 750ms, effectively updating:

  • the elapsed duration,
  • the remaining duration,
  • the ‘buffer’ / time based progression indicator.
import Box from "@mui/material/Box";
import Markdown from "react-markdown";
import CheckIcon from "@mui/icons-material/Check";
import HourglassBottomIcon from "@mui/icons-material/HourglassBottom";
import { LinearProgress } from "react-admin";
import Typography from "@mui/material/Typography";
import React, { FC, useEffect, useMemo, useState } from "react";

import { ProgressMessageType } from "./types";
import { formatDuration } from "./utils";

type ProgressionMessageProps = {
  message: ProgressMessageType;
};

const ProgressionMessage: FC<ProgressionMessageProps> = (props) => {
  const { message } = props;

  const [now, setNow] = useState<number>(0);

  // the started duration is the time spent since the task started
  // (or the total duration of the task if it has already ended)
  const startedDuration = useMemo(() => {
    switch (message.progressionStatus) {
      case "ended":
      case "failed":
        if (message.startedAt === undefined || message.endsAt === undefined) {
          return -1;
        }

        return Math.round(message.endsAt - message.startedAt);
      case "idle":
        return -1;
      case "running":
        if (now === 0 || message.startedAt === undefined) {
          return -1;
        }
        return Math.round(now - message.startedAt);
    }
  }, [now, message.progressionStatus, message.endsAt, message.startedAt]);

  // the remaining duration is the time remaining before the task ends
  const remainingDuration = useMemo(() => {
    switch (message.progressionStatus) {
      case "ended":
      case "failed":
        return 0;
      case "idle":
        return -1;
      case "running":
        if (now === 0 || message.endsAt === undefined) {
          return -1;
        }
        return Math.round(message.endsAt - now);
    }
  }, [now, message.progressionStatus, message.endsAt]);

  // buffer is the progress of the task based on the current time
  const buffer = useMemo(() => {
    switch (message.progressionStatus) {
      case "ended":
      case "failed":
        return message.progress;
      case "idle":
        return -1;
      case "running":
        if (now === 0 || startedDuration < 0 || remainingDuration < 0) {
          return message.progress;
        }

        return Math.min(
          1,
          startedDuration / (startedDuration + remainingDuration),
        );
    }
  }, [
    message.progressionStatus,
    message.progress,
    now,
    startedDuration,
    remainingDuration,
  ]);

  useEffect(() => {
    // no need to update if not running
    if (message.progressionStatus !== "running") {
      return;
    }

    const interval = setInterval(() => {
      // it will trigger a rerender each time
      setNow(Date.now() / 1000);
    }, 750);
    return () => {
      clearInterval(interval);
    };
  }, [message.progressionStatus]);

  // TODO: handle displaying error messages

  return (
    <>
      <Box sx={{ display: "flex", alignItems: "center" }}>
        <Box sx={{ width: "100%", mr: 1 }}>
          <Markdown>{message.text}</Markdown>
        </Box>
        <Box sx={{ minWidth: 35 }}>
          {message.progressionStatus === "ended" && (
            <CheckIcon color="success" />
          )}
          {message.progressionStatus === "running" && (
            <HourglassBottomIcon color="primary" />
          )}
        </Box>
      </Box>
      <Box sx={{ width: "100%" }}>
        {message.progress === undefined && (
          <LinearProgress variant={"indeterminate"} />
        )}
        {message.progress !== undefined &&
          (buffer === undefined || buffer < 0) && (
            <LinearProgress
              variant="determinate"
              value={message.progress * 100}
              sx={{
                width: "100%",
                height: 10,
              }}
            />
          )}
        {message.progress !== undefined &&
          buffer !== undefined &&
          buffer >= 0 && (
            <LinearProgress
              variant="buffer"
              value={message.progress * 100}
              valueBuffer={buffer * 100}
              sx={{
                width: "100%",
                height: 10,
              }}
            />
          )}
      </Box>
      <Box sx={{ display: "flex", alignItems: "center" }}>
        <Box sx={{ width: "50%" }}>
          <Typography
            variant="body2"
            sx={{ color: "text.secondary" }}
          >{`${formatDuration(startedDuration)}`}</Typography>
        </Box>

        <Box sx={{ width: "50%" }}>
          <Typography
            variant="body2"
            sx={{ color: "text.secondary" }}
          >{`${formatDuration(remainingDuration)}`}</Typography>
        </Box>
      </Box>
    </>
  );
};

export default ProgressionMessage;

To conclude

I feel like I’ve forgotten something…

the result image!

"A duck flying above a lake" using stable diffusion 1.5

“A duck flying above a lake” using stable diffusion 1.5

The role of a software developer is not about writing the same thing again and again (which tend to be forgotten by those relying too much on AI source code generation), it is about writing functions so that you can reuse them easily multiple times on your software!

Use the DRY (Don’t repeat yourself) principle, not the WET one (write everything twice, we enjoy typing, waste everyone’s time)

That’s all folks!