pythonloggingpyqtstdouttqdm

How to correctly redirect stdout, logging and tqdm into a PyQt widget


TL;DR

For answers, see:

  1. my 2019 initially own accepted answer using a text edit and stdout/stderr streams redirections, see https://stackoverflow.com/a/55082521/7237062
  2. my second answer, now marked as the accepted one: a derived and improved approach with a real QProgressBar ! https://stackoverflow.com/a/74091829/7237062

QUESTION

First of all, I know that a lot of questions are similar to this one. But after spending so much time on it, I now look for help from the community.

I developed and use a bunch of python modules that rely on tqdm. I want them to be usable inside Jupyter, in console or with a GUI. Everything works fine in Jupyter or console : there are no collisions between logging/prints and tqdm progress bars. Here is a sample code that shows the console/Jupyter behavior:

# coding=utf-8
from tqdm.auto import tqdm
import time
import logging
import sys
import datetime
__is_setup_done = False


def setup_logging(log_prefix):
    global __is_setup_done

    if __is_setup_done:
        pass
    else:
        __log_file_name = "{}-{}_log_file.txt".format(log_prefix,
                                                      datetime.datetime.utcnow().isoformat().replace(":", "-"))

        __log_format = '%(asctime)s - %(name)-30s - %(levelname)s - %(message)s'
        __console_date_format = '%Y-%m-%d %H:%M:%S'
        __file_date_format = '%Y-%m-%d %H-%M-%S'

        root = logging.getLogger()
        root.setLevel(logging.DEBUG)

        console_formatter = logging.Formatter(__log_format, __console_date_format)

        file_formatter = logging.Formatter(__log_format, __file_date_format)
        file_handler = logging.FileHandler(__log_file_name, mode='a', delay=True)
        # file_handler = TqdmLoggingHandler2(__log_file_name, mode='a', delay=True)
        file_handler.setLevel(logging.DEBUG)
        file_handler.setFormatter(file_formatter)
        root.addHandler(file_handler)

        tqdm_handler = TqdmLoggingHandler()
        tqdm_handler.setLevel(logging.DEBUG)
        tqdm_handler.setFormatter(console_formatter)
        root.addHandler(tqdm_handler)

        __is_setup_done = True

class TqdmLoggingHandler(logging.StreamHandler):

    def __init__(self, level=logging.NOTSET):
        logging.StreamHandler.__init__(self)

    def emit(self, record):
        msg = self.format(record)
        tqdm.write(msg)
        # from https://stackoverflow.com/questions/38543506/change-logging-print-function-to-tqdm-write-so-logging-doesnt-interfere-wit/38739634#38739634
        self.flush()


def example_long_procedure():
    setup_logging('long_procedure')
    __logger = logging.getLogger('long_procedure')
    __logger.setLevel(logging.DEBUG)
    for i in tqdm(range(10), unit_scale=True, dynamic_ncols=True, file=sys.stdout):
        time.sleep(.1)
        __logger.info('foo {}'.format(i))

example_long_procedure()

The obtained output:

2019-03-07 22:22:27 - long_procedure                 - INFO - foo 0
2019-03-07 22:22:27 - long_procedure                 - INFO - foo 1
2019-03-07 22:22:27 - long_procedure                 - INFO - foo 2
2019-03-07 22:22:27 - long_procedure                 - INFO - foo 3
2019-03-07 22:22:27 - long_procedure                 - INFO - foo 4
2019-03-07 22:22:28 - long_procedure                 - INFO - foo 5
2019-03-07 22:22:28 - long_procedure                 - INFO - foo 6
2019-03-07 22:22:28 - long_procedure                 - INFO - foo 7
2019-03-07 22:22:28 - long_procedure                 - INFO - foo 8
2019-03-07 22:22:28 - long_procedure                 - INFO - foo 9
100%|¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦| 10.0/10.0 [00:01<00:00, 9.69it/s]

Now, I'm making a GUI with PyQt that uses code similar to above. Since processing may be long, I used threading in order to avoid freezing HMI during processing. I also used stdout redirection using Queue() towards a Qt QWidget so the user can see what is happenning.

