diff --git a/cura/CrashHandler.py b/cura/CrashHandler.py index 7008ba64d2..04f04a1c37 100644 --- a/cura/CrashHandler.py +++ b/cura/CrashHandler.py @@ -1,18 +1,27 @@ +# Copyright (c) 2017 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + import sys import platform import traceback -import webbrowser import faulthandler import tempfile import os -import urllib +import os.path +import time +import json +import ssl +import urllib.request +import urllib.error -from PyQt5.QtCore import QT_VERSION_STR, PYQT_VERSION_STR, Qt, QCoreApplication -from PyQt5.QtGui import QPixmap -from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QVBoxLayout, QLabel, QTextEdit +from PyQt5.QtCore import QT_VERSION_STR, PYQT_VERSION_STR, QCoreApplication +from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout, QLabel, QTextEdit, QGroupBox, QPushButton from UM.Logger import Logger +from UM.View.GL.OpenGL import OpenGL from UM.i18n import i18nCatalog +from UM.Platform import Platform + catalog = i18nCatalog("cura") MYPY = False @@ -35,83 +44,238 @@ fatal_exception_types = [ SystemError, ] -def show(exception_type, value, tb): - Logger.log("c", "An uncaught exception has occurred!") - for line in traceback.format_exception(exception_type, value, tb): - for part in line.rstrip("\n").split("\n"): - Logger.log("c", part) +class CrashHandler: + crash_url = "https://stats.ultimaker.com/api/cura" - if not CuraDebugMode and exception_type not in fatal_exception_types: - return + def __init__(self, exception_type, value, tb): - application = QCoreApplication.instance() - if not application: - sys.exit(1) + self.exception_type = exception_type + self.value = value + self.traceback = tb - dialog = QDialog() - dialog.setMinimumWidth(640) - dialog.setMinimumHeight(640) - dialog.setWindowTitle(catalog.i18nc("@title:window", "Crash Report")) + # While we create the GUI, the information will be stored for sending afterwards + self.data = dict() + self.data["time_stamp"] = time.time() - layout = QVBoxLayout(dialog) + Logger.log("c", "An uncaught exception has occurred!") + for line in traceback.format_exception(exception_type, value, tb): + for part in line.rstrip("\n").split("\n"): + Logger.log("c", part) - #label = QLabel(dialog) - #pixmap = QPixmap() - #try: - # data = urllib.request.urlopen("http://www.randomkittengenerator.com/cats/rotator.php").read() - # pixmap.loadFromData(data) - #except: - # try: - # from UM.Resources import Resources - # path = Resources.getPath(Resources.Images, "kitten.jpg") - # pixmap.load(path) - # except: - # pass - #pixmap = pixmap.scaled(150, 150) - #label.setPixmap(pixmap) - #label.setAlignment(Qt.AlignCenter) - #layout.addWidget(label) + if not CuraDebugMode and exception_type not in fatal_exception_types: + return - label = QLabel(dialog) - layout.addWidget(label) + application = QCoreApplication.instance() + if not application: + sys.exit(1) - #label.setScaledContents(True) - label.setText(catalog.i18nc("@label", """

A fatal exception has occurred that we could not recover from!

-

Please use the information below to post a bug report at http://github.com/Ultimaker/Cura/issues

- """)) + self._createDialog() - textarea = QTextEdit(dialog) - layout.addWidget(textarea) + ## Creates a modal dialog. + def _createDialog(self): - try: - from UM.Application import Application - version = Application.getInstance().getVersion() - except: - version = "Unknown" + self.dialog = QDialog() + self.dialog.setMinimumWidth(640) + self.dialog.setMinimumHeight(640) + self.dialog.setWindowTitle(catalog.i18nc("@title:window", "Crash Report")) - trace = "".join(traceback.format_exception(exception_type, value, tb)) + layout = QVBoxLayout(self.dialog) - crash_info = "Version: {0}\nPlatform: {1}\nQt: {2}\nPyQt: {3}\n\nException:\n{4}" - crash_info = crash_info.format(version, platform.platform(), QT_VERSION_STR, PYQT_VERSION_STR, trace) + layout.addWidget(self._messageWidget()) + layout.addWidget(self._informationWidget()) + layout.addWidget(self._exceptionInfoWidget()) + layout.addWidget(self._logInfoWidget()) + layout.addWidget(self._userDescriptionWidget()) + layout.addWidget(self._buttonsWidget()) - tmp_file_fd, tmp_file_path = tempfile.mkstemp(prefix = "cura-crash", text = True) - os.close(tmp_file_fd) - with open(tmp_file_path, "w") as f: - faulthandler.dump_traceback(f, all_threads=True) - with open(tmp_file_path, "r") as f: - data = f.read() + def _messageWidget(self): + label = QLabel() + label.setText(catalog.i18nc("@label crash message", """

