From 973a095cf04cb84f0c73bf2204172efb85ae2f61 Mon Sep 17 00:00:00 2001 From: Haozhe Zhang Date: Mon, 13 Jul 2026 00:51:26 -0700 Subject: [PATCH] Make the traceback copyable The exception message sits in a QLabel that is neither selectable nor shown in full, and the traceback tree has an empty context menu, so there is no way to get the error out of the app. Add a 'Copy traceback' action to the traceback pane that puts the frames and the full, unelided exception message on the clipboard in the usual Python format, and make the message label selectable. --- cq_editor/widgets/traceback_viewer.py | 66 ++++++++++++++++++++++++++- tests/test_app.py | 34 +++++++++++++- 2 files changed, 97 insertions(+), 3 deletions(-) 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.