Python logger – log output on QTextWidget (PyQt6)

There was a Python console application in which many logs were written using the logging module. Then I installed the GUI on PyQt6, of course I want to duplicate the logs in some widget in the corner. I absolutely don’t want to change anything in the console part, and just continue to use standard logging.

This post will look at two examples. Simple – a widget that would duplicate the output of a standard Python logger. Complication – there are several threads, they also write logs. You also need to see their logs on the widget, but it is in the parent part, and threads cannot write directly to it – we get a segfault.

Introduction

I came across this question on the Internet in lore, Reddit, overflow… There were no sensible solutions, half a year ago. It was proposed to use stdout redirection. Or read logs from a file and then push them onto the widget. This didn't suit me.

The collective farm option came straight away – crawl into the core of the application, add classes there that inherit from QObject, and when writing to the logger, at the same time send a signal with the same message. In the gui part, it is caught and displayed on the widget. I tried it in one place – it works, there is no segfault.

The solution, of course, sucks. First, we tightly bind the kernel to Qt, and mixes the interface and calculations. Secondly, I’m too lazy to make so many edits for such a clumsy and incorrect solution from a design point of view.

I solved the problem the next day, having tried several approaches. So I decided carve in stone publish this case on Habré.

The article does not cover the basics, and assumes that the reader knows how to use logging and can do “Hello, world!” on PyQt6 (I think it will also work on PySide and C++ Qt, perhaps with (slight) changes).

Links to materials on the subject:

1. A simple example – a black log box in QPlainText

The bottom line.

The logging module has a StreamHandler class. It has a method emit(), which is called every time a message is sent to the logger, for example

logger.info("Форматирование всех дисков завершено, хорошего вечера.")

We create a class by multiple inheritance of StreamHandler and QPlainText, hiding all the details inside it. We use the global logger and don’t worry. Just a short example to get the point across. There are an excessive number of comments in the code, so I don’t know what else to explain.

#!/usr/bin/env  python3

import sys
import logging
from PyQt6 import QtWidgets


# Создаем логгер
NAME = "ULTIMATE SUPER MEGA BEST LOGGER v0.1.0"
logger = logging.getLogger(NAME)
logger.setLevel(logging.DEBUG)

# Наследуем QPlainTextEdit & StreamHandler
class LogWidget(QtWidgets.QPlainTextEdit, logging.StreamHandler):
    def __init__(self, parent=None):
        QtWidgets.QPlainTextEdit.__init__(self, parent)
        logging.StreamHandler.__init__(self)

        # Создаем formatter
        stream_formatter = logging.Formatter(
            "%(module)s: %(asctime)s [%(levelname)s] %(message)s",
            datefmt="%H:%M:%S",
            )

        # Устанавливаем formatter в self
        # self является и текст эдитом и хендлером
        self.setFormatter(stream_formatter)

        # Устанавливаем уроверь вывода лога
        self.setLevel(logging.INFO)

        # Ну это просто для иллюстрации как получить тот же логгер,
        # хотя в данном примере можно было бы просто взять глобальный 'logger'.
        # В реальных задачах логгер может быть объявлен и
        # сконфигурирован в другом месте, теперь тут мы получаем
        # тот же логгер через имя
        logger = logging.getLogger(NAME)

        # Добавляем в self еще один хендлер,
        # который мутант StreamHandler и QPlainTextEdit
        logger.addHandler(self)

    # Переопределяем метод StreamHandler.emit, этот метод
    # вызывается при каждой записи в логгер, типо:
    # logger.warning("У вас хлеб кончился, вам надо хлеба купить!")
    def emit(self, record: str):
        # record - строка которую мы передали логгеру
        # метод формат применяет к ней форматтер установленный
        # ранее в конструкторе
        log_msg = self.format(record)

        # отправляем отформатированную строку в QPlainTextEdit
        self.appendPlainText(log_msg)
        self.__scrollDown()

        # сбрасываем буфер
        self.flush()

    def __scrollDown(self):
        scroll_bar = self.verticalScrollBar()
        end_text = scroll_bar.maximum()
        scroll_bar.setValue(end_text)


def main():
    # создаем приложение, и наш виджет
    app = QtWidgets.QApplication(sys.argv)
    w = LogWidget()
    w.show()

    # Напишем что-нибудь в логгер
    logger.debug("Это дебаг сообщение, мы его не увидим")
    logger.info("Hello, Habr!")
    logger.warning("Винни: Я тучка тучка тучка, я вовсе не медведь")
    logger.error("Пятачок: Ой, кажется дождь собирается")
    logger.critical("Винни: Это неправильные пчелы...")

    sys.exit(app.exec())


if __name__ == "__main__":
    main()

Problems with this solution:
– if someone from a child stream writes to the logger, we get a Segmentation fault
– well, not kosher at all

2. A more human example

It worked for me. In terms of the market, I probably don’t reach June, I’m writing the project for myself, so I don’t claim the canonicity of this decision.

  1. Let us have a logger somewhere that writes an info stream to the console and a debug to a file.

  2. Let's take QTextEdit this time and color the messages at the same time. This is exactly the question I came across online.

  3. Let's make our own handler, inherit logging.StreamHandler and when recording a new message, it will at the same time create a signal in which the formatted and colored message will be transmitted.

  4. The log widget (inherited from QTextEdit) will receive our handler in its constructor and connect its signal to its __updateText() method. __

  5. Profit – all messages sent to the logger, no matter where they are, and in child streams too, will be easily duplicated to this widget. Qt allows you to pass signals from child threads to the parent thread, or anywhere at all.

  6. Let's look at the code with comments:

