"""
THis module contains widgets used for error reporting. The report backed is sentry_.
.. _sentry: https://sentry.io
"""
import getpass
import io
import os
import pprint
import re
import traceback
import typing
from contextlib import suppress
import numpy as np
import requests
import sentry_sdk
from napari.settings import get_settings
from napari.utils.theme import get_theme
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import (
QApplication,
QDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QPushButton,
QTextEdit,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from sentry_sdk.utils import event_from_exception, exc_info_from_error
from traceback_with_variables import Format, print_exc
from PartSeg import __version__, state_store
from PartSeg.common_backend.python_syntax_highlight import Pylighter
from PartSegCore.io_utils import find_problematic_leafs
from PartSegCore.segmentation.algorithm_base import SegmentationLimitException
from PartSegCore.utils import numpy_repr
try:
from qtpy import QT5
except ImportError: # pragma: no cover
QT5 = True
_EMAIL_REGEXP = re.compile(r"[\w+]+@\w+\.\w+")
_FEEDBACK_URL = "https://sentry.io/api/0/projects/{organization_slug}/{project_slug}/user-feedback/".format(
organization_slug="cent", project_slug="partseg"
)
def _print_traceback(exception, file_):
while True:
print_exc(exception, file_=file_, fmt=Format(custom_var_printers=[(np.ndarray, numpy_repr)]))
if exception.__cause__ is not None:
print("The above exception was the direct cause of the following exception:", file=file_)
exception = exception.__cause__
elif exception.__context__ is not None:
print("During handling of the above exception, another exception occurred:", file=file_)
exception = exception.__context__
else:
break
[docs]
class ErrorDialog(QDialog):
"""
Dialog to present user the exception information. User can send error report (possible to add custom information)
"""
def __init__(self, exception: Exception, description: str, additional_notes: str = "", additional_info=None):
super().__init__()
self.exception = exception
self.additional_notes = additional_notes
self.send_report_btn = QPushButton("Report error")
self.send_report_btn.setDisabled(not state_store.report_errors)
self.create_issue_btn = QPushButton("Create issue")
self.cancel_btn = QPushButton("Cancel")
self.error_description = QTextEdit()
theme = get_theme(get_settings().appearance.theme, as_dict=False)
self._highlight = Pylighter(self.error_description.document(), "python", theme.syntax_style)
self.traceback_summary = additional_info
if additional_info is None:
stream = io.StringIO()
_print_traceback(exception, file_=stream)
self.error_description.setText(stream.getvalue())
elif isinstance(additional_info, traceback.StackSummary):
self.error_description.setText("".join(additional_info.format()))
elif isinstance(additional_info[1], traceback.StackSummary):
self.error_description.setText("".join(additional_info[1].format()))
self.error_description.append(str(exception))
self.error_description.setReadOnly(True)
self.additional_info = QTextEdit()
self.contact_info = QLineEdit()
self.user_name = QLineEdit()
self.cancel_btn.clicked.connect(self.reject)
self.send_report_btn.clicked.connect(self.send_report)
self.create_issue_btn.clicked.connect(self.create_issue)
self.desc = QLabel(description)
self.desc.setWordWrap(True)
self.setup_ui()
if isinstance(additional_info, tuple):
self.exception_tuple = additional_info[0], None
else:
exec_info = exc_info_from_error(exception)
self.exception_tuple = event_from_exception(exec_info)
[docs]
def setup_ui(self):
layout = QVBoxLayout()
info_text = QLabel(
"If you see these dialog it not means that you do something wrong. "
"In such case you should see some message box not error report dialog."
)
info_text.setWordWrap(True)
layout.addWidget(info_text)
layout.addWidget(self.desc)
layout.addWidget(self.error_description, 1)
layout.addWidget(QLabel("Contact information"))
layout.addWidget(self.contact_info)
layout.addWidget(QLabel("User name"))
layout.addWidget(self.user_name)
layout.addWidget(QLabel("Additional information from user:"))
layout.addWidget(self.additional_info)
if not state_store.report_errors:
layout.addWidget(
QLabel(
"Sending reports was disabled by runtime flag. "
"You can report it manually by creating report on "
"https://github.com/4DNucleome/PartSeg/issues"
)
)
btn_layout = QHBoxLayout()
btn_layout.addWidget(self.cancel_btn)
btn_layout.addWidget(self.create_issue_btn)
btn_layout.addWidget(self.send_report_btn)
layout.addLayout(btn_layout)
self.setLayout(layout)
if QT5:
[docs]
def exec(self):
self.exec_()
[docs]
def exec_(self):
"""
Check if dialog should be shown base on :py:data:`state_store.show_error_dialog`.
If yes then show dialog. Otherwise print exception traceback on stderr.
"""
# TODO check if this check is needed
if not state_store.show_error_dialog:
print_exc(self.exception)
return False
super().exec_()
return None
[docs]
def create_issue(self):
"""
Create issue on github. This method is used when user disable error reporting.
"""
import urllib.parse
import webbrowser
url = "https://github.com/4DNucleome/PartSeg/issues/new?"
data = {
"title": f"Error report from PartSeg `{self.exception!r}`",
"body": f"This issue is created from PartSeg error dialog\n\n```"
f"python\n{self.error_description.toPlainText()}\n```\n",
"labels": "bug",
}
versions_dkt = {"PartSeg": __version__}
with suppress(ModuleNotFoundError):
from importlib.metadata import PackageNotFoundError, version
for name in ["napari", "numpy", "SimpleITK", "PartSegData", "PartSegCore_compiled_backend"]:
try:
versions_dkt[name] = version(name)
except PackageNotFoundError: # pragma: no cover # noqa: PERF203
versions_dkt[name] = "not found"
data["body"] += "Packages: \n```\n" + "\n".join(f"{k}=={v}" for k, v in versions_dkt.items()) + "\n```\n"
webbrowser.open(f"{url}{urllib.parse.urlencode(data)}")
[docs]
def send_report(self):
"""
Function with construct final error message and send it using sentry.
"""
with sentry_sdk.push_scope() as scope:
text = f"{self.desc.text()}\n\nVersion: {__version__}\n"
if len(self.additional_notes) > 0:
scope.set_extra("additional_notes", self.additional_notes)
if len(self.additional_info.toPlainText()) > 0:
scope.set_extra("user_information", self.additional_info.toPlainText())
if len(self.contact_info.text()) > 0:
scope.set_extra("contact", self.contact_info.text())
event, hint = self.exception_tuple
event["message"] = text
if self.traceback_summary is not None:
scope.set_extra("traceback", self.error_description.toPlainText())
event_id = sentry_sdk.capture_event(event, hint=hint)
if event_id is None:
event_id = sentry_sdk.hub.Hub.current.last_event_id()
if len(self.additional_info.toPlainText()) > 0:
contact_text = self.contact_info.text()
user_name = self.user_name.text()
data = {
"comments": self.additional_info.toPlainText(),
"event_id": event_id,
"email": contact_text if _EMAIL_REGEXP.match(contact_text) else "unknown@unknown.com",
"name": user_name or get_user(),
}
with suppress(requests.exceptions.Timeout):
r = requests.post(
url=_FEEDBACK_URL,
data=data,
headers={"Authorization": "DSN https://d4118280b73d4ee3a0222d0b17637687@sentry.io/1309302"},
timeout=3,
)
if r.status_code != 200:
data["email"] = "unknown@unknown.com"
data["name"] = get_user()
requests.post(
url=_FEEDBACK_URL,
data=data,
headers={"Authorization": "DSN https://d4118280b73d4ee3a0222d0b17637687@sentry.io/1309302"},
timeout=3,
)
self.accept()
[docs]
class ExceptionListItem(QListWidgetItem):
"""
Element storing exception and showing basic information about it
:param exception: exception or union of exception and traceback
"""
# TODO Prevent from reporting disc error
def __init__(
self,
exception: typing.Union[Exception, typing.Tuple[Exception, typing.List]],
parent: typing.Optional[QListWidget] = None,
):
if isinstance(exception, Exception):
traceback_summary = None
else:
exception, traceback_summary = exception
if isinstance(exception, SegmentationLimitException):
super().__init__(f"{exception}", parent, QListWidgetItem.ItemType.UserType)
elif isinstance(exception, Exception):
super().__init__(f"{type(exception)}: {exception}", parent, QListWidgetItem.ItemType.UserType)
self.setToolTip("Double click for report")
self.exception = exception
self.additional_info = traceback_summary
[docs]
class ExceptionList(QListWidget):
"""
List to store exceptions
"""
def __init__(self, parent=None):
super().__init__(parent)
self.itemDoubleClicked.connect(self.item_double_clicked)
[docs]
@staticmethod
def item_double_clicked(el: QListWidgetItem):
"""
if element clicked is :py:class:`ExceptionListItem` then open
:py:class:`ErrorDialog` for reporting this error.
This function is connected to :py:meth:`QListWidget.itemDoubleClicked`
"""
if isinstance(el, ExceptionListItem) and not isinstance(el.exception, SegmentationLimitException):
dial = ErrorDialog(el.exception, "Error during batch processing", additional_info=el.additional_info)
dial.exec_()
[docs]
class DataImportErrorDialog(QDialog):
def __init__(
self,
errors: typing.Dict[str, typing.Union[Exception, typing.List[typing.Tuple[str, dict]]]],
parent: typing.Optional[QWidget] = None,
text: str = "During import data part of the entries was filtered out",
):
super().__init__(parent)
self.setWindowTitle("Data import error")
self.setWindowIcon(QIcon(":/icons/error.png"))
self.setMinimumWidth(500)
self.setMinimumHeight(500)
self.setLayout(QVBoxLayout())
self.setModal(True)
self.layout().setContentsMargins(0, 0, 0, 0)
self.layout().setSpacing(0)
self.errors = errors
self.error_view = QTreeWidget()
self.layout().addWidget(QLabel(text))
self.layout().addWidget(self.error_view)
self.error_view.setHeaderLabels(["Keys", "Details"])
for file_path, values in self.errors.items():
file_item = QTreeWidgetItem(self.error_view, [file_path])
file_item.setExpanded(True)
file_item.setFirstColumnSpanned(True)
if isinstance(values, Exception):
QTreeWidgetItem(file_item, [str(values)]).setFirstColumnSpanned(True)
continue
for key, desc in values:
problematic_entries = find_problematic_leafs(desc)
item = QTreeWidgetItem(file_item, [key, str(problematic_entries[0]["__error__"]) + "..."])
if len(problematic_entries) == 1:
QTreeWidgetItem(item, ["", pprint.pformat(problematic_entries[0])])
if len(problematic_entries) > 1:
for entry in problematic_entries:
item2 = QTreeWidgetItem(item, ["", str(entry["__error__"])])
QTreeWidgetItem(item2, ["", pprint.pformat(entry)])
close_btn = QPushButton("Close")
close_btn.clicked.connect(self.accept)
copy_btn = QPushButton("Copy to clipboard")
copy_btn.clicked.connect(self._copy_to_clipboard)
btn_layout = QHBoxLayout()
btn_layout.addWidget(close_btn)
btn_layout.addWidget(copy_btn)
self.layout().addLayout(btn_layout)
def _copy_to_clipboard(self):
res = ""
for file_path, values in self.errors.items():
res += f"{file_path}\n"
if isinstance(values, Exception):
res += f"\t{values}\n"
continue
for key, desc in values:
problematic_entries = find_problematic_leafs(desc)
for entry in problematic_entries:
res += f"{key}: {entry['__error__']}\n{pprint.pformat(entry)}\n\n"
QApplication.clipboard().setText(res)
[docs]
class QMessageFromException(QMessageBox):
"""
Specialized QMessageBox to provide information with attached exception
"""
def __init__(self, icon, title, text, exception, standard_buttons=QMessageBox.StandardButton.Ok, parent=None):
super().__init__(icon, title, text, standard_buttons, parent)
self.exception = exception
stream = io.StringIO()
_print_traceback(exception, file_=stream)
self.setDetailedText(stream.getvalue())
[docs]
@classmethod
def critical(
cls,
parent=None,
title="",
text="",
standard_buttons=QMessageBox.StandardButton.Ok,
default_button=QMessageBox.StandardButton.NoButton,
exception=None,
) -> QMessageBox.StandardButton: # pylint: disable=arguments-differ
ob = cls(
icon=QMessageBox.Icon.Critical,
title=title,
text=text,
exception=exception,
parent=parent,
standard_buttons=standard_buttons,
)
ob.setDefaultButton(default_button)
return ob.exec_()
[docs]
@classmethod
def question(
cls,
parent=None,
title="",
text="",
standard_buttons=QMessageBox.StandardButton.Ok,
default_button=QMessageBox.StandardButton.NoButton,
exception=None,
) -> QMessageBox.StandardButton: # pylint: disable=arguments-differ
ob = cls(
icon=QMessageBox.Icon.Question,
title=title,
text=text,
exception=exception,
parent=parent,
standard_buttons=standard_buttons,
)
ob.setDefaultButton(default_button)
return ob.exec_()
[docs]
@classmethod
def warning(
cls,
parent=None,
title="",
text="",
standard_buttons=QMessageBox.StandardButton.Ok,
default_button=QMessageBox.StandardButton.NoButton,
exception=None,
) -> QMessageBox.StandardButton: # pylint: disable=arguments-differ
ob = cls(
icon=QMessageBox.Icon.Warning,
title=title,
text=text,
exception=exception,
parent=parent,
standard_buttons=standard_buttons,
)
ob.setDefaultButton(default_button)
return ob.exec_()
[docs]
def get_user() -> str:
try:
return getpass.getuser()
except ModuleNotFoundError: # pragma: no cover
# On windows `pwd` module is not available
return os.getlogin()