import logging import time import re from functools import partial from PyQt5 import QtCore, QtGui, QtWidgets from sipyco.logging_tools import SourceFilter from artiq.gui.tools import (LayoutWidget, log_level_to_name, QDockWidgetCloseDetect) class _ModelItem: def __init__(self, parent, row): self.parent = parent self.row = row self.children_by_row = [] class _LogFilterProxyModel(QtCore.QSortFilterProxyModel): def __init__(self): super().__init__() self.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) self.setRecursiveFilteringEnabled(True) self.filter_level = 0 def filterAcceptsRow(self, source_row, source_parent): source = self.sourceModel() index0 = source.index(source_row, 0, source_parent) index1 = source.index(source_row, 1, source_parent) level = source.data(index0, QtCore.Qt.UserRole) if level >= self.filter_level: regex = self.filterRegExp() index0_text = source.data(index0, QtCore.Qt.DisplayRole) msg_text = source.data(index1, QtCore.Qt.DisplayRole) return (regex.indexIn(index0_text) != -1 or regex.indexIn(msg_text) != -1) else: return False def apply_filter_level(self, filter_level): self.filter_level = getattr(logging, filter_level) self.invalidateFilter() class _Model(QtCore.QAbstractItemModel): def __init__(self): QtCore.QAbstractTableModel.__init__(self) self.headers = ["Source", "Message"] self.children_by_row = [] self.entries = [] self.pending_entries = [] self.depth = 1000 timer = QtCore.QTimer(self) timer.timeout.connect(self.timer_tick) timer.start(100) self.fixed_font = QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.FixedFont) self.white = QtGui.QBrush(QtGui.QColor(255, 255, 255)) self.black = QtGui.QBrush(QtGui.QColor(0, 0, 0)) self.debug_fg = QtGui.QBrush(QtGui.QColor(55, 55, 55)) self.warning_bg = QtGui.QBrush(QtGui.QColor(255, 255, 180)) self.error_bg = QtGui.QBrush(QtGui.QColor(255, 150, 150)) def headerData(self, col, orientation, role): if (orientation == QtCore.Qt.Horizontal and role == QtCore.Qt.DisplayRole): return self.headers[col] return None def rowCount(self, parent): if parent.isValid(): item = parent.internalPointer() return len(item.children_by_row) else: return len(self.entries) def columnCount(self, parent): return len(self.headers) def append(self, v): severity, source, timestamp, message = v self.pending_entries.append((severity, source, timestamp, message.splitlines())) def clear(self): self.beginRemoveRows(QtCore.QModelIndex(), 0, len(self.entries)-1) self.entries.clear() self.children_by_row.clear() self.endRemoveRows() def timer_tick(self): if not self.pending_entries: return nrows = len(self.entries) records = self.pending_entries self.pending_entries = [] self.beginInsertRows(QtCore.QModelIndex(), nrows, nrows+len(records)-1) self.entries.extend(records) for rec in records: item = _ModelItem(self, len(self.children_by_row)) self.children_by_row.append(item) for i in range(len(rec[3])-1): item.children_by_row.append(_ModelItem(item, i)) self.endInsertRows() if len(self.entries) > self.depth: start = len(self.entries) - self.depth self.beginRemoveRows(QtCore.QModelIndex(), 0, start-1) self.entries = self.entries[start:] self.children_by_row = self.children_by_row[start:] for child in self.children_by_row: child.row -= start self.endRemoveRows() def index(self, row, column, parent): if parent.isValid(): parent_item = parent.internalPointer() return self.createIndex(row, column, parent_item.children_by_row[row]) else: return self.createIndex(row, column, self.children_by_row[row]) def parent(self, index): if index.isValid(): parent = index.internalPointer().parent if parent is self: return QtCore.QModelIndex() else: return self.createIndex(parent.row, 0, parent) else: return QtCore.QModelIndex() def full_entry(self, index): if not index.isValid(): return item = index.internalPointer() if item.parent is self: msgnum = item.row else: msgnum = item.parent.row return self.entries[msgnum][3] def data(self, index, role): if not index.isValid(): return item = index.internalPointer() if item.parent is self: msgnum = item.row else: msgnum = item.parent.row if role == QtCore.Qt.FontRole and index.column() == 1: return self.fixed_font elif role == QtCore.Qt.BackgroundRole: level = self.entries[msgnum][0] if level >= logging.ERROR: return self.error_bg elif level >= logging.WARNING: return self.warning_bg else: return self.white elif role == QtCore.Qt.ForegroundRole: level = self.entries[msgnum][0] if level <= logging.DEBUG: return self.debug_fg else: return self.black elif role == QtCore.Qt.DisplayRole: v = self.entries[msgnum] column = index.column() if item.parent is self: if column == 0: return v[1] else: return v[3][0] else: if column == 0: return "" else: return v[3][item.row+1] elif role == QtCore.Qt.ToolTipRole: v = self.entries[msgnum] if item.parent is self: lineno = 0 else: lineno = item.row + 1 return (log_level_to_name(v[0]) + ", " + time.strftime("%m/%d %H:%M:%S", time.localtime(v[2])) + "\n" + v[3][lineno]) elif role == QtCore.Qt.UserRole: return self.entries[msgnum][0] class LogDock(QDockWidgetCloseDetect): def __init__(self, manager, name): QDockWidgetCloseDetect.__init__(self, "Log") self.setObjectName(name) grid = LayoutWidget() self.setWidget(grid) grid.addWidget(QtWidgets.QLabel("Minimum level: "), 0, 0) self.filter_level = QtWidgets.QComboBox() self.filter_level.addItems(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]) self.filter_level.setToolTip("Filter entries at or above this level") grid.addWidget(self.filter_level, 0, 1) self.filter_freetext = QtWidgets.QLineEdit() self.filter_freetext.setPlaceholderText("freetext filter...") self.filter_freetext.setToolTip("Filter entries containing this text") grid.addWidget(self.filter_freetext, 0, 2) scrollbottom = QtWidgets.QToolButton() scrollbottom.setToolTip("Scroll to bottom") scrollbottom.setIcon(QtWidgets.QApplication.style().standardIcon( QtWidgets.QStyle.SP_ArrowDown)) grid.addWidget(scrollbottom, 0, 3) scrollbottom.clicked.connect(self.scroll_to_bottom) clear = QtWidgets.QToolButton() clear.setIcon(QtWidgets.QApplication.style().standardIcon( QtWidgets.QStyle.SP_DialogResetButton)) grid.addWidget(clear, 0, 4) clear.clicked.connect(lambda: self.model.clear()) if manager: newdock = QtWidgets.QToolButton() newdock.setToolTip("Create new log dock") newdock.setIcon(QtWidgets.QApplication.style().standardIcon( QtWidgets.QStyle.SP_FileDialogNewFolder)) # note the lambda, the default parameter is overriden otherwise newdock.clicked.connect(lambda: manager.create_new_dock()) grid.addWidget(newdock, 0, 5) grid.layout.setColumnStretch(2, 1) self.log = QtWidgets.QTreeView() self.log.setHorizontalScrollMode( QtWidgets.QAbstractItemView.ScrollPerPixel) self.log.setVerticalScrollMode( QtWidgets.QAbstractItemView.ScrollPerPixel) self.log.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) grid.addWidget(self.log, 1, 0, colspan=6 if manager else 5) self.scroll_at_bottom = False self.scroll_value = 0 self.log.setContextMenuPolicy(QtCore.Qt.ActionsContextMenu) copy_action = QtWidgets.QAction("Copy entry to clipboard", self.log) copy_action.triggered.connect(self.copy_to_clipboard) self.log.addAction(copy_action) clear_action = QtWidgets.QAction("Clear", self.log) clear_action.triggered.connect(lambda: self.model.clear()) self.log.addAction(clear_action) # If Qt worked correctly, this would be nice to have. Alas, resizeSections # is broken when the horizontal scrollbar is enabled. # sizeheader_action = QtWidgets.QAction("Resize header", self.log) # sizeheader_action.triggered.connect( # lambda: self.log.header().resizeSections(QtWidgets.QHeaderView.ResizeToContents)) # self.log.addAction(sizeheader_action) cw = QtGui.QFontMetrics(self.font()).averageCharWidth() self.log.header().resizeSection(0, 26*cw) self.model = _Model() self.proxy_model = _LogFilterProxyModel() self.proxy_model.setSourceModel(self.model) self.log.setModel(self.proxy_model) self.model.rowsAboutToBeInserted.connect(self.rows_inserted_before) self.model.rowsInserted.connect(self.rows_inserted_after) self.model.rowsRemoved.connect(self.rows_removed) self.filter_freetext.returnPressed.connect(self.apply_text_filter) self.filter_level.currentIndexChanged.connect(self.apply_level_filter) def apply_text_filter(self): self.proxy_model.setFilterRegExp(self.filter_freetext.text()) def apply_level_filter(self): self.proxy_model.apply_filter_level(self.filter_level.currentText()) def scroll_to_bottom(self): self.log.scrollToBottom() def rows_inserted_before(self): scrollbar = self.log.verticalScrollBar() self.scroll_value = scrollbar.value() self.scroll_at_bottom = self.scroll_value == scrollbar.maximum() def rows_inserted_after(self): if self.scroll_at_bottom: self.log.scrollToBottom() # HACK: # Qt intermittently likes to scroll back to the top when rows are removed. # Work around this by restoring the scrollbar to the previously memorized # position, after the removal. # Note that this works because _LogModel always does the insertion right # before the removal. # TODO: check if this is still required after moving to QTreeView def rows_removed(self): if self.scroll_at_bottom: self.log.scrollToBottom() else: scrollbar = self.log.verticalScrollBar() scrollbar.setValue(self.scroll_value) def copy_to_clipboard(self): idx = self.log.selectedIndexes() if idx: source_idx = self.proxy_model.mapToSource(idx[0]) entry = "\n".join(self.model.full_entry(source_idx)) QtWidgets.QApplication.clipboard().setText(entry) def save_state(self): return { "min_level_idx": self.filter_level.currentIndex(), "freetext_filter": self.filter_freetext.text(), "header": bytes(self.log.header().saveState()) } def restore_state(self, state): try: idx = state["min_level_idx"] except KeyError: pass else: self.filter_level.setCurrentIndex(idx) try: freetext = state["freetext_filter"] except KeyError: pass else: self.filter_freetext.setText(freetext) try: header = state["header"] except KeyError: pass else: self.log.header().restoreState(QtCore.QByteArray(header)) class LogDockManager: def __init__(self, main_window): self.main_window = main_window self.docks = dict() def append_message(self, msg): for dock in self.docks.values(): dock.model.append(msg) def create_new_dock(self, add_to_area=True): n = 0 name = "log0" while name in self.docks: n += 1 name = "log" + str(n) dock = LogDock(self, name) self.docks[name] = dock if add_to_area: self.main_window.addDockWidget(QtCore.Qt.RightDockWidgetArea, dock) dock.setFloating(True) dock.sigClosed.connect(partial(self.on_dock_closed, name)) self.update_closable() return dock def on_dock_closed(self, name): dock = self.docks[name] dock.deleteLater() del self.docks[name] self.update_closable() def update_closable(self): flags = (QtWidgets.QDockWidget.DockWidgetMovable | QtWidgets.QDockWidget.DockWidgetFloatable) if len(self.docks) > 1: flags |= QtWidgets.QDockWidget.DockWidgetClosable for dock in self.docks.values(): dock.setFeatures(flags) def save_state(self): return {name: dock.save_state() for name, dock in self.docks.items()} def restore_state(self, state): if self.docks: raise NotImplementedError for name, dock_state in state.items(): dock = LogDock(self, name) self.docks[name] = dock dock.restore_state(dock_state) self.main_window.addDockWidget(QtCore.Qt.RightDockWidgetArea, dock) dock.sigClosed.connect(partial(self.on_dock_closed, name)) self.update_closable() def first_log_dock(self): if self.docks: return None dock = self.create_new_dock(False) return dock class LogWidgetHandler(logging.Handler): def __init__(self, *args, **kwargs): logging.Handler.__init__(self, *args, **kwargs) self.callback = None self.setFormatter(logging.Formatter("%(name)s:%(message)s")) def emit(self, record): if self.callback is not None: message = self.format(record) self.callback((record.levelno, record.source, record.created, message)) def init_log(args, local_source): root_logger = logging.getLogger() root_logger.setLevel(logging.NOTSET) # we use our custom filter only flt = SourceFilter(logging.INFO + args.quiet*10 - args.verbose*10, local_source) handlers = [] console_handler = logging.StreamHandler() console_handler.setFormatter(logging.Formatter( "%(levelname)s:%(source)s:%(name)s:%(message)s")) handlers.append(console_handler) widget_handler = LogWidgetHandler() handlers.append(widget_handler) for handler in handlers: handler.addFilter(flt) root_logger.addHandler(handler) return widget_handler