diff --git a/README.rst b/README.rst index 06a11f5..94edd93 100644 --- a/README.rst +++ b/README.rst @@ -221,6 +221,24 @@ In a terminal it would look like: ----- +Action ``UI`` provide interactive visualizer for tracked result: + +.. sourcecode:: python + + import hunter + hunter.trace(action=hunter.UI) + + import os + os.path.join('a', 'b') + hunter.stop() + hunter.UI.start() + +That would result in: + +.. image:: https://raw.githubusercontent.com/seniorsolt/python-hunter/master/docs/UI.png + +----- + You can give it a tree-like configuration where you can optionally configure specific actions for parts of the tree (like dumping variables or a pdb set_trace): diff --git a/docs/UI.png b/docs/UI.png new file mode 100644 index 0000000..b68429b Binary files /dev/null and b/docs/UI.png differ diff --git a/src/hunter/actions.py b/src/hunter/actions.py index 1c339a7..0b929f4 100644 --- a/src/hunter/actions.py +++ b/src/hunter/actions.py @@ -2,14 +2,17 @@ import collections import opcode import os +import sys import threading from collections import defaultdict from itertools import islice from os import getpid from typing import ClassVar +from PyQt5.QtWidgets import QApplication + from . import config -from .util import BUILTIN_SYMBOLS +from .util import BUILTIN_SYMBOLS, TracebackVisualizer from .util import CALL_COLORS from .util import CODE_COLORS from .util import MISSING @@ -33,6 +36,7 @@ 'CodePrinter', 'CallPrinter', 'VarsPrinter', + 'UI' ] BUILTIN_REPR_FUNCS = {'repr': repr, 'safe_repr': safe_repr} @@ -871,3 +875,81 @@ def __call__(self, event): thread_prefix, filename_prefix, ) + + +class UI(ColorStreamAction): + """track events and show the result with UI. To open one should use UI.start() after tracking finished""" + events = [] + counter = 0 + + @classmethod + def cleanup(cls): + cls.locals = defaultdict(list) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.locals = defaultdict(list) + + def __call__(self, event): + ident = (event.module, event.function) + thread = threading.current_thread() + stack = self.locals[thread.ident] + self.counter += 1 + pid_prefix = "" + thread_prefix = thread.name + filename_prefix = f"{event.filename}:{event.lineno}" + + if event.kind == 'call': + code = event.code + stack.append(ident) + args = {var_display: self.try_repr(event.locals.get(var_lookup, None)) + for _, var_lookup, var_display in get_arguments(code)} + initial_args = {var_display: event.locals.get(var_lookup, None) + for _, var_lookup, var_display in get_arguments(code)} + + event_data = { + "pid_prefix": pid_prefix, + "thread_prefix": thread_prefix, + "filename_prefix": filename_prefix, + "kind": event.kind, + "depth": len(stack) - 1, + "function": event.function, + "args": {"repr_args": f"'{args}'", "initial_args": initial_args}, + "color": self.event_colors.get(event.kind, ""), + "counter": self.counter + } + + elif event.kind in ('return', 'exception'): + if stack and stack[-1] == ident: + stack.pop() + event_data = { + "pid_prefix": pid_prefix, + "thread_prefix": thread_prefix, + "filename_prefix": filename_prefix, + "kind": event.kind, + "depth": len(stack), + "function": event.function, + "args": {"repr_args": self.try_repr(event.arg), "initial_args": event.arg}, + "color": self.event_colors.get(event.kind, ""), + "counter": self.counter + } + else: + event_data = { + "pid_prefix": pid_prefix, + "thread_prefix": thread_prefix, + "filename_prefix": filename_prefix, + "kind": event.kind, + "depth": len(stack), + "function": event.function, + "args": {"repr_args": self.try_source(event).strip(), "initial_args": self.try_source(event).strip()}, + "color": self.event_colors.get(event.kind, ""), + "counter": self.counter + } + UI.events.append(event_data) + + @classmethod + def start(cls): + app = QApplication(sys.argv) + ui = TracebackVisualizer(cls.events) + ui.show() + sys.exit(app.exec_()) diff --git a/src/hunter/util.py b/src/hunter/util.py index e25d0d5..238013f 100644 --- a/src/hunter/util.py +++ b/src/hunter/util.py @@ -17,6 +17,14 @@ from re import RegexFlag from threading import main_thread +from PyQt5.QtCore import Qt, QRect, QEvent, QSize +from PyQt5.QtGui import QFontMetrics, QColor +from PyQt5.QtGui import QTextOption +from PyQt5.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QTreeWidget, QTreeWidgetItem, \ + QSplitter, QStyle, QStyledItemDelegate, \ + QTextEdit, QAbstractItemView +from tqdm import tqdm + from .vendor.colorama import Back from .vendor.colorama import Fore from .vendor.colorama import Style @@ -229,3 +237,302 @@ def frame_iterator(frame): while frame: yield frame frame = frame.f_back + + +class ResizableWidget(QWidget): + """custom window to provide resizing and keep minimalistic style for tooltip + showing full argument hidden under dots <...> """ + def __init__(self, text, cursor_pos, parent=None): + super(ResizableWidget, self).__init__(parent) + self.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint) + self.setAttribute(Qt.WA_TranslucentBackground) + self.setStyleSheet("background-color: white; border: 1px solid black;") + self.oldGeometry = None + + layout = QVBoxLayout(self) + self.textEdit = QTextEdit(self) + self.textEdit.setPlainText(text) + self.textEdit.setReadOnly(True) + self.textEdit.setWordWrapMode(QTextOption.WordWrap) + layout.addWidget(self.textEdit) + + self.resizeGripSize = 20 + self.setMouseTracking(True) + self.isResizing = False + self.resizeDirection = None + self.oldMousePos = None + self.setMinimumSize(150, 70) + self.adjustSize() + + QApplication.instance().installEventFilter(self) + self.calculateInitialSize(text) + self.move(cursor_pos.x() - self.width(), cursor_pos.y()) + + def calculateInitialSize(self, text): + fontMetrics = QFontMetrics(self.textEdit.font()) + textSize = fontMetrics.size(0, text) + textSize += QSize(30, 40) # Добавляем отступы для краев и прокрутки + self.resize(textSize) + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + if self.isInResizeArea(event.pos()): + self.isResizing = True + self.oldMousePos = event.globalPos() + self.oldGeometry = self.geometry() + self.resizeDirection = self.getResizeDirection(event.pos()) + else: + super().mousePressEvent(event) + + def mouseMoveEvent(self, event): + if self.isResizing: + delta = event.globalPos() - self.oldMousePos + newRect = QRect(self.oldGeometry) + if self.resizeDirection == "bottom-right": + newRect.setBottomRight(self.oldGeometry.bottomRight() + delta) + elif self.resizeDirection == "bottom-left": + newRect.setBottomLeft(self.oldGeometry.bottomLeft() + delta) + elif self.resizeDirection == "top-right": + newRect.setTopRight(self.oldGeometry.topRight() + delta) + elif self.resizeDirection == "top-left": + newRect.setTopLeft(self.oldGeometry.topLeft() + delta) + elif self.resizeDirection == "bottom": + newRect.setBottom(self.oldGeometry.bottom() + delta.y()) + elif self.resizeDirection == "right": + newRect.setRight(self.oldGeometry.right() + delta.x()) + elif self.resizeDirection == "top": + newRect.setTop(self.oldGeometry.top() + delta.y()) + elif self.resizeDirection == "left": + newRect.setLeft(self.oldGeometry.left() + delta.x()) + self.setGeometry(newRect) + elif self.isInResizeArea(event.pos()): + self.setCursor(self.getCursorShape(event.pos())) + else: + self.setCursor(Qt.ArrowCursor) + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + if event.button() == Qt.LeftButton: + self.isResizing = False + super().mouseReleaseEvent(event) + + def isInResizeArea(self, pos): + rect = self.rect() + return ( + abs(pos.x() - rect.left()) <= self.resizeGripSize or + abs(pos.x() - rect.right()) <= self.resizeGripSize or + abs(pos.y() - rect.top()) <= self.resizeGripSize or + abs(pos.y() - rect.bottom()) <= self.resizeGripSize + ) + + def getResizeDirection(self, pos): + rect = self.rect() + if abs(pos.y() - rect.bottom()) <= self.resizeGripSize and abs(pos.x() - rect.right()) <= self.resizeGripSize: + return "bottom-right" + elif abs(pos.y() - rect.bottom()) <= self.resizeGripSize and abs(pos.x() - rect.left()) <= self.resizeGripSize: + return "bottom-left" + elif abs(pos.y() - rect.top()) <= self.resizeGripSize and abs(pos.x() - rect.right()) <= self.resizeGripSize: + return "top-right" + elif abs(pos.y() - rect.top()) <= self.resizeGripSize and abs(pos.x() - rect.left()) <= self.resizeGripSize: + return "top-left" + elif abs(pos.x() - rect.right()) <= self.resizeGripSize: + return "right" + elif abs(pos.x() - rect.left()) <= self.resizeGripSize: + return "left" + elif abs(pos.y() - rect.top()) <= self.resizeGripSize: + return "top" + elif abs(pos.y() - rect.bottom()) <= self.resizeGripSize: + return "bottom" + + def getCursorShape(self, pos): + direction = self.getResizeDirection(pos) + if direction in ["top-right", "bottom-left"]: + return Qt.SizeBDiagCursor + elif direction in ["top-left", "bottom-right"]: + return Qt.SizeFDiagCursor + elif direction in ["left", "right"]: + return Qt.SizeHorCursor + elif direction in ["top", "bottom"]: + return Qt.SizeVerCursor + return Qt.ArrowCursor + + def eventFilter(self, obj, event): + if event.type() == QEvent.MouseButtonPress: + if not self.rect().contains(self.mapFromGlobal(event.globalPos())): + self.close() + return True + return super(ResizableWidget, self).eventFilter(obj, event) + + def closeEvent(self, event): + QApplication.instance().removeEventFilter(self) + super(ResizableWidget, self).closeEvent(event) + + +class ElidedItemDelegate(QStyledItemDelegate): + """custom eliding delegate""" + def __init__(self, parent=None): + super(ElidedItemDelegate, self).__init__(parent) + self.elideMode = Qt.ElideNone + self.tooltip = None + self.highlight_color = QColor(200, 200, 255) + + def elideText(self, text, fontMetrics, available_width): + elidedText = text + text_width = fontMetrics.width(text) + + if text_width > available_width: + elidedText = "" + for char in text: + if fontMetrics.width(elidedText + char) > available_width: + break + elidedText += char + + return elidedText + + def paint(self, painter, option, index): + if option.state & QStyle.State_Selected: + painter.fillRect(option.rect, self.highlight_color) + + text = index.data(Qt.DisplayRole) + if text: + fontMetrics = option.fontMetrics + available_width = option.rect.width() - 10 + elidedText = self.elideText(text, fontMetrics, available_width) + + painter.drawText(option.rect.adjusted(0, 0, -20, 0), Qt.AlignLeft | Qt.AlignTop, elidedText) + + if fontMetrics.width(text) > available_width or '\n' in text: + buttonRect = QRect(option.rect.right() - 25, option.rect.top(), 25, option.rect.height()) + painter.setPen(QColor(Qt.blue)) + painter.drawText(buttonRect, Qt.AlignCenter | Qt.AlignTop, "...") + painter.setPen(QColor(Qt.black)) + else: + super(ElidedItemDelegate, self).paint(painter, option, index) + + def sizeHint(self, option, index): + fontMetrics = option.fontMetrics + return QSize(option.rect.width(), fontMetrics.height()) + + def editorEvent(self, event, model, option, index): + if event.type() == QEvent.MouseButtonRelease: + pos = event.pos() + text = index.data(Qt.DisplayRole) + fontMetrics = QFontMetrics(option.font) + available_width = option.rect.width() - 10 + if fontMetrics.width(text) > available_width or '\n' in text: + buttonRect = QRect(option.rect.right() - 25, option.rect.top(), 25, option.rect.height()) + if buttonRect.contains(pos): + self.showInteractiveTooltip(text, event.globalPos()) + return True + return super(ElidedItemDelegate, self).editorEvent(event, model, option, index) + + def showInteractiveTooltip(self, text, pos): + if self.tooltip: + self.tooltip.close() + self.tooltip = ResizableWidget(text, pos) + self.tooltip.show() + + +class TracebackVisualizer(QMainWindow): + """main window""" + def __init__(self, events): + super().__init__() + self.events = events + self.initUI() + self.populateTree() + + def initUI(self): + self.setWindowTitle("Traceback Visualizer") + self.setGeometry(100, 100, 1200, 800) + self.showMaximized() + + self.tree = QTreeWidget() + self.tree.setColumnCount(4) + self.tree.setHeaderLabels(['Function', 'Arguments', 'Kind', 'Filename:Line']) + self.tree.itemClicked.connect(self.onItemClicked) + + self.details_widget = QTreeWidget() + self.details_widget.setColumnCount(3) + self.details_widget.setHeaderLabels(['Key', 'Type', 'Value']) + self.details_widget.setItemDelegate(ElidedItemDelegate()) + self.details_widget.setSelectionBehavior(QAbstractItemView.SelectItems) + + main_layout = QVBoxLayout() + main_layout.addWidget(self.tree) + main_widget = QWidget() + main_widget.setLayout(main_layout) + + details_layout = QVBoxLayout() + details_layout.addWidget(self.details_widget) + details_widget = QWidget() + details_widget.setLayout(details_layout) + + splitter = QSplitter(Qt.Horizontal) + splitter.setHandleWidth(2) + splitter.addWidget(main_widget) + splitter.addWidget(details_widget) + splitter.setStretchFactor(0, 3) + splitter.setStretchFactor(1, 1) + + self.setCentralWidget(splitter) + + def onItemClicked(self, item): + self.details_widget.clear() + index = item.index + args = self.events[index - 1]["args"]["initial_args"] + self.populateDetails(args) + + def populateDetails(self, data, parent=None): + if parent is None: + parent = self.details_widget.invisibleRootItem() + + def add_item(parent, key, value, item_type, length=None): + if length is None: + QTreeWidgetItem(parent, [key, f'{{{item_type}}}', str(value)]) + else: + QTreeWidgetItem(parent, [key, f"{{{item_type}: {length}}}", ""]) + + if isinstance(data, dict): + for key, value in data.items(): + item_type = type(value).__name__ + if not isinstance(value, (list, dict, tuple, set)): + add_item(parent, key, value, item_type) + else: + add_item(parent, key, "", item_type, len(value)) + self.populateDetails(value, parent.child(parent.childCount() - 1)) + elif isinstance(data, (list, tuple, set)): + for i, value in enumerate(data): + item_type = type(value).__name__ + if not isinstance(value, (list, dict, tuple, set)): + add_item(parent, str(i), value, item_type) + else: + add_item(parent, str(i), "", item_type, len(value)) + self.populateDetails(value, parent.child(parent.childCount() - 1)) + else: + item_type = type(data).__name__ + add_item(parent, "", data, item_type) + + def populateTree(self): + top_level_items = [] + + stack = [] # (current element, depth) + for event in tqdm(self.events, desc="Processing events"): + filename_lineno = f"{event['filename_prefix']}" + kind = event['kind'] + function = f"{'=>' if kind == 'call' else '<=' if kind == 'return' else ''} {event['function']}" + args = event["args"]["repr_args"] + depth = event['depth'] + item = QTreeWidgetItem([function, args, kind, filename_lineno]) + item.index = event["counter"] + + while stack and stack[-1][1] >= depth: + stack.pop() + + if stack: + stack[-1][0].addChild(item) + else: + top_level_items.append(item) + + stack.append((item, depth)) + + self.tree.addTopLevelItems(top_level_items)