Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 108 additions & 15 deletions PyReconstruct/modules/gui/utils/errors.py
Original file line number Diff line number Diff line change
@@ -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"<b>An error occurred:</b><br><br>{html.escape(str(value))}<br><br>"
"Click <b>Copy report to clipboard</b> below, then paste it into a bug "
"report or email so we can help.<br><br>"
f'Report bugs at <a href="{gh_issues}">{gh_issues}</a>'
)

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()

27 changes: 27 additions & 0 deletions tests/test_error_report.py
Original file line number Diff line number Diff line change
@@ -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)
Loading