"""
A :class:`~QtWidgets.QWidget` to display :mod:`logging` messages.
"""
import datetime
import logging
import random
from .. import QtGui
from .. import QtWidgets
from .. import convert
from .. import prompt
[docs]class Logger(logging.Handler, QtWidgets.QWidget):
def __init__(self, *, level=logging.INFO, fmt='%(asctime)s [%(levelname)s] -- %(name)s -- %(message)s',
datefmt=None, logger=None, parent=None):
"""A :class:`~QtWidgets.QWidget` to display :mod:`logging` messages.
Parameters
----------
level : :class:`int` or :class:`str`, optional
The :ref:`Logging Level <python:levels>` to use to display the
:class:`~logging.LogRecord`.
fmt : :class:`str`, optional
The :ref:`python:logrecord-attributes` to use
to display the :class:`~logging.LogRecord`.
datefmt : :class:`str` or :data:`None`, optional
The :ref:`strftime format <python:strftime-strptime-behavior>`
to use for the time stamp. If :data:`None` then the ``ISO8601``
date format is used, ``YYYY-mm-dd HH:MM:SS,sss``.
logger : :class:`logging.Logger`, optional
The :class:`logging.Logger` to add this :class:`logging.Handler` to.
If :data:`None` the uses the root :class:`logging.Logger`.
parent : :class:`QtWidgets.QWidget`, optional
The parent widget.
Example
-------
To view an example of the :class:`Logger` widget run::
>>> from msl.examples.qt import logger # doctest: +SKIP
>>> logger.show() # doctest: +SKIP
"""
logging.Handler.__init__(self)
QtWidgets.QWidget.__init__(self, parent=parent)
# a list of all the LogRecords that were emitted
self._records = []
self._num_displayed = 0
self.color_map = {
logging.CRITICAL: QtGui.QColor(255, 0, 0),
logging.ERROR: QtGui.QColor(175, 25, 25),
logging.WARN: QtGui.QColor(180, 100, 127),
logging.INFO: QtGui.QColor(0, 0, 255),
logging.DEBUG: QtGui.QColor(127, 127, 127),
logging.NOTSET: QtGui.QColor(40, 110, 95)
}
self._level_names = {
'CRITICAL': logging.CRITICAL,
'ERROR': logging.ERROR,
'WARN': logging.WARN,
'INFO': logging.INFO,
'DEBUG': logging.DEBUG
}
#
# configure logging
#
if logger is None:
logger = logging.getLogger()
elif not isinstance(logger, logging.Logger):
raise TypeError(f'the logger must be of type logging.Logger, got {type(logger)}')
if logger.level == logging.NOTSET:
logger.setLevel(logging.DEBUG)
if isinstance(level, str):
level = self._level_names[level.upper()]
if not isinstance(level, int):
raise TypeError('the level must be an integer or the name of a logging level')
self._current_level = level
formatter = logging.Formatter(fmt=fmt, datefmt=datefmt)
formatter.default_msec_format = '%s.%03d'
self.setFormatter(formatter)
logger.addHandler(self)
#
# create the widgets
#
self._level_combobox = QtWidgets.QComboBox(self)
for name in sorted(self._level_names, key=self._level_names.get, reverse=False):
self._level_combobox.addItem(name, userData=self._level_names[name])
for key in self._level_names:
if self._level_names[key] == self._current_level:
self._level_combobox.setCurrentText(key)
break
self._level_combobox.currentTextChanged.connect(self._update_records) # noqa: currentTextChanged.connect
self._level_combobox.setToolTip('Select the logging level')
self._level_checkbox = QtWidgets.QCheckBox()
self._level_checkbox.setChecked(False)
self._level_checkbox.stateChanged.connect(self._level_checkbox_changed) # noqa: stateChanged.connect
self._update_level_checkbox_tooltip()
self._label = QtWidgets.QLabel()
self._update_label()
self._save_button = QtWidgets.QPushButton()
self._save_button.setIcon(convert.to_qicon(QtWidgets.QStyle.StandardPixmap.SP_DialogSaveButton))
self._save_button.clicked.connect(self._save_records) # noqa: clicked.connect
self._save_button.setToolTip('Save the log records')
self._text_browser = QtWidgets.QTextBrowser(self)
self._text_browser.setLineWrapMode(QtWidgets.QTextBrowser.LineWrapMode.NoWrap)
self._vsb = self._text_browser.verticalScrollBar()
#
# add the widgets to the layout
#
top = QtWidgets.QHBoxLayout()
top.addWidget(self._level_combobox)
top.addWidget(self._level_checkbox)
top.addStretch()
top.addWidget(self._label)
top.addStretch()
top.addWidget(self._save_button)
layout = QtWidgets.QVBoxLayout()
layout.addLayout(top)
layout.addWidget(self._text_browser)
self.setLayout(layout)
@property
def records(self):
""":class:`list` of :class:`~logging.LogRecord`: The history of all the log records."""
return self._records
def emit(self, record):
"""Overrides :meth:`logging.Handler.emit`."""
self._records.append(record)
try:
# Fixes the following
# RuntimeError: wrapped C/C++ object of type QComboBox has been deleted
data = self._level_combobox.currentData()
except RuntimeError:
logging.getLogger().removeHandler(self)
return
if record.levelno >= data:
self._append_record(record)
self._update_label()
[docs] def save(self, path, *, level=logging.INFO):
"""Save log records to a file.
Parameters
----------
path : :class:`str`
The path to save the log records to. Appends the records to the file
if the file already exists, otherwise creates a new log file. It is
recommended that the file extension be ``.log``, but not mandatory.
level : :class:`int`, optional
All :class:`~logging.LogRecord`\'s with a :ref:`Logging Level <python:levels>`
>= `level` will be saved.
"""
with open(path, mode='a') as fp:
self._write_header(fp)
for record in self._records:
if record.levelno >= level:
fp.write(self.format(record) + '\n')
fp.write('\n')
def _add_new_level(self, record):
# the MSL-Equipment package has a logging.DEMO level
if record.levelname == 'DEMO':
color = QtGui.QColor(93, 170, 78)
else:
color = QtGui.QColor(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
self.color_map[record.levelno] = color
self._level_names[record.levelname] = record.levelno
for i in range(self._level_combobox.count()):
if record.levelno < self._level_combobox.itemData(i):
self._level_combobox.insertItem(i, record.levelname, userData=record.levelno)
return
self._level_combobox.addItem(record.levelname, userData=record.levelno)
def _append_record(self, record):
"""Append a LogRecord to the QTextBrowser."""
if self._level_checkbox.isChecked() and record.levelno != self._level_combobox.currentData():
return
msg = self.format(record)
try:
self._text_browser.setTextColor(self.color_map[record.levelno])
except KeyError:
self._add_new_level(record)
self._text_browser.setTextColor(self.color_map[record.levelno])
self._text_browser.append(msg)
self._num_displayed += 1
if self._vsb.maximum() - self._vsb.value() <= self._vsb.singleStep():
self._text_browser.moveCursor(QtGui.QTextCursor.MoveOperation.End)
self._text_browser.moveCursor(QtGui.QTextCursor.MoveOperation.StartOfLine)
def _level_checkbox_changed(self, state): # noqa: parameter 'state' is not used
self._update_records(self._level_combobox.currentText())
def _save_records(self, checked): # noqa: parameter 'checked' is not used
"""Save the LogRecords that are currently displayed in the QTextBrowser to a file."""
if len(self._records) == 0:
prompt.information('There are no log records to save.')
return
option = QtWidgets.QFileDialog.Option.DontConfirmOverwrite
title = 'Save the Log Records (appends to an existing file)'
path = prompt.save(filters={'Log Files': '*.log'}, title=title, options=option)
if path is None:
return
if not path.endswith('.log'):
path += '.log'
with open(path, mode='a') as fp:
self._write_header(fp)
fp.writelines(self._text_browser.toPlainText())
fp.write('\n\n')
def _update_label(self):
"""Update the label that shows the number of LogRecords that are visible."""
if not self._records:
self._label.setText('There are no log records')
elif self._num_displayed == len(self._records):
self._label.setText('Displaying all log records')
else:
self._label.setText(f'Displaying {self._num_displayed} of {len(self._records)} log records')
def _update_level_checkbox_tooltip(self):
"""Update the ToolTip for self._level_checkbox"""
self._level_checkbox.setToolTip(f'Show {self._level_combobox.currentText()} level only?')
def _update_records(self, name):
"""
Clears the QTextBrowser and adds all the LogRecords that have a
levelno >= the currently-selected logging level.
"""
self._num_displayed = 0
self._text_browser.clear()
self._update_level_checkbox_tooltip()
levelno = self._level_names[name]
for record in self._records:
if record.levelno >= levelno:
self._append_record(record)
self._update_label()
@staticmethod
def _write_header(fp):
now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
fp.write(f'# Saved {now}\n')