#!/usr/bin/env  python3

import sys
import logging
from PyQt6 import QtCore, QtWidgets


# Отнаследуем StreamHandler и QtObject
# QtObject нужен чтобы отправлять сигналы
class MyHandler(logging.StreamHandler, QtCore.QObject):
    # Сигнал, передающий отформатированное сообщение логгера
    # по сути мы его можем теперь ловить любым виджетом
    # и что угодно с ним делать, хоть на QLabel выводить
    message = QtCore.pyqtSignal(str)

    def __init__(self, parent=None):
        logging.StreamHandler.__init__(self)
        QtCore.QObject.__init__(self, parent)

    def emit(self, record: str):
        # record - строка которую мы передали логгеру
        # метод формат применяет к ней форматтер если он установлен
        log_msg = self.format(record)

        # Раскрасим строку перед отправкой
        if "DEBUG" in log_msg:
            text = f"""<span style="color:#888888;">{log_msg}</span>"""
        elif "INFO" in log_msg:
            text = f"""<span style="color:#008800;">{log_msg}</span>"""
        elif "WARNING" in log_msg:
            text = f"""<span style="color:#888800;">{log_msg}</span>"""
        elif "ERROR" in log_msg:
            text = f"""<span style="color:#000088;">{log_msg}</span>"""
        elif "CRITICAL" in log_msg:
            text = f"""<span style="color:#880000;">{log_msg}</span>"""

        # генерируем сигнал, передаем раскрашенный текст
        self.message.emit(text)

        # сбрасываем буфер
        self.flush()

class LogWidget(QtWidgets.QTextEdit):
    def __init__(self, handler: logging.StreamHandler, parent=None):
        QtWidgets.QTabWidget.__init__(self, parent)
        # Сохраним хендлер чтобы его сборщик мусора не прибил
        self.handler = handler

        # Конектим сигнал от хендлера
        self.handler.message.connect(self.__updateText)

    def __scrollDown(self):
        logger.debug(f"{self.__class__.__name__}.__scrollDown()")
        scroll_bar = self.verticalScrollBar()
        end_text = scroll_bar.maximum()
        scroll_bar.setValue(end_text)

    def __updateText(self, msg: str):
        logger.debug(f"{self.__class__.__name__}.__updateText(msg)")
        self.append(msg)
        self.__scrollDown()


# Пусть где то и когда-то мы создали и настроили логгер
NAME = "NEO_IMPROVE_LOGGER v0.10.0"
logger = logging.getLogger(NAME)
logger.setLevel(logging.DEBUG)

# Со стрим хендлером для вывода лога в консоль, уровень [INFO]
stream_formatter = logging.Formatter(
    "%(module)s: %(asctime)s [%(levelname)s] %(message)s",
    datefmt="%H:%M:%S",
    )
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(stream_formatter)
stream_handler.setLevel(logging.INFO)
logger.addHandler(stream_handler)

# И с файл хендлером для вывода лога в файл, уровень [DEBUG]
file_formatter = logging.Formatter(
    "%(module)s: %(asctime)s [%(levelname)s] %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
    )
file_path = "debug.log"
file_handler = logging.FileHandler(file_path, mode="w")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(file_formatter)
logger.addHandler(file_handler)


def main():
    logger.critical(
        "Это сообщение пойдет в файл и в консоль. "
        "Но оно не появится на виджете, он еще не создан."
        )

    # Пусть теперь мы еще хотим выводить лог на виджет
    # создаем стрим хендлер для вывода лога в gui, уровень [WARNING]
    formatter = logging.Formatter(
        "%(module)s: %(asctime)s [%(levelname)s] %(message)s",
        datefmt="%H:%M:%S",
        )
    gui_stream_handler = MyHandler()  # наш хендлер, выдающий сигналы
    gui_stream_handler.setFormatter(formatter)
    gui_stream_handler.setLevel(logging.WARNING)

    # связываем его с общим для всего логгером
    logging.getLogger(NAME)
    logger.addHandler(gui_stream_handler)

    # Здесь запускаем приложение, создаем лог виджет
    # и передаем ему gui_stream_handler, хендлер уровня WARNING
    # только его сообщения и будут писаться в виджет
    app = QtWidgets.QApplication(sys.argv)
    w = LogWidget(gui_stream_handler)
    w.show()

    # Напишем что-нибудь в логгер
    logger.debug("Это дебаг сообщение, мы его увидим только в файле")
    logger.info("Это инфо сообщение, мы его увидим в консоли")
    logger.warning("Винни: ... они несут не правильный мед!")
    logger.error("Пяточок: И что же теперь делать?")
    logger.critical("Goodbuy Habr! Thanks for your attention.")

    sys.exit(app.exec())



if __name__ == "__main__":
    main()

P.S.

I hope this solution will be useful. I tried to make the most complete statement of the issue from pieces and nonsense that came across the network, and my understanding after reading the documentation. I'd be glad to hear comments from more experienced developers.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *