anki/qt/aqt/errors.py
Damien Elmes 063b6f60fd Rework error dialog
- Hide traceback
- Include full add-on info in 'copy debug info' button, like about
screen
- Link to troubleshooting page
- Use non-modal pop-up in the common case, to avoid potential conflicts
with other modals.

Closes #2830
2023-11-29 10:25:32 +10:00

342 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
import re
import time
from typing import TYPE_CHECKING, Optional, TextIO, cast
from markdown import markdown
import aqt
from anki.collection import HelpPage
from anki.errors import BackendError, Interrupted
from aqt.addons import AddonManager, AddonMeta
from aqt.qt import *
from aqt.utils import openHelp, showWarning, supportText, tooltip, tr
if TYPE_CHECKING:
from aqt.main import AnkiQt
def show_exception(*, parent: QWidget, exception: Exception) -> None:
"Present a caught exception to the user using a pop-up."
if isinstance(exception, Interrupted):
# nothing to do
return
if isinstance(exception, BackendError):
if exception.context:
print(exception.context)
if exception.backtrace:
print(exception.backtrace)
showWarning(str(exception), parent=parent, help=exception.help_page)
else:
# if the error is not originating from the backend, dump
# a traceback to the console to aid in debugging
traceback.print_exception(
None, exception, exception.__traceback__, file=sys.stdout
)
showWarning(str(exception), parent=parent)
def is_chromium_cert_error(error: str) -> bool:
"""QtWebEngine sometimes spits out 'unknown error' messages to stderr on Windows.
They appear to be IDS_SETTINGS_CERTIFICATE_MANAGER_UNKNOWN_ERROR in
chrome/browser/ui/webui/certificates_handler.cc. At a guess, it's the
NetErrorToString() method.
The constant appears to get converted to an ID; the resources are found
in files like this:
chrome/app/resources/generated_resources_fr-CA.xtb
2258:<translation id="3380365263193509176">Erreur inconnue</translation>
List derived with:
qtwebengine-chromium% rg --no-heading --no-filename --no-line-number \
3380365263193509176 | perl -pe 's/.*>(.*)<.*/"$1",/' | sort | uniq
This list has been manually updated to add a different Japanese translation, as
the translations may change in different Chromium releases.
Judging by error reports, we can't assume the error falls on a separate line:
https://forums.ankiweb.net/t/topic/22036/
"""
if not is_win:
return False
for msg in (
"알 수 없는 오류가 발생했습니다.",
"Bilinmeyen hata",
"Eroare necunoscută",
"Erreur inconnue",
"Erreur inconnue.",
"Erro descoñecido",
"Erro desconhecido",
"Error desconegut",
"Error desconocido",
"Errore ezezaguna",
"Errore sconosciuto",
"Gabim i panjohur",
"Hindi kilalang error",
"Hitilafu isiyojulikana",
"Iphutha elingaziwa",
"Ismeretlen hiba",
"Kesalahan tidak dikenal",
"Lỗi không xác định",
"Naməlum xəta",
"Nepoznata greška",
"Nepoznata pogreška",
"Nezināma kļūda",
"Nežinoma klaida",
"Neznáma chyba",
"Neznámá chyba",
"Neznana napaka",
"Nieznany błąd",
"Nomalum xatolik",
"Okänt fel",
"Onbekende fout",
"Óþekkt villa",
"Ralat tidak diketahui",
"Tundmatu viga",
"Tuntematon virhe",
"Ukendt fejl",
"Ukjent feil",
"Unbekannter Fehler",
"Unknown error",
"Άγνωστο σφάλμα",
"Белгисиз ката",
"Белгісіз қате",
"Невідома помилка",
"Невядомая памылка",
"Неизвестна грешка",
"Неизвестная ошибка",
"Непозната грешка",
"Үл мэдэгдэх алдаа",
"უცნობი შეცდომა",
"Անհայտ սխալ",
"שגיאה לא ידועה",
"خطأ غير معروف",
"خطای ناشناس",
"نامعلوم خرابی",
"ያልታወቀ ስህተት",
"अज्ञात एरर",
"अज्ञात गड़बड़ी",
"अज्ञात त्रुटि",
"অজানা ত্রুটি",
"অজ্ঞাত আসোঁৱাহ",
"ਅਗਿਆਤ ਗੜਬੜ",
"અજ્ઞાત ભૂલ",
"ଅଜଣା ତୃଟି",
"அறியப்படாத பிழை",
"తెలియని ఎర్రర్",
"ಅಪರಿಚಿತ ದೋಷ",
"അജ്ഞാതമായ പിശക്",
"නොදන්නා දෝෂය",
"ข้อผิดพลาดที่ไม่รู้จัก",
"ຄວາມຜິດພາດທີ່ບໍ່ຮູ້ຈັກ",
"မသိရ အမှား",
"កំហុសឆ្គងមិនស្គាល់",
"不明なエラー",
"未知のエラー",
"未知的錯誤",
"未知错误",
):
if error.startswith(msg):
return True
return False
if not os.environ.get("DEBUG"):
def excepthook(etype, val, tb) -> None: # type: ignore
sys.stderr.write("%s\n" % ("".join(traceback.format_exception(etype, val, tb))))
sys.excepthook = excepthook
# so we can be non-modal/non-blocking, without Python deallocating the message
# box ahead of time
_mbox: QMessageBox | None = None
class ErrorHandler(QObject):
"Catch stderr and write into buffer."
ivl = 100
fatal_error_encountered = False
errorTimer = pyqtSignal()
def __init__(self, mw: AnkiQt) -> None:
QObject.__init__(self, mw)
self.mw = mw
self.timer: Optional[QTimer] = None
qconnect(self.errorTimer, self._setTimer)
self.pool = ""
self._oldstderr = sys.stderr
sys.stderr = cast(TextIO, self)
def unload(self) -> None:
sys.stderr = self._oldstderr
sys.excepthook = None
def write(self, data: str) -> None:
# dump to stdout
sys.stdout.write(data)
# save in buffer
self.pool += data
# and update timer
self.setTimer()
def setTimer(self) -> None:
# we can't create a timer from a different thread, so we post a
# message to the object on the main thread
self.errorTimer.emit() # type: ignore
def _setTimer(self) -> None:
if not self.timer:
self.timer = QTimer(self.mw)
qconnect(self.timer.timeout, self.onTimeout)
self.timer.setInterval(self.ivl)
self.timer.setSingleShot(True)
self.timer.start()
def tempFolderMsg(self) -> str:
return tr.qt_misc_unable_to_access_anki_media_folder()
def onTimeout(self) -> None:
if self.fatal_error_encountered:
# suppress follow-up errors caused by the poisoned lock
return
error = self.pool
self.pool = ""
self.mw.progress.clear()
if "AbortSchemaModification" in error:
return
if "DeprecationWarning" in error:
return
if "10013" in error:
showWarning(tr.qt_misc_your_firewall_or_antivirus_program_is())
return
if "invalidTempFolder" in error:
showWarning(self.tempFolderMsg())
return
if "Beautiful Soup is not an HTTP client" in error:
return
if "database or disk is full" in error or "Errno 28" in error:
showWarning(tr.qt_misc_your_computers_storage_may_be_full())
return
if "disk I/O error" in error:
showWarning(markdown(tr.errors_accessing_db()))
return
if is_chromium_cert_error(error):
return
debug_text = supportText() + "\n" + error
if "PanicException" in error:
self.fatal_error_encountered = True
# ensure no collection-related timers like backup fire
self.mw.col = None
user_text = "A fatal error occurred, and Anki must close. Please report this message on the forums."
else:
user_text = tr.errors_standard_popup2()
if self.mw.addonManager.dirty:
user_text += "\n\n" + self._addonText(error)
debug_text += addon_debug_info()
def show_troubleshooting():
openHelp(HelpPage.TROUBLESHOOTING)
def copy_debug_info():
QApplication.clipboard().setText(debug_text)
tooltip(tr.errors_copied_to_clipboard(), parent=_mbox)
global _mbox
_mbox = QMessageBox()
_mbox.setWindowTitle("Anki")
_mbox.setText(user_text)
_mbox.setIcon(QMessageBox.Icon.Warning)
_mbox.setTextFormat(Qt.TextFormat.PlainText)
troubleshooting = _mbox.addButton(
tr.errors_troubleshooting_button(), QMessageBox.ButtonRole.ActionRole
)
debug_info = _mbox.addButton(
tr.errors_copy_debug_info_button(), QMessageBox.ButtonRole.ActionRole
)
cancel = _mbox.addButton(QMessageBox.StandardButton.Cancel)
cancel.setText(tr.actions_close())
troubleshooting.disconnect()
troubleshooting.clicked.connect(show_troubleshooting)
debug_info.disconnect()
debug_info.clicked.connect(copy_debug_info)
if self.fatal_error_encountered:
_mbox.exec()
sys.exit(1)
else:
_mbox.show()
def _addonText(self, error: str) -> str:
matches = re.findall(r"addons21(/|\\)(.*?)(/|\\)", error)
if not matches:
return tr.errors_may_be_addon()
# reverse to list most likely suspect first, dict to deduplicate:
addons = [
aqt.mw.addonManager.addonName(i[1])
for i in dict.fromkeys(reversed(matches))
]
addons_str = ", ".join(addons)
return tr.addons_possibly_involved(addons=addons_str)
def addon_fmt(addmgr: AddonManager, addon: AddonMeta) -> str:
if addon.installed_at:
installed = time.strftime("%Y-%m-%dT%H:%M", time.localtime(addon.installed_at))
else:
installed = "0"
if addon.provided_name:
name = addon.provided_name
else:
name = "''"
user = addmgr.getConfig(addon.dir_name)
default = addmgr.addonConfigDefaults(addon.dir_name)
if user == default:
modified = "''"
else:
modified = "mod"
return (
f"{name} ['{addon.dir_name}', {installed}, '{addon.human_version}', {modified}]"
)
def addon_debug_info() -> str:
from aqt import mw
addmgr = mw.addonManager
active = []
activeids = []
inactive = []
for addon in addmgr.all_addon_meta():
if addon.enabled:
active.append(addon_fmt(addmgr, addon))
if addon.ankiweb_id():
activeids.append(addon.dir_name)
else:
inactive.append(addon_fmt(addmgr, addon))
newline = "\n"
info = f"""\
===Add-ons (active)===
(add-on provided name [Add-on folder, installed at, version, is config changed])
{newline.join(sorted(active))}
===IDs of active AnkiWeb add-ons===
{" ".join(activeids)}
===Add-ons (inactive)===
(add-on provided name [Add-on folder, installed at, version, is config changed])
{newline.join(sorted(inactive))}
"""
return info