Python logger — вывод лога на QTextWidget (PyQt6)

от автора

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

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

Введение

В интернетах такой вопрос я встречал на лоре, редите, оверфлоу… Толковых решений не было, пол года назад. Предлагалась дичь с перенаправлением 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. По-моему это хорошо.

П.С.

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


ссылка на оригинал статьи https://habr.com/ru/articles/823744/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *