diff --git a/cq_editor/widgets/traceback_viewer.py b/cq_editor/widgets/traceback_viewer.py index b02f378f..55050c69 100644 --- a/cq_editor/widgets/traceback_viewer.py +++ b/cq_editor/widgets/traceback_viewer.py @@ -1,9 +1,16 @@ from traceback import extract_tb, format_exception_only from itertools import dropwhile -from PyQt5.QtWidgets import QWidget, QTreeWidget, QTreeWidgetItem, QAction, QLabel +from PyQt5.QtWidgets import ( + QWidget, + QTreeWidget, + QTreeWidgetItem, + QAction, + QLabel, + QApplication, +) from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal -from PyQt5.QtGui import QFontMetrics +from PyQt5.QtGui import QFontMetrics, QKeySequence from ..mixins import ComponentMixin from ..utils import layout @@ -39,6 +46,20 @@ def __init__(self, parent): self.current_exception = QLabel(self) self.current_exception.setStyleSheet("QLabel {color : red; }") + # the message is shown elided, so let it at least be selected + self.current_exception.setTextInteractionFlags( + Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard + ) + + # the full exception text, unescaped and not elided + self._exception_text = "" + + self.copy_action = QAction("Copy traceback", self) + self.copy_action.setShortcut(QKeySequence.Copy) + self.copy_action.setShortcutContext(Qt.WidgetWithChildrenShortcut) + self.copy_action.triggered.connect(self.copyTraceback) + self.tree.addAction(self.copy_action) + layout(self, (self.current_exception, self.tree), self) self.tree.currentItemChanged.connect(self.handleSelection) @@ -78,6 +99,9 @@ def addTraceback(self, exc_info, code): exc_name = t.__name__ exc_msg = str(exc) + + self._exception_text = "{}: {}".format(exc_name, exc_msg) + exc_msg = exc_msg.replace("<", "<").replace(">", ">") # replace <> truncated_msg = self.truncate_text(exc_msg) @@ -98,9 +122,47 @@ def addTraceback(self, exc_info, code): ) ) else: + self._exception_text = "" self.current_exception.setText("") self.current_exception.setToolTip("") + def tracebackText(self): + """ + The traceback as it is shown in the pane, in the usual Python format. + """ + + if not self._exception_text: + return "" + + lines = ["Traceback (most recent call last):"] + + root = self.tree.root + + for i in range(root.childCount()): + item = root.child(i) + filename, lineno, code = (item.data(col, 0) for col in range(3)) + + lines.append(' File "{}", line {}'.format(filename, lineno)) + + if code: + lines.append(" {}".format(code)) + + lines.append(self._exception_text) + + return "\n".join(lines) + + @pyqtSlot() + def copyTraceback(self): + """ + Puts the traceback on the clipboard, so that it can be pasted into a + bug report or a search box. + """ + + text = self.tracebackText() + + if text: + QApplication.clipboard().setText(text) + @pyqtSlot(QTreeWidgetItem, QTreeWidgetItem) def handleSelection(self, item, *args): diff --git a/tests/test_app.py b/tests/test_app.py index 76deda64..b5bcfb5b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -12,11 +12,12 @@ import cadquery as cq from PyQt5.QtCore import Qt, QSettings, QPoint, QEvent, QSize -from PyQt5.QtWidgets import QFileDialog, QMessageBox +from PyQt5.QtWidgets import QApplication, QFileDialog, QMessageBox from PyQt5.QtGui import QMouseEvent from cq_editor.__main__ import MainWindow from cq_editor.widgets.editor import Editor +from cq_editor.widgets.traceback_viewer import TracebackPane from cq_editor.cq_utils import export, get_occ_color code = """import cadquery as cq @@ -970,6 +971,37 @@ def test_search(editor): qtbot.keyClick(editor, Qt.Key_F3, modifier=Qt.AltModifier) +def test_traceback_copy(qtbot): + """ + The traceback should be copyable, so that it can be pasted elsewhere. + """ + + pane = TracebackPane(None) + qtbot.addWidget(pane) + + try: + exec(compile(code_err2, "", "exec"), {}) + except Exception: + exc_info = sys.exc_info() + + pane.addTraceback(exc_info, code_err2) + + pane.copy_action.triggered.emit() + + text = QApplication.clipboard().text() + + assert text.startswith("Traceback (most recent call last):") + assert 'File "", line 3' in text + assert text.endswith("NameError: name 'f' is not defined") + + # the message is shown elided, so it has to be selectable as well + assert pane.current_exception.textInteractionFlags() & Qt.TextSelectableByMouse + + # nothing to copy when there is no traceback + pane.addTraceback(None, "") + assert pane.tracebackText() == "" + + def test_line_number_area(editor): """ Tests to make sure the line number area on the left of the editor is working correctly.