Было консольное Python приложение, в котором много где пишутся логи с использованием модуля logging. Затем прикрутил GUI на PyQt6, конечно хочется продублировать логи в какой-нибудь виджет в уголочке. Категорически не хочется ничего менять в консольной части, и спокойно использовать дальше стандартный logging.
В этом посте будет рассмотрено два примера. Простой - виджет, который дублировал бы вывод стандартного Python логгера. Усложнение - имеется несколько потоков QTread, они тоже пишут логи. Нужно их логи тоже увидеть на виджете, но он в родительской части, а потоки не могут напрямую в него писать - получим сегфолт.
Введение
В интернетах такой вопрос я встречал на лоре, редите, оверфлоу... Толковых решений не было, пол года назад. Предлагалась дичь с перенаправлением stdout. Или читать логи из файла и потом их на виджет пихать. Меня это не устраивало.
Колхозный вариант пришел с ходу - слазить в ядро приложения, добавить там классам наследованием от QObject, и при записи в логгер заодно слать сигнал с тем же сообщением. В gui части его ловить и выводить на виджет. Попробовал в одном месте - работает, сегфолта нет.
Решение, конечно, отстой. Во-первых наглухо привязываем ядро к Qt, и смешивает интерфейс и вычисления. Во-вторых лень вносить столько правок для такого топорного и неправильного с точки зрения проектирования решения.
Задачу я решил на следующий день, перепробовав несколько подходов. Поэтому решил высечь в камне опубликовать на хабре этот кейс.
Статья не рассматривает основы основ, и подразумевает что читатель знает как пользоваться logging и может сделать "Hellow, world!" на PyQt6 (на PySide и C++ Qt я думаю тоже будет работать, возможно с (не)большими изменениями).
Ссылки на материалы по сабжу:
Базовые знания про модуль logging, на Хабре уже хорошо описали.
Документация модуля logging.
Базовые знания про PyQt6, на Хабре тоже уже имеются.
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. Более человеческий пример
У меня заработало. По рынку я наверное не дотягиваю до джуна, проект пишу для себя, так что не претендую на каноничность этого решения.
Пусть где-то у нас есть логгер, который пишет инфо стрим в консоль, и дебаг в файл.
Возьмем на этот раз QTextEdit и заодно раскрасим сообщения. Именно такой вопрос попадался мне в сети.
Сделаем свой хендлер, наследуем logging.StreamHandler и он будет при записи нового сообщения, заодно создавать сигнал, в котором будет передаваться отформатированное и раскрашенное сообщение.
Лог виджет (отнаследованный от QTextEdit) будет принимать в конструктор наш хендлер и коннектить его сигнал к своему методу __updateText().
Профит - все сообщения отправленные в логгер, где бы они не были, и в дочерних потоках тоже, будут спокойно дублироваться на этот виджет. Qt позволяет передавать сигналы от дочерних потоков в родительский, или вообще куда угодно.
Смотрим код с комментариями:
#!/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)
okhsunrog
23.06.2024 09:44Вопрос не по теме: а что за оконный менеджер у вас установлен?
arsvincere Автор
23.06.2024 09:44+1Hyprland, если что конфиги здесь https://github.com/arsvincere/dotfiles
Andy_U
Да почему же дичь? Вот заготовка аналога утилиты tee: http://stackoverflow.com/questions/24435987/how-to-stream-stdout-stderr-from-a-child-process-using-asyncio-and-obtain-its-e
arsvincere Автор
Насколько я понял там задача была другая, там именно нужно было передать выход stdout, stderr. Я не разбирался дальше зачем и почему.
Я же рассматриваю задачу передачи логов. И как оказалось в модуле логгинг есть специальный класс хендлер с помощью которого можно перехватывать сообщения логгера.
Вторая задача которая тут решалась - передача лога из потоков QTread. В доках Qt рекомендуется делать это через сигналы.
Получившееся решения кажется мне хорошим. А лезть в stdout или файлы логов мне кажется обходным путем и костылями.
Andy_U
Да, ваша правда. Хотя и для вашего случая есть решение https://stackoverflow.com/questions/67305609/real-time-stdout-redirect-from-a-python-function-call-to-an-async-method, однако, если вызываемый код ваш, то оно, согласен, излишне.