diff --git a/PyReconstruct/modules/gui/utils/errors.py b/PyReconstruct/modules/gui/utils/errors.py index ae9c558f..694edf0d 100644 --- a/PyReconstruct/modules/gui/utils/errors.py +++ b/PyReconstruct/modules/gui/utils/errors.py @@ -1,27 +1,120 @@ import sys +import html +import platform +import traceback as _traceback -from PySide6.QtWidgets import QApplication, QMessageBox +from PySide6.QtCore import Qt +from PySide6.QtGui import QFontDatabase +from PySide6.QtWidgets import ( + QApplication, + QMessageBox, + QDialog, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPlainTextEdit, + QPushButton, +) from PyReconstruct.modules.constants import gh_issues -def customExcepthook(exctype, value, traceback): - """Global exception hook: Show notification.""" - sys.__excepthook__(exctype, value, traceback) # call default exception hook - - message = ( - f"An error occurred:\n\n{str(value)}\n\n" - "(See console for more info.)\n\n" - "If you think this is a bug or need help, " - "please issue a bug report at:\n\n" - f"{gh_issues}" +def build_error_report(exctype, value, tb) -> str: + """Assemble a paste-ready crash report: version / OS / Python context plus the + full traceback. Never raises -- the global exception hook must not itself fail. + """ + lines = ["PyReconstruct error report"] + try: + from PyReconstruct.modules.backend.updater.install_info import current_version_str + lines.append(f"Version: {current_version_str()}") + except Exception: + pass + try: + lines.append(f"Platform: {platform.platform()}") + lines.append(f"Python: {platform.python_version()}") + except Exception: + pass + lines.append("") + try: + tb_text = "".join(_traceback.format_exception(exctype, value, tb)).rstrip() + except Exception: + tb_text = f"{getattr(exctype, '__name__', exctype)}: {value}" + lines.append(tb_text or "(no traceback available)") + return "\n".join(lines) + + +class ErrorReportDialog(QDialog): + """Modal error window that shows a copyable report. + + The frozen app has no console, so lay users cannot read the traceback that + ``sys.__excepthook__`` prints to stderr. This shows the full report inline and + a one-click "Copy report to clipboard" button so it can be pasted into a bug + report or email. + """ + + def __init__(self, summary_html: str, report: str, parent=None): + super().__init__(parent) + self.setWindowTitle("Error") + self._report = report + + layout = QVBoxLayout(self) + + heading = QLabel(summary_html) + heading.setWordWrap(True) + heading.setTextFormat(Qt.RichText) + heading.setTextInteractionFlags(Qt.TextBrowserInteraction) + heading.setOpenExternalLinks(True) + layout.addWidget(heading) + + view = QPlainTextEdit(report) + view.setReadOnly(True) + view.setLineWrapMode(QPlainTextEdit.NoWrap) + view.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont)) + layout.addWidget(view) + + buttons = QHBoxLayout() + self._copy_btn = QPushButton("Copy report to clipboard") + self._copy_btn.clicked.connect(self._copyReport) + close_btn = QPushButton("Close") + close_btn.clicked.connect(self.accept) + close_btn.setDefault(True) + buttons.addWidget(self._copy_btn) + buttons.addStretch() + buttons.addWidget(close_btn) + layout.addLayout(buttons) + + self.resize(720, 480) + + def _copyReport(self): + clipboard = QApplication.clipboard() + if clipboard is not None: + clipboard.setText(self._report) + self._copy_btn.setText("Copied ✓") + + +def customExcepthook(exctype, value, tb): + """Global exception hook: show an error window with a copyable report.""" + sys.__excepthook__(exctype, value, tb) # keep console output for terminal users + + report = build_error_report(exctype, value, tb) + + summary = ( + f"An error occurred:

{html.escape(str(value))}

" + "Click Copy report to clipboard below, then paste it into a bug " + "report or email so we can help.

" + f'Report bugs at {gh_issues}' ) active_window = QApplication.activeWindow() parent = active_window if active_window else None - - QMessageBox.critical(parent, "Error", message, QMessageBox.Ok) - + + try: + ErrorReportDialog(summary, report, parent).exec() + except Exception: + # the error handler itself must never fail -- fall back to a plain box + QMessageBox.critical( + parent, "Error", f"{str(value)}\n\n{gh_issues}", QMessageBox.Ok + ) + if active_window: active_window.activateWindow() - diff --git a/tests/test_error_report.py b/tests/test_error_report.py new file mode 100644 index 00000000..72fd0852 --- /dev/null +++ b/tests/test_error_report.py @@ -0,0 +1,27 @@ +"""The crash-report builder behind the copyable error dialog. + +The frozen app has no console, so the report (shown + copied to clipboard) must +carry the full traceback + context, and the builder must never itself raise +(it runs inside the global exception hook). +""" +import sys + +from PyReconstruct.modules.gui.utils.errors import build_error_report + + +def test_report_has_traceback_and_context(): + try: + raise ValueError("boom-marker-123") + except ValueError: + exctype, value, tb = sys.exc_info() + report = build_error_report(exctype, value, tb) + assert "boom-marker-123" in report # the exception message + assert "ValueError" in report # the exception type + assert "Traceback" in report # the stack + assert "Platform:" in report and "Python:" in report # context + + +def test_report_never_raises_on_bad_input(): + # the exception hook must not fail even on degenerate inputs + assert isinstance(build_error_report(None, None, None), str) + assert isinstance(build_error_report(ValueError, ValueError("x"), None), str)