CrashHandler.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. # Copyright (c) 2017 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. import sys
  4. import platform
  5. import traceback
  6. import faulthandler
  7. import tempfile
  8. import os
  9. import os.path
  10. import time
  11. import json
  12. import ssl
  13. import urllib.request
  14. import urllib.error
  15. from PyQt5.QtCore import QT_VERSION_STR, PYQT_VERSION_STR, QCoreApplication
  16. from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout, QLabel, QTextEdit, QGroupBox
  17. from UM.Application import Application
  18. from UM.Logger import Logger
  19. from UM.View.GL.OpenGL import OpenGL
  20. from UM.i18n import i18nCatalog
  21. from UM.Platform import Platform
  22. catalog = i18nCatalog("cura")
  23. MYPY = False
  24. if MYPY:
  25. CuraDebugMode = False
  26. else:
  27. try:
  28. from cura.CuraVersion import CuraDebugMode
  29. except ImportError:
  30. CuraDebugMode = False # [CodeStyle: Reflecting imported value]
  31. # List of exceptions that should be considered "fatal" and abort the program.
  32. # These are primarily some exception types that we simply cannot really recover from
  33. # (MemoryError and SystemError) and exceptions that indicate grave errors in the
  34. # code that cause the Python interpreter to fail (SyntaxError, ImportError).
  35. fatal_exception_types = [
  36. MemoryError,
  37. SyntaxError,
  38. ImportError,
  39. SystemError,
  40. ]
  41. class CrashHandler:
  42. crash_url = "https://stats.ultimaker.com/api/cura"
  43. def __init__(self, exception_type, value, tb):
  44. self.exception_type = exception_type
  45. self.value = value
  46. self.traceback = tb
  47. self.dialog = None # Don't create a QDialog before there is a QApplication
  48. # While we create the GUI, the information will be stored for sending afterwards
  49. self.data = dict()
  50. self.data["time_stamp"] = time.time()
  51. Logger.log("c", "An uncaught error has occurred!")
  52. for line in traceback.format_exception(exception_type, value, tb):
  53. for part in line.rstrip("\n").split("\n"):
  54. Logger.log("c", part)
  55. if not CuraDebugMode and exception_type not in fatal_exception_types:
  56. return
  57. application = QCoreApplication.instance()
  58. if not application:
  59. sys.exit(1)
  60. self.dialog = QDialog()
  61. self._createDialog()
  62. ## Creates a modal dialog.
  63. def _createDialog(self):
  64. self.dialog.setMinimumWidth(640)
  65. self.dialog.setMinimumHeight(640)
  66. self.dialog.setWindowTitle(catalog.i18nc("@title:window", "Crash Report"))
  67. layout = QVBoxLayout(self.dialog)
  68. layout.addWidget(self._messageWidget())
  69. layout.addWidget(self._informationWidget())
  70. layout.addWidget(self._exceptionInfoWidget())
  71. layout.addWidget(self._logInfoWidget())
  72. layout.addWidget(self._userDescriptionWidget())
  73. layout.addWidget(self._buttonsWidget())
  74. def _messageWidget(self):
  75. label = QLabel()
  76. label.setText(catalog.i18nc("@label crash message", """<p><b>A fatal error has occurred. Please send us this Crash Report to fix the problem</p></b>
  77. <p>Please use the "Send report" button to post a bug report automatically to our servers</p>
  78. """))
  79. return label
  80. def _informationWidget(self):
  81. group = QGroupBox()
  82. group.setTitle(catalog.i18nc("@title:groupbox", "System information"))
  83. layout = QVBoxLayout()
  84. label = QLabel()
  85. try:
  86. from UM.Application import Application
  87. self.cura_version = Application.getInstance().getVersion()
  88. except:
  89. self.cura_version = catalog.i18nc("@label unknown version of Cura", "Unknown")
  90. crash_info = "<b>" + catalog.i18nc("@label Cura version number", "Cura version") + ":</b> " + str(self.cura_version) + "<br/>"
  91. crash_info += "<b>" + catalog.i18nc("@label Type of platform", "Platform") + ":</b> " + str(platform.platform()) + "<br/>"
  92. crash_info += "<b>" + catalog.i18nc("@label", "Qt version") + ":</b> " + str(QT_VERSION_STR) + "<br/>"
  93. crash_info += "<b>" + catalog.i18nc("@label", "PyQt version") + ":</b> " + str(PYQT_VERSION_STR) + "<br/>"
  94. crash_info += "<b>" + catalog.i18nc("@label OpenGL version", "OpenGL") + ":</b> " + str(self._getOpenGLInfo()) + "<br/>"
  95. label.setText(crash_info)
  96. layout.addWidget(label)
  97. group.setLayout(layout)
  98. self.data["cura_version"] = self.cura_version
  99. self.data["os"] = {"type": platform.system(), "version": platform.version()}
  100. self.data["qt_version"] = QT_VERSION_STR
  101. self.data["pyqt_version"] = PYQT_VERSION_STR
  102. return group
  103. def _getOpenGLInfo(self):
  104. opengl_instance = OpenGL.getInstance()
  105. if not opengl_instance:
  106. self.data["opengl"] = {"version": "n/a", "vendor": "n/a", "type": "n/a"}
  107. return catalog.i18nc("@label", "not yet initialised<br/>")
  108. info = "<ul>"
  109. info += catalog.i18nc("@label OpenGL version", "<li>OpenGL Version: {version}</li>").format(version = opengl_instance.getOpenGLVersion())
  110. info += catalog.i18nc("@label OpenGL vendor", "<li>OpenGL Vendor: {vendor}</li>").format(vendor = opengl_instance.getGPUVendorName())
  111. info += catalog.i18nc("@label OpenGL renderer", "<li>OpenGL Renderer: {renderer}</li>").format(renderer = opengl_instance.getGPUType())
  112. info += "</ul>"
  113. self.data["opengl"] = {"version": opengl_instance.getOpenGLVersion(), "vendor": opengl_instance.getGPUVendorName(), "type": opengl_instance.getGPUType()}
  114. return info
  115. def _exceptionInfoWidget(self):
  116. group = QGroupBox()
  117. group.setTitle(catalog.i18nc("@title:groupbox", "Error traceback"))
  118. layout = QVBoxLayout()
  119. text_area = QTextEdit()
  120. trace_dict = traceback.format_exception(self.exception_type, self.value, self.traceback)
  121. trace = "".join(trace_dict)
  122. text_area.setText(trace)
  123. text_area.setReadOnly(True)
  124. layout.addWidget(text_area)
  125. group.setLayout(layout)
  126. # Parsing all the information to fill the dictionary
  127. summary = trace_dict[len(trace_dict)-1].rstrip("\n")
  128. module = trace_dict[len(trace_dict)-2].rstrip("\n").split("\n")
  129. module_split = module[0].split(", ")
  130. filepath = module_split[0].split("\"")[1]
  131. directory, filename = os.path.split(filepath)
  132. line = int(module_split[1].lstrip("line "))
  133. function = module_split[2].lstrip("in ")
  134. code = module[1].lstrip(" ")
  135. # Using this workaround for a cross-platform path splitting
  136. split_path = []
  137. folder_name = ""
  138. # Split until reach folder "cura"
  139. while folder_name != "cura":
  140. directory, folder_name = os.path.split(directory)
  141. if not folder_name:
  142. break
  143. split_path.append(folder_name)
  144. # Look for plugins. If it's not a plugin, the current cura version is set
  145. isPlugin = False
  146. module_version = self.cura_version
  147. module_name = "Cura"
  148. if split_path.__contains__("plugins"):
  149. isPlugin = True
  150. # Look backwards until plugin.json is found
  151. directory, name = os.path.split(filepath)
  152. while not os.listdir(directory).__contains__("plugin.json"):
  153. directory, name = os.path.split(directory)
  154. json_metadata_file = os.path.join(directory, "plugin.json")
  155. try:
  156. with open(json_metadata_file, "r") as f:
  157. try:
  158. metadata = json.loads(f.read())
  159. module_version = metadata["version"]
  160. module_name = metadata["name"]
  161. except json.decoder.JSONDecodeError:
  162. # Not throw new exceptions
  163. Logger.logException("e", "Failed to parse plugin.json for plugin %s", name)
  164. except:
  165. # Not throw new exceptions
  166. pass
  167. exception_dict = dict()
  168. exception_dict["traceback"] = {"summary": summary, "full_trace": trace}
  169. exception_dict["location"] = {"path": filepath, "file": filename, "function": function, "code": code, "line": line,
  170. "module_name": module_name, "version": module_version, "is_plugin": isPlugin}
  171. self.data["exception"] = exception_dict
  172. return group
  173. def _logInfoWidget(self):
  174. group = QGroupBox()
  175. group.setTitle(catalog.i18nc("@title:groupbox", "Logs"))
  176. layout = QVBoxLayout()
  177. text_area = QTextEdit()
  178. tmp_file_fd, tmp_file_path = tempfile.mkstemp(prefix = "cura-crash", text = True)
  179. os.close(tmp_file_fd)
  180. with open(tmp_file_path, "w") as f:
  181. faulthandler.dump_traceback(f, all_threads=True)
  182. with open(tmp_file_path, "r") as f:
  183. logdata = f.read()
  184. text_area.setText(logdata)
  185. text_area.setReadOnly(True)
  186. layout.addWidget(text_area)
  187. group.setLayout(layout)
  188. self.data["log"] = logdata
  189. return group
  190. def _userDescriptionWidget(self):
  191. group = QGroupBox()
  192. group.setTitle(catalog.i18nc("@title:groupbox", "User description"))
  193. layout = QVBoxLayout()
  194. # When sending the report, the user comments will be collected
  195. self.user_description_text_area = QTextEdit()
  196. self.user_description_text_area.setFocus(True)
  197. layout.addWidget(self.user_description_text_area)
  198. group.setLayout(layout)
  199. return group
  200. def _buttonsWidget(self):
  201. buttons = QDialogButtonBox()
  202. buttons.addButton(QDialogButtonBox.Close)
  203. buttons.addButton(catalog.i18nc("@action:button", "Send report"), QDialogButtonBox.AcceptRole)
  204. buttons.rejected.connect(self.dialog.close)
  205. buttons.accepted.connect(self._sendCrashReport)
  206. return buttons
  207. def _sendCrashReport(self):
  208. # Before sending data, the user comments are stored
  209. self.data["user_info"] = self.user_description_text_area.toPlainText()
  210. # Convert data to bytes
  211. binary_data = json.dumps(self.data).encode("utf-8")
  212. # Submit data
  213. kwoptions = {"data": binary_data, "timeout": 5}
  214. if Platform.isOSX():
  215. kwoptions["context"] = ssl._create_unverified_context()
  216. Logger.log("i", "Sending crash report info to [%s]...", self.crash_url)
  217. try:
  218. f = urllib.request.urlopen(self.crash_url, **kwoptions)
  219. Logger.log("i", "Sent crash report info.")
  220. f.close()
  221. except urllib.error.HTTPError:
  222. Logger.logException("e", "An HTTP error occurred while trying to send crash report")
  223. except Exception: # We don't want any exception to cause problems
  224. Logger.logException("e", "An exception occurred while trying to send crash report")
  225. os._exit(1)
  226. def show(self):
  227. # must run the GUI code on the Qt thread, otherwise the widgets on the dialog won't react correctly.
  228. Application.getInstance().callLater(self._show)
  229. def _show(self):
  230. # When the exception is not in the fatal_exception_types list, the dialog is not created, so we don't need to show it
  231. if self.dialog:
  232. self.dialog.exec_()
  233. os._exit(1)