My current use case is 1 single thread that has logs and tqdm progress bars to redirect to 1 dedicated widget. (I'm not looking for multiple threads to feed the widget with multiple logs and multiple tqdm progress bar).

I managed to redirect stdout thanks to the informations from Redirecting stdout and stderr to a PyQt5 QTextEdit from a secondary thread. However, only logger lines are redirected. TQDM progress bar is still directed to the console output.

Here is my current code:

# coding=utf-8

import time
import logging
import sys
import datetime
__is_setup_done = False

from queue import Queue

from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject, QThread, QMetaObject, Q_ARG, Qt
from PyQt5.QtGui import QTextCursor, QFont
from PyQt5.QtWidgets import QTextEdit, QPlainTextEdit, QWidget, QToolButton, QVBoxLayout, QApplication
from tqdm.auto import tqdm


class MainApp(QWidget):
    def __init__(self):
        super().__init__()

        setup_logging(self.__class__.__name__)


        self.__logger = logging.getLogger(self.__class__.__name__)
        self.__logger.setLevel(logging.DEBUG)

        # create console text queue
        self.queue_console_text = Queue()
        # redirect stdout to the queue
        output_stream = WriteStream(self.queue_console_text)
        sys.stdout = output_stream

        layout = QVBoxLayout()

        self.setMinimumWidth(500)

        # GO button
        self.btn_perform_actions = QToolButton(self)
        self.btn_perform_actions.setText('Launch long processing')
        self.btn_perform_actions.clicked.connect(self._btn_go_clicked)

        self.console_text_edit = ConsoleTextEdit(self)

        self.thread_initialize = QThread()
        self.init_procedure_object = InitializationProcedures(self)

        # create console text read thread + receiver object
        self.thread_queue_listener = QThread()
        self.console_text_receiver = ThreadConsoleTextQueueReceiver(self.queue_console_text)
        # connect receiver object to widget for text update
        self.console_text_receiver.queue_element_received_signal.connect(self.console_text_edit.append_text)
        # attach console text receiver to console text thread
        self.console_text_receiver.moveToThread(self.thread_queue_listener)
        # attach to start / stop methods
        self.thread_queue_listener.started.connect(self.console_text_receiver.run)
        self.thread_queue_listener.finished.connect(self.console_text_receiver.finished)
        self.thread_queue_listener.start()

        layout.addWidget(self.btn_perform_actions)
        layout.addWidget(self.console_text_edit)
        self.setLayout(layout)
        self.show()

    @pyqtSlot()
    def _btn_go_clicked(self):
        # prepare thread for long operation
        self.init_procedure_object.moveToThread(self.thread_initialize)
        self.thread_initialize.started.connect(self.init_procedure_object.run)
        self.thread_initialize.finished.connect(self.init_procedure_object.finished)
        # start thread
        self.btn_perform_actions.setEnabled(False)
        self.thread_initialize.start()


class WriteStream(object):
    def __init__(self, q: Queue):
        self.queue = q

    def write(self, text):
        """
        Redirection of stream to the given queue
        """
        self.queue.put(text)

    def flush(self):
        """
        Stream flush implementation
        """
        pass


class ThreadConsoleTextQueueReceiver(QObject):
    queue_element_received_signal = pyqtSignal(str)

    def __init__(self, q: Queue, *args, **kwargs):
        QObject.__init__(self, *args, **kwargs)
        self.queue = q

    @pyqtSlot()
    def run(self):
        self.queue_element_received_signal.emit('---> Console text queue reception Started <---\n')
        while True:
            text = self.queue.get()
            self.queue_element_received_signal.emit(text)

    @pyqtSlot()
    def finished(self):
        self.queue_element_received_signal.emit('---> Console text queue reception Stopped <---\n')


class ConsoleTextEdit(QTextEdit):#QTextEdit):
    def __init__(self, parent):
        super(ConsoleTextEdit, self).__init__()
        self.setParent(parent)
        self.setReadOnly(True)
        self.setLineWidth(50)
        self.setMinimumWidth(1200)
        self.setFont(QFont('Consolas', 11))
        self.flag = False

    @pyqtSlot(str)
    def append_text(self, text: str):
        self.moveCursor(QTextCursor.End)
        self.insertPlainText(text)

def long_procedure():
    setup_logging('long_procedure')
    __logger = logging.getLogger('long_procedure')
    __logger.setLevel(logging.DEBUG)
    for i in tqdm(range(10), unit_scale=True, dynamic_ncols=True):
        time.sleep(.1)
        __logger.info('foo {}'.format(i))


class InitializationProcedures(QObject):
    def __init__(self, main_app: MainApp):
        super(InitializationProcedures, self).__init__()
        self._main_app = main_app

    @pyqtSlot()
    def run(self):
        long_procedure()

    @pyqtSlot()
    def finished(self):
        print("Thread finished !")  # might call main window to do some stuff with buttons
        self._main_app.btn_perform_actions.setEnabled(True)

def setup_logging(log_prefix):
    global __is_setup_done

    if __is_setup_done:
        pass
    else:
        __log_file_name = "{}-{}_log_file.txt".format(log_prefix,
                                                      datetime.datetime.utcnow().isoformat().replace(":", "-"))

        __log_format = '%(asctime)s - %(name)-30s - %(levelname)s - %(message)s'
        __console_date_format = '%Y-%m-%d %H:%M:%S'
        __file_date_format = '%Y-%m-%d %H-%M-%S'

        root = logging.getLogger()
        root.setLevel(logging.DEBUG)

        console_formatter = logging.Formatter(__log_format, __console_date_format)

        file_formatter = logging.Formatter(__log_format, __file_date_format)
        file_handler = logging.FileHandler(__log_file_name, mode='a', delay=True)
        
        file_handler.setLevel(logging.DEBUG)
        file_handler.setFormatter(file_formatter)
        root.addHandler(file_handler)

        tqdm_handler = TqdmLoggingHandler()
        tqdm_handler.setLevel(logging.DEBUG)
        tqdm_handler.setFormatter(console_formatter)
        root.addHandler(tqdm_handler)

        __is_setup_done = True

class TqdmLoggingHandler(logging.StreamHandler):

    def __init__(self, level=logging.NOTSET):
        logging.StreamHandler.__init__(self)

    def emit(self, record):
        msg = self.format(record)
        tqdm.write(msg)
        # from https://stackoverflow.com/questions/38543506/change-logging-print-function-to-tqdm-write-so-logging-doesnt-interfere-wit/38739634#38739634
        self.flush()

if __name__ == '__main__':

    app = QApplication(sys.argv)
    app.setStyle('Fusion')
    tqdm.ncols = 50
    ex = MainApp()
    sys.exit(app.exec_())

Gives:Logging correctly redirected but not TQDM progress bar output

I would like to obtain the exact behavior I would have had strictly invoking the code in console. i.e. expected output in PyQt widget:

---> Console text queue reception Started <---
2019-03-07 19:42:19 - long_procedure                 - INFO - foo 0
2019-03-07 19:42:19 - long_procedure                 - INFO - foo 1
2019-03-07 19:42:19 - long_procedure                 - INFO - foo 2
2019-03-07 19:42:19 - long_procedure                 - INFO - foo 3
2019-03-07 19:42:19 - long_procedure                 - INFO - foo 4
2019-03-07 19:42:19 - long_procedure                 - INFO - foo 5
2019-03-07 19:42:20 - long_procedure                 - INFO - foo 6
2019-03-07 19:42:20 - long_procedure                 - INFO - foo 7
2019-03-07 19:42:20 - long_procedure                 - INFO - foo 8
2019-03-07 19:42:20 - long_procedure                 - INFO - foo 9

100%|################################| 10.0/10.0 [00:01<00:00, 9.16it/s]

Things I tried / explored with no success.

Option 1

This solution Display terminal output with tqdm in QPlainTextEdit does not give the expected results. It works well to redirect outputs containing only tqdm stuff.

The following code does not give the intended behavior, wether it is with QTextEdit or QPlainTextEdit. Only logger lines are redirected.

    # code from this answer
    # https://stackoverflow.com/questions/53381975/display-terminal-output-with-tqdm-in-qplaintextedit
    @pyqtSlot(str)
    def append_text(self, message: str):
        if not hasattr(self, "flag"):
            self.flag = False
        message = message.replace('\r', '').rstrip()
        if message:
            method = "replace_last_line" if self.flag else "append_text"
            QMetaObject.invokeMethod(self,
                                     method,
                                     Qt.QueuedConnection,
                                     Q_ARG(str, message))
            self.flag = True
        else:
            self.flag = False

    @pyqtSlot(str)
    def replace_last_line(self, text):
        cursor = self.textCursor()
        cursor.movePosition(QTextCursor.End)
        cursor.select(QTextCursor.BlockUnderCursor)
        cursor.removeSelectedText()
        cursor.insertBlock()
        self.setTextCursor(cursor)
        self.insertPlainText(text)

However, the above code + adding file=sys.stdout to the tqdm call changes the behavior: tqdm output is redirected to the Qt widget. But in the end, only one line is displayed, and it is either a logger line or a tqdm line (it looks like it depends on which Qt widget I derived).

In the end, changing all tqdm invocations used modules should not be the preferred option.

So the other approach I found is to redirect stderr in the same stream/queue stdout is redirected to. Since tqdm writes to stderr by default, this way all tqdm outputs are redirected to widget.

But I still can’t figure out obtaining the exact output I’m looking for.

This question does not provide a clue on why behavior seems to differ between QTextEdit vs QPlainTextEdit

Option 2

This question Duplicate stdout, stderr in QTextEdit widget looks very similar to Display terminal output with tqdm in QPlainTextEdit and does not answer to my exact problem described above.

Option 3

Trying this solution using contextlib gave me an error due to no flush() method being defined. After fixing, I end up with only tqdm lines and no logger lines.

Option 4

I also tried to intercept the \r character and implement a specific behavior, with not success.

Versions:

tqdm                      4.28.1
pyqt                      5.9.2
PyQt5                     5.12
PyQt5_sip                 4.19.14
Python                    3.7.2

Solution

  • Using QProgressBar

    Long after my inital anwser, I had to think about this again. Don't ask why, but this time I managed to get it with a QProgressBar :)

    The trick (at least with TQDM 4.63.1 and higher), is that there is a property format_dict with almost everything necessary for a progress bar. Maybe we already did have that before, but I missed it the first time ...

    Tested with:

    tqdm=4.63.1
    Qt=5.15.2; PyQt=5.15.6
    coloredlogs=15.0.1
    

    EDIT: modified code working with multiple progress bars here

    https://gist.github.com/LoneWanderer-GH/ec18189a8476adb463531a68430e94a8

    enter image description here

    # This is derived from my own stackoverflow question and answer 
    # Question: https://stackoverflow.com/questions/55050685/how-to-correctly-redirect-stdout-logging-and-tqdm-into-a-pyqt-widget
    # Answer  : https://stackoverflow.com/a/74091829/7237062
    #
    # IMPROVEMENTS here:
    # - captures up to 10 TQDM progress bars
    #
    # ------------- LICENSE -------------
    #  Stack overflow content is supposed to be CC BY-SA 4.0 license: https://creativecommons.org/licenses/by-sa/4.0/
    #  so this applies to the question and answer above
    
    import contextlib
    import logging
    import sys
    from abc import ABC, abstractmethod
    from queue import Queue
    from typing import Dict
    
    from PyQt5 import QtTest
    from PyQt5.QtCore import PYQT_VERSION_STR, pyqtSignal, pyqtSlot, QObject, Qt, QT_VERSION_STR, QThread
    from PyQt5.QtWidgets import QApplication, QGridLayout, QPlainTextEdit, QProgressBar, QToolButton, QVBoxLayout, QWidget
    
    __CONFIGURED = False
    
    
    def setup_tqdm_pyqt():
        if not __CONFIGURED:
            tqdm_update_queue = Queue()
            perform_tqdm_pyqt_hack(tqdm_update_queue=tqdm_update_queue)
            return TQDMDataQueueReceiver(tqdm_update_queue)
    
    
    def perform_tqdm_pyqt_hack(tqdm_update_queue: Queue):
        import tqdm
        # save original class into module
        tqdm.original_class = tqdm.std.tqdm
        parent = tqdm.std.tqdm
    
        class TQDMPatch(parent):
            """
            Derive from original class
            """
    
            def __init__(self, iterable=None, desc=None, total=None, leave=True, file=None,
                         ncols=None, mininterval=0.1, maxinterval=10.0, miniters=None,
                         ascii=None, disable=False, unit='it', unit_scale=False,
                         dynamic_ncols=False, smoothing=0.3, bar_format=None, initial=0,
                         position=None, postfix=None, unit_divisor=1000, write_bytes=None,
                         lock_args=None, nrows=None, colour=None, delay=0, gui=False,
                         **kwargs):
                print('TQDM Patch called')  # check it works
                self.tqdm_update_queue = tqdm_update_queue
                super(TQDMPatch, self).__init__(iterable, desc, total, leave,
                                                file,  # no change here
                                                ncols,
                                                mininterval, maxinterval,
                                                miniters, ascii, disable, unit,
                                                unit_scale,
                                                False,  # change param ?
                                                smoothing,
                                                bar_format, initial, position, postfix,
                                                unit_divisor, gui, **kwargs)
                self.tqdm_update_queue.put({"do_reset": True, "pos": self.pos or 0})
    
            # def update(self, n=1):
            #     super(TQDMPatch, self).update(n=n)
            #     custom stuff ?
    
            def refresh(self, nolock=False, lock_args=None):
                super(TQDMPatch, self).refresh(nolock=nolock, lock_args=lock_args)
                d = self.format_dict
                d["pos"] = self.pos
                self.tqdm_update_queue.put(d)
    
            def close(self):
                self.tqdm_update_queue.put({"close": True, "pos": self.pos})
                super(TQDMPatch, self).close()
    
        # change original class with the patched one, the original still exists
        tqdm.std.tqdm = TQDMPatch
        tqdm.tqdm = TQDMPatch  # may not be necessary
        # for tqdm.auto users, maybe some additional stuff is needed ?
    
    
    class TQDMDataQueueReceiver(QObject):
        s_tqdm_object_received_signal = pyqtSignal(object)
    
        def __init__(self, q: Queue, *args, **kwargs):
            QObject.__init__(self, *args, **kwargs)
            self.queue = q
    
        @pyqtSlot()
        def run(self):
            while True:
                o = self.queue.get()
                # noinspection PyUnresolvedReferences
                self.s_tqdm_object_received_signal.emit(o)
    
    
    class QTQDMProgressBar(QProgressBar):
        def __init__(self, parent, pos: int, tqdm_signal: pyqtSignal):
            super(QTQDMProgressBar, self).__init__(parent)
            self.setAlignment(Qt.AlignCenter)
            self.setVisible(False)
            self.setMaximumHeight(15)
            self.setMinimumHeight(10)
            self.pos = pos
            # noinspection PyUnresolvedReferences
            tqdm_signal.connect(self.do_it)
    
        def do_it(self, e):
            if not isinstance(e, dict):
                return
            pos = e.get("pos", None)
            if pos != self.pos:
                return
            do_reset = e.get("do_reset", False)  # different from close, because we want visible=true
            initial = e.get("initial", 0)
            total = e.get("total", None)
            n = e.get("n", None)
            desc = e.get("prefix", None)
            text = e.get("text", None)
            do_close = e.get("close", False)  # different from do_reset, we want visible=false
            if do_reset:
                self.reset()
            if do_close:
                self.reset()
            self.setVisible(not do_close)
            if initial:
                self.setMinimum(initial)
            else:
                self.setMinimum(0)
            if total:
                self.setMaximum(total)
            else:
                self.setMaximum(0)
            if n:
                self.setValue(n)
            if desc:
                self.setFormat(f"{desc} %v/%m | %p %")
            elif text:
                self.setFormat(text)
            else:
                self.setFormat("%v/%m | %p")
    
    
    def long_procedure(identifier: int, launch_count: int):
        # emulate late import of modules
        from tqdm.auto import tqdm
        from tqdm.contrib.logging import logging_redirect_tqdm
        __logger = logging.getLogger('long_procedure')
        __logger.setLevel(logging.DEBUG)
        tqdm_object = tqdm(range(10), unit_scale=True, dynamic_ncols=True)
        tqdm_object.set_description(f"long_procedure [id {identifier}] Launch count: [{launch_count}]")
        with logging_redirect_tqdm():
            for i in tqdm_object:
                QtTest.QTest.qWait(1500)
                __logger.info(f'[id {identifier} | count{launch_count}] step {i}')
    
    
    class QtLoggingHelper(ABC):
        @abstractmethod
        def transform(self, msg: str):
            raise NotImplementedError()
    
    
    class QtLoggingBasic(QtLoggingHelper):
        def transform(self, msg: str):
            return msg
    
    
    class QtLoggingColoredLogs(QtLoggingHelper):
        def __init__(self):
            # offensive programming: crash if necessary if import is not present
            pass
    
        def transform(self, msg: str):
            import coloredlogs.converter
            msg_html = coloredlogs.converter.convert(msg)
            return msg_html
    
    
    class QTextEditLogger(logging.Handler, QObject):
        appendText = pyqtSignal(str)
    
        def __init__(self,
                     logger_: logging.Logger,
                     formatter: logging.Formatter,
                     text_widget: QPlainTextEdit,
                     # table_widget: QTableWidget,
                     parent: QWidget):
            super(QTextEditLogger, self).__init__()
            super(QObject, self).__init__(parent=parent)
            self.text_widget = text_widget
            self.text_widget.setReadOnly(True)
            # self.table_widget = table_widget
            try:
                self.helper = QtLoggingColoredLogs()
                self.appendText.connect(self.text_widget.appendHtml)
                logger_.info("Using QtLoggingColoredLogs")
            except ImportError:
                self.helper = QtLoggingBasic()
                self.appendText.connect(self.text_widget.appendPlainText)
                logger_.warning("Using QtLoggingBasic")
            # logTextBox = QTextEditLogger(self)
            # You can format what is printed to text box
            self.setFormatter(formatter)
            logger_.addHandler(self)
            # You can control the logging level
            self.setLevel(logging.DEBUG)
    
        def emit(self, record: logging.LogRecord):
            msg = self.format(record)
            display_msg = self.helper.transform(msg=msg)
            self.appendText.emit(display_msg)
            # self.add_row(record)
    
    
    class MainApp(QWidget):
        def __init__(self):
            super().__init__()
            self.__logger = logging.getLogger(self.__class__.__name__)
            self.__logger.setLevel(logging.DEBUG)
            layout = QVBoxLayout()
            self.setMinimumWidth(650)
            self.thread_tqdm_update_queue_listener = QThread()
            # must be done before any TQDM import
            self.tqdm_update_receiver = setup_tqdm_pyqt()
            self.tqdm_update_receiver.moveToThread(self.thread_tqdm_update_queue_listener)
            self.thread_tqdm_update_queue_listener.started.connect(self.tqdm_update_receiver.run)
            self.pb_dict: Dict[int, QTQDMProgressBar] = {}
            self.btn_dict: Dict[int, QToolButton] = {}
            self.worker_dict: Dict[int, LongProcedureWorker] = {}
            self.thread_dict: Dict[int, QThread] = {}
            for col_idx in range(10):
                pb = QTQDMProgressBar(self, pos=col_idx, tqdm_signal=self.tqdm_update_receiver.s_tqdm_object_received_signal)
                worker = LongProcedureWorker(self, identifier=col_idx)
                thread = QThread()
                thread.setObjectName(f"Thread {col_idx}")
                btn = QToolButton(self)
                btn.setText(f"Long processing {col_idx}")
                btn.clicked.connect(thread.start)
                worker.moveToThread(thread)
                thread.started.connect(worker.run)
                worker.started.connect(btn.setDisabled)
                worker.finished.connect(btn.setEnabled)
                worker.finished.connect(thread.quit)
                self.pb_dict[col_idx] = pb
                self.btn_dict[col_idx] = btn
                self.worker_dict[col_idx] = worker
                self.thread_dict[col_idx] = thread
            self.thread_tqdm_update_queue_listener.start()
            self.plain_text_edit_logger = QPlainTextEdit(self)
            LOG_FMT = "{thread:7d}-{threadName:10.10} | {asctime} | {levelname:10s} | {message}"
            try:
                import coloredlogs
                FORMATTER = coloredlogs.ColoredFormatter(fmt=LOG_FMT, style="{")
            except ImportError:
                FORMATTER = logging.Formatter(fmt=LOG_FMT, style="{")
    
            self.logging_ = QTextEditLogger(logger_=logging.getLogger(),  # root logger, to intercept every log of app
                                            formatter=FORMATTER,
                                            text_widget=self.plain_text_edit_logger,
                                            parent=self)
    
            self.widget_btns = QWidget(self)
            q_grid_layout = QGridLayout(self.widget_btns)
            self.widget_btns.setLayout(q_grid_layout)
            for idx in sorted(self.btn_dict.keys(), reverse=True):
                b = self.btn_dict[idx]
                j = int(idx % (len(self.btn_dict.values()) / 2))
                i = int(idx // (len(self.btn_dict.values()) / 2))
                q_grid_layout.addWidget(b, i, j)
            layout.addWidget(self.widget_btns)
            layout.addWidget(self.plain_text_edit_logger)
            for pb in self.pb_dict.values():
                layout.addWidget(pb)
            self.setLayout(layout)
            import tqdm
            self.__logger.info(f"tqdm {tqdm.__version__}")
            self.__logger.info(f"Qt={QT_VERSION_STR}; PyQt={PYQT_VERSION_STR}")
            with contextlib.suppress(ImportError):
                import coloredlogs
            self.__logger.info(f"coloredlogs {coloredlogs.__version__}")
            self.show()
    
    
    class LongProcedureWorker(QObject):
        started = pyqtSignal(bool)
        finished = pyqtSignal(bool)
    
        def __init__(self, main_app: MainApp, identifier: int):
            super(LongProcedureWorker, self).__init__()
            self._main_app = main_app
            self.id = identifier
            self.launch_count = 0
    
        @pyqtSlot()
        def run(self):
            self.launch_count += 1
            self.started.emit(True)
            long_procedure(self.id, self.launch_count)
            self.finished.emit(True)
    
    
    if __name__ == '__main__':
        app = QApplication(sys.argv)
        app.setStyle('Fusion')
        ex = MainApp()
        sys.exit(app.exec_())
    

    1. GIF showing the solution

    enter image description here

    2. How does it work?

    As in my previous answer, we need:

    New thing here are:

    Concerning the TQDM class patch, we redefine __init__, but now we also define refresh and close (instead of using the file stream trick from my previous answer)0

    3. Full example (1 file)

    import contextlib
    import logging
    import sys
    from abc import ABC, abstractmethod
    from queue import Queue
    
    from PyQt5 import QtTest
    from PyQt5.QtCore import PYQT_VERSION_STR, pyqtSignal, pyqtSlot, QObject, Qt, QT_VERSION_STR, QThread
    from PyQt5.QtWidgets import QApplication, QPlainTextEdit, QProgressBar, QToolButton, QVBoxLayout, QWidget
    
    
    __CONFIGURED = False
    
    
    def setup_streams_redirection(tqdm_nb_columns=None):
        if not __CONFIGURED:
            tqdm_update_queue = Queue()
            perform_tqdm_default_out_stream_hack(tqdm_update_queue=tqdm_update_queue, tqdm_nb_columns=tqdm_nb_columns)
            return TQDMDataQueueReceiver(tqdm_update_queue)
    
    
    def perform_tqdm_default_out_stream_hack(tqdm_update_queue: Queue, tqdm_nb_columns=None):
        import tqdm
        # save original class into module
        tqdm.original_class = tqdm.std.tqdm
        parent = tqdm.std.tqdm
    
        class TQDMPatch(parent):
            """
            Derive from original class
            """
    
            def __init__(self, iterable=None, desc=None, total=None, leave=True, file=None,
                         ncols=None, mininterval=0.1, maxinterval=10.0, miniters=None,
                         ascii=None, disable=False, unit='it', unit_scale=False,
                         dynamic_ncols=False, smoothing=0.3, bar_format=None, initial=0,
                         position=None, postfix=None, unit_divisor=1000, write_bytes=None,
                         lock_args=None, nrows=None, colour=None, delay=0, gui=False,
                         **kwargs):
                print('TQDM Patch called')  # check it works
                self.tqdm_update_queue = tqdm_update_queue
                self.tqdm_update_queue.put({"do_reset": True})
                super(TQDMPatch, self).__init__(iterable, desc, total, leave,
                                                file,  # no change here
                                                ncols,
                                                mininterval, maxinterval,
                                                miniters, ascii, disable, unit,
                                                unit_scale,
                                                False,  # change param ?
                                                smoothing,
                                                bar_format, initial, position, postfix,
                                                unit_divisor, gui, **kwargs)
    
            # def update(self, n=1):
            #     super(TQDMPatch, self).update(n=n)
            #     custom stuff ?
    
            def refresh(self, nolock=False, lock_args=None):
                super(TQDMPatch, self).refresh(nolock=nolock, lock_args=lock_args)
                self.tqdm_update_queue.put(self.format_dict)
    
            def close(self):
                self.tqdm_update_queue.put({"close": True})
                super(TQDMPatch, self).close()
    
        # change original class with the patched one, the original still exists
        tqdm.std.tqdm = TQDMPatch
        tqdm.tqdm = TQDMPatch  # may not be necessary
        # for tqdm.auto users, maybe some additional stuff is needed
    
    
    class TQDMDataQueueReceiver(QObject):
        s_tqdm_object_received_signal = pyqtSignal(object)
    
        def __init__(self, q: Queue, *args, **kwargs):
            QObject.__init__(self, *args, **kwargs)
            self.queue = q
    
        @pyqtSlot()
        def run(self):
            while True:
                o = self.queue.get()
                # noinspection PyUnresolvedReferences
                self.s_tqdm_object_received_signal.emit(o)
    
    
    class QTQDMProgressBar(QProgressBar):
        def __init__(self, parent, tqdm_signal: pyqtSignal):
            super(QTQDMProgressBar, self).__init__(parent)
            self.setAlignment(Qt.AlignCenter)
            self.setVisible(False)
            # noinspection PyUnresolvedReferences
            tqdm_signal.connect(self.do_it)
    
        def do_it(self, e):
            if not isinstance(e, dict):
                return
            do_reset = e.get("do_reset", False)  # different from close, because we want visible=true
            initial = e.get("initial", 0)
            total = e.get("total", None)
            n = e.get("n", None)
            desc = e.get("prefix", None)
            text = e.get("text", None)
            do_close = e.get("close", False)  # different from do_reset, we want visible=false
            if do_reset:
                self.reset()
            if do_close:
                self.reset()
            self.setVisible(not do_close)
            if initial:
                self.setMinimum(initial)
            else:
                self.setMinimum(0)
            if total:
                self.setMaximum(total)
            else:
                self.setMaximum(0)
            if n:
                self.setValue(n)
            if desc:
                self.setFormat(f"{desc} %v/%m | %p %")
            elif text:
                self.setFormat(text)
            else:
                self.setFormat("%v/%m | %p")
    
    
    def long_procedure():
        # emulate late import of modules
        from tqdm.auto import tqdm # don't import before patch !
        __logger = logging.getLogger('long_procedure')
        __logger.setLevel(logging.DEBUG)
        tqdm_object = tqdm(range(10), unit_scale=True, dynamic_ncols=True)
        tqdm_object.set_description("My progress bar description")
        from tqdm.contrib.logging import logging_redirect_tqdm # don't import before patch !
        with logging_redirect_tqdm():
            for i in tqdm_object:
                QtTest.QTest.qWait(200)
                __logger.info(f'foo {i}')
    
    
    class QtLoggingHelper(ABC):
        @abstractmethod
        def transform(self, msg: str):
            raise NotImplementedError()
    
    
    class QtLoggingBasic(QtLoggingHelper):
        def transform(self, msg: str):
            return msg
    
    
    class QtLoggingColoredLogs(QtLoggingHelper):
        def __init__(self):
            # offensive programming: crash if necessary if import is not present
            pass
    
        def transform(self, msg: str):
            import coloredlogs.converter
            msg_html = coloredlogs.converter.convert(msg)
            return msg_html
    
    
    class QTextEditLogger(logging.Handler, QObject):
        appendText = pyqtSignal(str)
    
        def __init__(self,
                     logger_: logging.Logger,
                     formatter: logging.Formatter,
                     text_widget: QPlainTextEdit,
                     # table_widget: QTableWidget,
                     parent: QWidget):
            super(QTextEditLogger, self).__init__()
            super(QObject, self).__init__(parent=parent)
            self.text_widget = text_widget
            self.text_widget.setReadOnly(True)
            # self.table_widget = table_widget
            try:
                self.helper = QtLoggingColoredLogs()
                self.appendText.connect(self.text_widget.appendHtml)
                logger_.info("Using QtLoggingColoredLogs")
            except ImportError:
                self.helper = QtLoggingBasic()
                self.appendText.connect(self.text_widget.appendPlainText)
                logger_.warning("Using QtLoggingBasic")
            # logTextBox = QTextEditLogger(self)
            # You can format what is printed to text box
            self.setFormatter(formatter)
            logger_.addHandler(self)
            # You can control the logging level
            self.setLevel(logging.DEBUG)
    
        def emit(self, record: logging.LogRecord):
            msg = self.format(record)
            display_msg = self.helper.transform(msg=msg)
            self.appendText.emit(display_msg)
            # self.add_row(record)
    
    
    class MainApp(QWidget):
        def __init__(self):
            super().__init__()
    
            self.__logger = logging.getLogger(self.__class__.__name__)
            self.__logger.setLevel(logging.DEBUG)
    
            layout = QVBoxLayout()
    
            self.setMinimumWidth(500)
    
            self.btn_perform_actions = QToolButton(self)
            self.btn_perform_actions.setText('Launch long processing')
            self.btn_perform_actions.clicked.connect(self._btn_go_clicked)
    
            self.thread_initialize = QThread()
            self.init_procedure_object = LongProcedureWorker(self)
    
            self.thread_tqdm_update_queue_listener = QThread()
            # must be done before any TQDM import
            self.tqdm_update_receiver = setup_streams_redirection()
            self.tqdm_update_receiver.moveToThread(self.thread_tqdm_update_queue_listener)
            self.thread_tqdm_update_queue_listener.started.connect(self.tqdm_update_receiver.run)
    
            self.pb_tqdm = QTQDMProgressBar(self, tqdm_signal=self.tqdm_update_receiver.s_tqdm_object_received_signal)
            layout.addWidget(self.pb_tqdm)
            self.thread_tqdm_update_queue_listener.start()
    
            self.plain_text_edit_logger = QPlainTextEdit(self)
            LOG_FMT = "{asctime} | {levelname:10s} | {message}"
            try:
                import coloredlogs
                FORMATTER = coloredlogs.ColoredFormatter(fmt=LOG_FMT, style="{")
            except ImportError:
                FORMATTER = logging.Formatter(fmt=LOG_FMT, style="{")
    
            self.logging_ = QTextEditLogger(logger_=logging.getLogger(),  # root logger, to intercept every log of app
                                            formatter=FORMATTER,
                                            text_widget=self.plain_text_edit_logger,
                                            parent=self)
            layout.addWidget(self.plain_text_edit_logger)
            layout.addWidget(self.btn_perform_actions)
            self.setLayout(layout)
            import tqdm
            self.__logger.info(f"tqdm {tqdm.__version__}")
            self.__logger.info(f"Qt={QT_VERSION_STR}; PyQt={PYQT_VERSION_STR}")
            with contextlib.suppress(ImportError):
                import coloredlogs
                self.__logger.info(f"coloredlogs {coloredlogs.__version__}")
            # prepare thread for long operation
            self.init_procedure_object.moveToThread(self.thread_initialize)
            self.thread_initialize.started.connect(self.init_procedure_object.run)
            self.init_procedure_object.finished.connect(self._init_procedure_finished)
            self.init_procedure_object.finished.connect(self.thread_initialize.quit)
            self.show()
    
        @pyqtSlot()
        def _btn_go_clicked(self):
            # start thread
            self.btn_perform_actions.setEnabled(False)
            self.__logger.info("Launch Thread")
            self.thread_initialize.start()
    
        def _init_procedure_finished(self):
            self.btn_perform_actions.setEnabled(True)
    
    
    class LongProcedureWorker(QObject):
        finished = pyqtSignal()
    
        def __init__(self, main_app: MainApp):
            super(LongProcedureWorker, self).__init__()
            self._main_app = main_app
    
        @pyqtSlot()
        def run(self):
            long_procedure()
            self.finished.emit()
    
    
    if __name__ == '__main__':
        app = QApplication(sys.argv)
        app.setStyle('Fusion')
        ex = MainApp()
        sys.exit(app.exec_())