Было консольное Python приложение, в котором много где пишутся логи с использованием модуля logging. Затем прикрутил GUI на PyQt6, конечно хочется продублировать логи в какой-нибудь виджет в уголочке. Категорически не хочется ничего менять в консольной части, и спокойно использовать дальше стандартный logging.

В этом посте будет рассмотрено два примера. Простой - виджет, который дублировал бы вывод стандартного Python логгера. Усложнение - имеется несколько потоков QTread, они тоже пишут логи. Нужно их логи тоже увидеть на виджете, но он в родительской части, а потоки не могут напрямую в него писать - получим сегфолт.

Введение

В интернетах такой вопрос я встречал на лоре, редите, оверфлоу... Толковых решений не было, пол года назад. Предлагалась дичь с перенаправлением stdout. Или читать логи из файла и потом их на виджет пихать. Меня это не устраивало.

Колхозный вариант пришел с ходу - слазить в ядро приложения, добавить там классам наследованием от QObject, и при записи в логгер заодно слать сигнал с тем же сообщением. В gui части его ловить и выводить на виджет. Попробовал в одном месте - работает, сегфолта нет.

Решение, конечно, отстой. Во-первых наглухо привязываем ядро к Qt, и смешивает интерфейс и вычисления. Во-вторых лень вносить столько правок для такого топорного и неправильного с точки зрения проектирования решения.

Задачу я решил на следующий день, перепробовав несколько подходов. Поэтому решил высечь в камне опубликовать на хабре этот кейс.

Статья не рассматривает основы основ, и подразумевает что читатель знает как пользоваться logging и может сделать "Hellow, world!" на PyQt6 (на PySide и C++ Qt я думаю тоже будет работать, возможно с (не)большими изменениями).

Ссылки на материалы по сабжу:

1. Простой пример - черный ящик лога в QPlainText

Суть.

В модуле logging есть класс StreamHandler. У него есть метод emit(), который вызывается каждый раз когда в логгер отправляется сообщение, например

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

Создаем класс множественным наследованием StreamHandler и QPlainText, внутри него прячем все подробности. Юзаем глобальный логгер и не паримся. Просто короткий пример, чтобы понять суть. В коде избыточное количество комментариев, так что не знаю, что тут еще объяснять.

#!/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()

Проблемы данного решения:
- если в логгер напишет кто-то из дочернего потока получим Segmentation fault
- ну вообще не кошерно

2. Более человеческий пример

У меня заработало. По рынку я наверное не дотягиваю до джуна, проект пишу для себя, так что не претендую на каноничность этого решения.

  1. Пусть где-то у нас есть логгер, который пишет инфо стрим в консоль, и дебаг в файл.

  2. Возьмем на этот раз QTextEdit и заодно раскрасим сообщения. Именно такой вопрос попадался мне в сети.

  3. Сделаем свой хендлер, наследуем logging.StreamHandler и он будет при записи нового сообщения, заодно создавать сигнал, в котором будет передаваться отформатированное и раскрашенное сообщение.

  4. Лог виджет (отнаследованный от QTextEdit) будет принимать в конструктор наш хендлер и коннектить его сигнал к своему методу __updateText().

  5. Профит - все сообщения отправленные в логгер, где бы они не были, и в дочерних потоках тоже, будут спокойно дублироваться на этот виджет. Qt позволяет передавать сигналы от дочерних потоков в родительский, или вообще куда угодно.

  6. Смотрим код с комментариями:

#!/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()

Побочный бонус - так как теперь мы перехватываем процесс отправки лог сообщения, есть возможность, например так же продолжать все выводить на QTextEdit, а сообщения уровня WARNING и выше показывать в диалоговых окнах. Таким образом, мы получаем единую систему отправки всех сообщений в приложении (через логгер). И единое место которое отвечает за дублирование этих сообщений в GUI. По-моему это хорошо.

П.С.

Надеюсь данное решение будет полезно. Я постарался сделать самое полное изложение вопроса из кусочков и бреда, которые попадались в сети, и своего понимания после чтения документации. Буду рад услышать комментарии более опытных разработчиков.

Комментарии (5)


  1. Andy_U
    23.06.2024 09:44

    Предлагалась дичь с перенаправлением stdout.

    Да почему же дичь? Вот заготовка аналога утилиты tee: http://stackoverflow.com/questions/24435987/how-to-stream-stdout-stderr-from-a-child-process-using-asyncio-and-obtain-its-e


    1. arsvincere Автор
      23.06.2024 09:44

      Насколько я понял там задача была другая, там именно нужно было передать выход stdout, stderr. Я не разбирался дальше зачем и почему.

      Я же рассматриваю задачу передачи логов. И как оказалось в модуле логгинг есть специальный класс хендлер с помощью которого можно перехватывать сообщения логгера.

      Вторая задача которая тут решалась - передача лога из потоков QTread. В доках Qt рекомендуется делать это через сигналы.

      Получившееся решения кажется мне хорошим. А лезть в stdout или файлы логов мне кажется обходным путем и костылями.


      1. Andy_U
        23.06.2024 09:44
        +1

        Да, ваша правда. Хотя и для вашего случая есть решение https://stackoverflow.com/questions/67305609/real-time-stdout-redirect-from-a-python-function-call-to-an-async-method, однако, если вызываемый код ваш, то оно, согласен, излишне.


  1. okhsunrog
    23.06.2024 09:44

    Вопрос не по теме: а что за оконный менеджер у вас установлен?


    1. arsvincere Автор
      23.06.2024 09:44
      +1

      Hyprland, если что конфиги здесь https://github.com/arsvincere/dotfiles