A fatal exception has occurred. Please send us this Crash Report to fix the problem

+

Please use the "Send report" button to post a bug report automatically to our servers

+ """)) - msg = "-------------------------\n" - msg += data - crash_info += "\n\n" + msg + return label - textarea.setText(crash_info) + def _informationWidget(self): + group = QGroupBox() + group.setTitle(catalog.i18nc("@title:groupbox", "System information")) + layout = QVBoxLayout() + label = QLabel() - buttons = QDialogButtonBox(QDialogButtonBox.Close, dialog) - layout.addWidget(buttons) - buttons.addButton(catalog.i18nc("@action:button", "Open Web Page"), QDialogButtonBox.HelpRole) - buttons.rejected.connect(dialog.close) - buttons.helpRequested.connect(lambda: webbrowser.open("http://github.com/Ultimaker/Cura/issues")) + try: + from UM.Application import Application + self.cura_version = Application.getInstance().getVersion() + except: + self.cura_version = catalog.i18nc("@label unknown version of Cura", "Unknown") - dialog.exec_() - sys.exit(1) + crash_info = catalog.i18nc("@label Cura version", "Cura version: {version}
").format(version = self.cura_version) + crash_info += catalog.i18nc("@label Platform", "Platform: {platform}
").format(platform = platform.platform()) + crash_info += catalog.i18nc("@label Qt version", "Qt version: {qt}
").format(qt = QT_VERSION_STR) + crash_info += catalog.i18nc("@label PyQt version", "PyQt version: {pyqt}
").format(pyqt = PYQT_VERSION_STR) + crash_info += catalog.i18nc("@label OpenGL", "OpenGL: {opengl}
").format(opengl = self._getOpenGLInfo()) + label.setText(crash_info) + + layout.addWidget(label) + group.setLayout(layout) + + self.data["cura_version"] = self.cura_version + self.data["os"] = {"type": platform.system(), "version": platform.version()} + self.data["qt_version"] = QT_VERSION_STR + self.data["pyqt_version"] = PYQT_VERSION_STR + + return group + + def _getOpenGLInfo(self): + info = "" + + self.data["opengl"] = {"version": OpenGL.getInstance().getOpenGLVersion(), "vendor": OpenGL.getInstance().getGPUVendorName(), "type": OpenGL.getInstance().getGPUType()} + + return info + + def _exceptionInfoWidget(self): + group = QGroupBox() + group.setTitle(catalog.i18nc("@title:groupbox", "Exception traceback")) + layout = QVBoxLayout() + + text_area = QTextEdit() + trace_dict = traceback.format_exception(self.exception_type, self.value, self.traceback) + trace = "".join(trace_dict) + text_area.setText(trace) + text_area.setReadOnly(True) + + layout.addWidget(text_area) + group.setLayout(layout) + + # Parsing all the information to fill the dictionary + summary = trace_dict[len(trace_dict)-1].rstrip("\n") + module = trace_dict[len(trace_dict)-2].rstrip("\n").split("\n") + module_split = module[0].split(", ") + filepath = module_split[0].split("\"")[1] + directory, filename = os.path.split(filepath) + line = int(module_split[1].lstrip("line ")) + function = module_split[2].lstrip("in ") + code = module[1].lstrip(" ") + + # Using this workaround for a cross-platform path splitting + split_path = [] + folder_name = "" + # Split until reach folder "cura" + while folder_name != "cura": + directory, folder_name = os.path.split(directory) + if not folder_name: + break + split_path.append(folder_name) + + # Look for plugins. If it's not a plugin, the current cura version is set + isPlugin = False + module_version = self.cura_version + module_name = "Cura" + if split_path.__contains__("plugins"): + isPlugin = True + # Look backwards until plugin.json is found + directory, name = os.path.split(filepath) + while not os.listdir(directory).__contains__("plugin.json"): + directory, name = os.path.split(directory) + + json_metadata_file = os.path.join(directory, "plugin.json") + try: + with open(json_metadata_file, "r") as f: + try: + metadata = json.loads(f.read()) + module_version = metadata["version"] + module_name = metadata["name"] + except json.decoder.JSONDecodeError: + # Not throw new exceptions + Logger.logException("e", "Failed to parse plugin.json for plugin %s", name) + except: + # Not throw new exceptions + pass + + exception_dict = dict() + exception_dict["traceback"] = {"summary": summary, "full_trace": trace} + exception_dict["location"] = {"path": filepath, "file": filename, "function": function, "code": code, "line": line, + "module_name": module_name, "version": module_version, "is_plugin": isPlugin} + self.data["exception"] = exception_dict + + return group + + def _logInfoWidget(self): + group = QGroupBox() + group.setTitle(catalog.i18nc("@title:groupbox", "Logs")) + layout = QVBoxLayout() + + text_area = QTextEdit() + tmp_file_fd, tmp_file_path = tempfile.mkstemp(prefix = "cura-crash", text = True) + os.close(tmp_file_fd) + with open(tmp_file_path, "w") as f: + faulthandler.dump_traceback(f, all_threads=True) + with open(tmp_file_path, "r") as f: + logdata = f.read() + + text_area.setText(logdata) + text_area.setReadOnly(True) + + layout.addWidget(text_area) + group.setLayout(layout) + + self.data["log"] = logdata + + return group + + + def _userDescriptionWidget(self): + group = QGroupBox() + group.setTitle(catalog.i18nc("@title:groupbox", "User description")) + layout = QVBoxLayout() + + # When sending the report, the user comments will be collected + self.user_description_text_area = QTextEdit() + self.user_description_text_area.setFocus(True) + + layout.addWidget(self.user_description_text_area) + group.setLayout(layout) + + return group + + def _buttonsWidget(self): + buttons = QDialogButtonBox() + buttons.addButton(QDialogButtonBox.Close) + buttons.addButton(catalog.i18nc("@action:button", "Send report"), QDialogButtonBox.AcceptRole) + buttons.rejected.connect(self.dialog.close) + buttons.accepted.connect(self._sendCrashReport) + + return buttons + + def _sendCrashReport(self): + # Before sending data, the user comments are stored + self.data["user_info"] = self.user_description_text_area.toPlainText() + + # Convert data to bytes + binary_data = json.dumps(self.data).encode("utf-8") + + # Submit data + kwoptions = {"data": binary_data, "timeout": 5} + + if Platform.isOSX(): + kwoptions["context"] = ssl._create_unverified_context() + + Logger.log("i", "Sending crash report info to [%s]...", self.crash_url) + + try: + f = urllib.request.urlopen(self.crash_url, **kwoptions) + Logger.log("i", "Sent crash report info.") + f.close() + except urllib.error.HTTPError: + Logger.logException("e", "An HTTP error occurred while trying to send crash report") + except Exception: # We don't want any exception to cause problems + Logger.logException("e", "An exception occurred while trying to send crash report") + + os._exit(1) + + def show(self): + self.dialog.exec_() + os._exit(1) \ No newline at end of file diff --git a/cura_app.py b/cura_app.py index 6869fd2111..d725bc1200 100755 --- a/cura_app.py +++ b/cura_app.py @@ -41,8 +41,9 @@ if "PYTHONPATH" in os.environ.keys(): # If PYTHONPATH is u sys.path.insert(1, PATH_real) # Insert it at 1 after os.curdir, which is 0. def exceptHook(hook_type, value, traceback): - import cura.CrashHandler - cura.CrashHandler.show(hook_type, value, traceback) + from cura.CrashHandler import CrashHandler + _crash_handler = CrashHandler(hook_type, value, traceback) + _crash_handler.show() sys.excepthook = exceptHook