mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-30 16:21:28 +01:00
feat(error_dialog): resizable QML and QWidget error dialogs with summary, foldable traceback, copy/report actions and an error list, opt-in via BEC_ERROR_DIALOG
This commit is contained in:
9 files changed
+2671
-7
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,755 @@
|
||||
"""Shared model of the error dialog used by its QML and QWidget versions.
|
||||
|
||||
The dialog replaces the small ``QMessageBox`` that ``SafeSlot(popup_error=True)``, the global
|
||||
excepthook and :class:`~bec_widgets.utils.error_popups.ErrorPopupUtility` used to open. Both views
|
||||
render the same :meth:`ErrorDialogController.view_state` and call the same controller slots, so
|
||||
they behave identically and can be compared side by side.
|
||||
|
||||
What the model adds on top of the plain traceback text:
|
||||
|
||||
* the traceback is parsed into exceptions (including ``raise ... from`` and "during handling"
|
||||
chains) and frames, shown most recent call first;
|
||||
* frames from the Python standard library and third party packages are folded into one row, so
|
||||
the BEC frames that matter stand out, and the deepest BEC frame is shown as "where it happened"
|
||||
with a few lines of source around it;
|
||||
* errors that arrive while the dialog is open are collected in one list instead of stacking modal
|
||||
dialogs, and repeats of the same error are folded into one entry with a counter;
|
||||
* a plain-text report with the environment (versions, platform) can be copied or turned into a
|
||||
pre-filled issue.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import linecache
|
||||
import platform
|
||||
import re
|
||||
import sysconfig
|
||||
import textwrap
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from importlib import metadata
|
||||
from pathlib import Path, PurePath
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from qtpy.QtCore import QObject, Qt, QUrl, Signal
|
||||
from qtpy.QtGui import QDesktopServices, QGuiApplication
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
#: Where "Report issue" opens a pre-filled issue. Decided by the maintainers, so kept in one place.
|
||||
ISSUE_URL = "https://github.com/bec-project/bec_widgets/issues/new"
|
||||
#: Packages whose frames count as BEC code even when they are installed in site-packages.
|
||||
BEC_PACKAGE_PREFIXES = ("bec", "ophyd_devices", "pytest_bec")
|
||||
#: Frames of the error plumbing itself, which never explain an error.
|
||||
PLUMBING_FRAMES = (("bec_widgets/utils/error_popups.py", "wrapper"),)
|
||||
MAX_ERRORS = 50
|
||||
CONTEXT_LINES = 2
|
||||
#: GitHub rejects very long URLs; the report body is cut to this many characters.
|
||||
MAX_ISSUE_BODY = 6000
|
||||
|
||||
_FILE_RE = re.compile(r'^\s*File "(?P<file>[^"]+)", line (?P<line>\d+)(?:, in (?P<func>.+))?$')
|
||||
_CARET_RE = re.compile(r"^\s*[\^~\-]+\s*$")
|
||||
_CHAIN_MARKERS = {
|
||||
"The above exception was the direct cause of the following exception:": "Caused by",
|
||||
"During handling of the above exception, another exception occurred:": "While handling",
|
||||
}
|
||||
_STDLIB_DIRS = tuple(
|
||||
str(PurePath(p))
|
||||
for p in dict.fromkeys(
|
||||
[sysconfig.get_paths().get("stdlib"), sysconfig.get_paths().get("platstdlib")]
|
||||
)
|
||||
if p
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TraceFrame:
|
||||
"""One ``File "...", line N, in func`` entry of a traceback."""
|
||||
|
||||
file: str
|
||||
line: int
|
||||
func: str
|
||||
code: str = ""
|
||||
|
||||
@property
|
||||
def is_bec(self) -> bool:
|
||||
"""True for frames in BEC packages or user scripts, False for stdlib and third party."""
|
||||
return classify_path(self.file) == "bec"
|
||||
|
||||
@property
|
||||
def is_plumbing(self) -> bool:
|
||||
"""True for the SafeSlot wrapper and similar frames that only forward the error."""
|
||||
posix = self.file.replace("\\", "/")
|
||||
return any(posix.endswith(path) and self.func == func for path, func in PLUMBING_FRAMES)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TraceException:
|
||||
"""One exception of a (possibly chained) traceback with the frames that led to it."""
|
||||
|
||||
type: str
|
||||
message: str
|
||||
frames: list[TraceFrame] = field(default_factory=list)
|
||||
#: How this exception relates to the one after it: "Caused by", "While handling" or "".
|
||||
relation: str = ""
|
||||
|
||||
@property
|
||||
def short_type(self) -> str:
|
||||
"""Exception class name without its module, e.g. ``ValueError``."""
|
||||
return self.type.rsplit(".", 1)[-1]
|
||||
|
||||
|
||||
def classify_path(path: str) -> str:
|
||||
"""Return ``"bec"`` for BEC and user code, ``"library"`` for stdlib and third party code."""
|
||||
posix = path.replace("\\", "/")
|
||||
if posix.startswith("<") and posix.endswith(">"):
|
||||
# <frozen runpy>, <string> and friends
|
||||
return "library" if posix.startswith("<frozen") else "bec"
|
||||
for marker in ("/site-packages/", "/dist-packages/"):
|
||||
if marker in posix:
|
||||
package = posix.split(marker, 1)[1].split("/", 1)[0]
|
||||
return "bec" if package.startswith(BEC_PACKAGE_PREFIXES) else "library"
|
||||
if any(posix.startswith(stdlib.replace("\\", "/") + "/") for stdlib in _STDLIB_DIRS):
|
||||
return "library"
|
||||
if re.search(r"/lib/python3\.\d+/", posix):
|
||||
return "library"
|
||||
return "bec"
|
||||
|
||||
|
||||
def short_path(path: str) -> str:
|
||||
"""Shorten ``path`` to the part a developer recognises, e.g. ``bec_widgets/utils/x.py``."""
|
||||
posix = path.replace("\\", "/")
|
||||
for marker in ("/site-packages/", "/dist-packages/"):
|
||||
if marker in posix:
|
||||
return posix.split(marker, 1)[1]
|
||||
match = re.search(r"/lib/python3\.\d+/(.+)$", posix)
|
||||
if match:
|
||||
return match.group(1)
|
||||
parts = PurePath(posix).parts
|
||||
top = _top_package_index(posix)
|
||||
if top is not None:
|
||||
return "/".join(parts[top:])
|
||||
for index, part in enumerate(parts[:-1]):
|
||||
if part.startswith(BEC_PACKAGE_PREFIXES):
|
||||
# a checkout is often named like its package: repo/bec_widgets/bec_widgets/...
|
||||
while index + 2 < len(parts) and parts[index + 1] == part:
|
||||
index += 1
|
||||
return "/".join(parts[index:])
|
||||
return "/".join(parts[-3:]) if len(parts) > 3 else posix
|
||||
|
||||
|
||||
def _top_package_index(posix: str) -> int | None:
|
||||
"""Index of the outermost directory of ``posix`` that is still a Python package."""
|
||||
path = Path(posix)
|
||||
if not path.is_absolute() or not path.is_file():
|
||||
return None
|
||||
directory = path.parent
|
||||
top = None
|
||||
while (directory / "__init__.py").is_file() and directory != directory.parent:
|
||||
top = directory
|
||||
directory = directory.parent
|
||||
if top is None:
|
||||
return None
|
||||
return len(top.parts) - 1
|
||||
|
||||
|
||||
def parse_traceback(text: str) -> list[TraceException]: # pylint: disable=too-many-branches
|
||||
"""Parse a formatted Python traceback into its chain of exceptions, oldest first.
|
||||
|
||||
Args:
|
||||
text(str): Output of :func:`traceback.format_exception` or any similar text.
|
||||
|
||||
Returns:
|
||||
list[TraceException]: The exceptions in the order Python prints them. The last one is the
|
||||
exception that was actually raised. Text that is not a traceback yields one exception
|
||||
holding the text as message.
|
||||
"""
|
||||
exceptions: list[TraceException] = []
|
||||
frames: list[TraceFrame] = []
|
||||
relation = ""
|
||||
message_lines: list[str] | None = None
|
||||
exc_type = ""
|
||||
|
||||
def finish():
|
||||
nonlocal frames, message_lines, exc_type, relation
|
||||
if message_lines is not None:
|
||||
message = "\n".join(message_lines).strip()
|
||||
exceptions.append(TraceException(exc_type, message, frames, ""))
|
||||
if exceptions[:-1]:
|
||||
exceptions[-2].relation = relation
|
||||
relation = ""
|
||||
frames, message_lines, exc_type = [], None, ""
|
||||
|
||||
for raw in (text or "").splitlines():
|
||||
line = raw.rstrip()
|
||||
stripped = line.strip()
|
||||
if stripped in _CHAIN_MARKERS:
|
||||
finish()
|
||||
relation = _CHAIN_MARKERS[stripped]
|
||||
continue
|
||||
if stripped.startswith("Traceback (most recent call last)"):
|
||||
if message_lines is not None:
|
||||
finish()
|
||||
continue
|
||||
match = _FILE_RE.match(line)
|
||||
if match and message_lines is None:
|
||||
frames.append(
|
||||
TraceFrame(
|
||||
file=match.group("file"),
|
||||
line=int(match.group("line")),
|
||||
func=(match.group("func") or "").strip(),
|
||||
)
|
||||
)
|
||||
continue
|
||||
if message_lines is None and frames and line.startswith(" "):
|
||||
if _CARET_RE.match(line) or frames[-1].code:
|
||||
continue
|
||||
frames[-1].code = stripped
|
||||
continue
|
||||
if message_lines is None:
|
||||
if not stripped:
|
||||
continue
|
||||
head, sep, tail = stripped.partition(":")
|
||||
if sep and head and " " not in head:
|
||||
exc_type, message_lines = head, [tail.strip()]
|
||||
elif re.fullmatch(r"[\w.]+", stripped):
|
||||
exc_type, message_lines = stripped, []
|
||||
else:
|
||||
exc_type, message_lines = "", [stripped]
|
||||
continue
|
||||
message_lines.append(line)
|
||||
finish()
|
||||
if not exceptions:
|
||||
exceptions.append(TraceException("Error", (text or "").strip()))
|
||||
for exc in exceptions:
|
||||
if not exc.type:
|
||||
exc.type = "Error"
|
||||
return exceptions
|
||||
|
||||
|
||||
def source_context(frame: TraceFrame, around: int = CONTEXT_LINES) -> list[dict]:
|
||||
"""Return the source lines around ``frame`` as ``{"no", "text", "current"}`` rows.
|
||||
|
||||
Falls back to the code line printed in the traceback when the file cannot be read.
|
||||
"""
|
||||
rows = []
|
||||
if not frame.file.startswith("<"):
|
||||
for number in range(max(1, frame.line - around), frame.line + around + 1):
|
||||
text = linecache.getline(frame.file, number)
|
||||
if not text and number > frame.line:
|
||||
break
|
||||
rows.append({"no": number, "text": text.rstrip("\n"), "current": number == frame.line})
|
||||
if not any(row["current"] and row["text"].strip() for row in rows):
|
||||
rows = [{"no": frame.line, "text": frame.code, "current": True}] if frame.code else []
|
||||
return rows
|
||||
|
||||
|
||||
def environment_lines() -> list[str]:
|
||||
"""Versions and platform for the report."""
|
||||
|
||||
def version(dist: str) -> str:
|
||||
try:
|
||||
return metadata.version(dist)
|
||||
except metadata.PackageNotFoundError:
|
||||
return "not installed"
|
||||
|
||||
try:
|
||||
from qtpy import QT_VERSION # pylint: disable=import-outside-toplevel
|
||||
except ImportError: # pragma: no cover
|
||||
QT_VERSION = "unknown" # pylint: disable=invalid-name
|
||||
return [
|
||||
f"bec_widgets {version('bec_widgets')}, bec_lib {version('bec_lib')}, "
|
||||
f"ophyd_devices {version('ophyd_devices')}",
|
||||
f"Python {platform.python_version()}, Qt {QT_VERSION}, {platform.platform()}",
|
||||
]
|
||||
|
||||
|
||||
def absolute_time(timestamp: float) -> str:
|
||||
"""Full local timestamp."""
|
||||
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def clock_time(timestamp: float) -> str:
|
||||
"""``HH:MM:SS`` of ``timestamp``."""
|
||||
return datetime.fromtimestamp(timestamp).strftime("%H:%M:%S")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ErrorRecord: # pylint: disable=too-many-instance-attributes
|
||||
"""One error shown by the dialog; repeats of the same error raise ``count``."""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
traceback: str
|
||||
source: str = ""
|
||||
created: float = field(default_factory=time.time)
|
||||
updated: float = field(default_factory=time.time)
|
||||
count: int = 1
|
||||
seen: bool = False
|
||||
exceptions: list[TraceException] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.exceptions:
|
||||
self.exceptions = parse_traceback(self.traceback)
|
||||
|
||||
@property
|
||||
def final(self) -> TraceException:
|
||||
"""The exception that was raised last, i.e. the one the user sees first."""
|
||||
return self.exceptions[-1]
|
||||
|
||||
@property
|
||||
def signature(self) -> tuple:
|
||||
"""Identity used to fold repeats: type, message and every frame location."""
|
||||
return tuple(
|
||||
(exc.type, exc.message, tuple((f.file, f.line) for f in exc.frames))
|
||||
for exc in self.exceptions
|
||||
)
|
||||
|
||||
def focus_frame(self) -> TraceFrame | None:
|
||||
"""The deepest BEC frame of the raised exception, else its deepest frame."""
|
||||
for exc in reversed(self.exceptions):
|
||||
candidates = [f for f in exc.frames if not f.is_plumbing]
|
||||
for frame in reversed(candidates):
|
||||
if frame.is_bec:
|
||||
return frame
|
||||
frames = self.final.frames
|
||||
return frames[-1] if frames else None
|
||||
|
||||
def report_text(self) -> str:
|
||||
"""Plain-text report for the clipboard, a logbook or an issue."""
|
||||
final = self.final
|
||||
lines = [f"{final.type}: {final.message}".rstrip(": ")]
|
||||
lines.append(f"Kind: {self.title}")
|
||||
if self.source:
|
||||
lines.append(f"Raised in: {self.source}")
|
||||
focus = self.focus_frame()
|
||||
if focus is not None:
|
||||
lines.append(f"Location: {short_path(focus.file)}:{focus.line} in {focus.func}")
|
||||
lines.append(f"Time: {absolute_time(self.updated)}")
|
||||
if self.count > 1:
|
||||
lines.append(f"Occurrences: {self.count} (first at {absolute_time(self.created)})")
|
||||
lines.extend(environment_lines())
|
||||
lines.extend(["", self.traceback.strip()])
|
||||
return "\n".join(lines)
|
||||
|
||||
def issue_url(self) -> str:
|
||||
"""A pre-filled "new issue" URL for this error."""
|
||||
final = self.final
|
||||
title = f"{final.short_type}: {final.message.splitlines()[0] if final.message else ''}"
|
||||
body = self.report_text()
|
||||
if len(body) > MAX_ISSUE_BODY:
|
||||
body = body[: MAX_ISSUE_BODY - 40] + "\n... (cut, use Copy report for the rest)"
|
||||
body = f"**What I was doing:**\n\n\n**Error report:**\n```\n{body}\n```\n"
|
||||
return f"{ISSUE_URL}?{urlencode({'title': title[:200], 'body': body})}"
|
||||
|
||||
|
||||
class ErrorDialogController(QObject): # pylint: disable=too-many-instance-attributes
|
||||
"""State and actions of the error dialog, shared by the QML and QWidget versions.
|
||||
|
||||
Signals:
|
||||
changed: Emitted after any change; views re-read :meth:`view_state`.
|
||||
error_added(str): Emitted with the id of a new or repeated error.
|
||||
"""
|
||||
|
||||
changed = Signal()
|
||||
error_added = Signal(str)
|
||||
|
||||
def __init__(self, parent: QObject | None = None):
|
||||
super().__init__(parent)
|
||||
self._errors: list[ErrorRecord] = [] # newest first
|
||||
self._current: str | None = None
|
||||
self._ids = itertools.count(1)
|
||||
self.show_library = False
|
||||
self.mode = "frames"
|
||||
self._expanded_frames: set[str] = set()
|
||||
self._open_groups: set[str] = set()
|
||||
self.last_copied = ""
|
||||
|
||||
# ------------------------------------------------------------------ errors
|
||||
|
||||
@property
|
||||
def errors(self) -> list[ErrorRecord]:
|
||||
"""All errors, newest first."""
|
||||
return list(self._errors)
|
||||
|
||||
@property
|
||||
def current(self) -> ErrorRecord | None:
|
||||
"""The error the dialog shows."""
|
||||
return next((e for e in self._errors if e.id == self._current), None)
|
||||
|
||||
def add_error(self, title: str, traceback_text: str, source: str = "") -> str:
|
||||
"""Add an error, folding it into an identical earlier one.
|
||||
|
||||
The dialog switches to the new error unless the user is reading an older one, so a
|
||||
burst of errors does not pull the view away from what they are looking at.
|
||||
|
||||
Args:
|
||||
title(str): Kind of error, e.g. ``"Method error"`` or ``"Application error"``.
|
||||
traceback_text(str): Formatted traceback.
|
||||
source(str): Where it was raised, e.g. the slot's qualified name.
|
||||
|
||||
Returns:
|
||||
str: Id of the (possibly folded) error.
|
||||
"""
|
||||
record = ErrorRecord(f"e{next(self._ids)}", title, traceback_text, source)
|
||||
follow = self._current is None or (self._errors and self._errors[0].id == self._current)
|
||||
existing = next((e for e in self._errors if e.signature == record.signature), None)
|
||||
if existing is not None:
|
||||
existing.count += 1
|
||||
existing.updated = record.updated
|
||||
existing.seen = existing.id == self._current
|
||||
self._errors.remove(existing)
|
||||
self._errors.insert(0, existing)
|
||||
record = existing
|
||||
else:
|
||||
self._errors.insert(0, record)
|
||||
del self._errors[MAX_ERRORS:]
|
||||
if follow:
|
||||
self._select(record.id)
|
||||
self.changed.emit()
|
||||
self.error_added.emit(record.id)
|
||||
return record.id
|
||||
|
||||
def select(self, error_id: str) -> None:
|
||||
"""Show the error with ``error_id``."""
|
||||
if any(e.id == error_id for e in self._errors):
|
||||
self._select(error_id)
|
||||
self.changed.emit()
|
||||
|
||||
def step(self, delta: int) -> None:
|
||||
"""Move ``delta`` entries in the list (positive = older)."""
|
||||
if not self._errors:
|
||||
return
|
||||
ids = [e.id for e in self._errors]
|
||||
index = ids.index(self._current) if self._current in ids else 0
|
||||
self.select(ids[max(0, min(len(ids) - 1, index + delta))])
|
||||
|
||||
def dismiss(self, error_id: str | None = None) -> None:
|
||||
"""Remove one error (the current one by default) and show its neighbour."""
|
||||
error_id = error_id or self._current
|
||||
ids = [e.id for e in self._errors]
|
||||
if error_id not in ids:
|
||||
return
|
||||
index = ids.index(error_id)
|
||||
self._errors.pop(index)
|
||||
if error_id == self._current:
|
||||
self._current = None
|
||||
if self._errors:
|
||||
self._select(self._errors[min(index, len(self._errors) - 1)].id)
|
||||
self.changed.emit()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove every error."""
|
||||
self._errors.clear()
|
||||
self._current = None
|
||||
self.changed.emit()
|
||||
|
||||
def _select(self, error_id: str) -> None:
|
||||
if error_id != self._current:
|
||||
self._expanded_frames.clear()
|
||||
self._open_groups.clear()
|
||||
self._current = error_id
|
||||
record = self.current
|
||||
if record is not None:
|
||||
record.seen = True
|
||||
|
||||
# ------------------------------------------------------------------ traceback view
|
||||
|
||||
def set_show_library(self, show: bool) -> None:
|
||||
"""Show or fold the standard library and third party frames."""
|
||||
self.show_library = bool(show)
|
||||
self.changed.emit()
|
||||
|
||||
def set_mode(self, mode: str) -> None:
|
||||
"""Switch the traceback between ``"frames"`` and ``"raw"`` text."""
|
||||
if mode in ("frames", "raw"):
|
||||
self.mode = mode
|
||||
self.changed.emit()
|
||||
|
||||
def toggle_frame(self, key: str) -> None:
|
||||
"""Expand or collapse the source context of one frame."""
|
||||
self._expanded_frames.symmetric_difference_update({key})
|
||||
self.changed.emit()
|
||||
|
||||
def toggle_group(self, key: str) -> None:
|
||||
"""Unfold or fold one run of library frames."""
|
||||
self._open_groups.symmetric_difference_update({key})
|
||||
self.changed.emit()
|
||||
|
||||
# ------------------------------------------------------------------ actions
|
||||
|
||||
def copy_report(self) -> str:
|
||||
"""Copy the report of the current error to the clipboard and return it."""
|
||||
record = self.current
|
||||
if record is None:
|
||||
return ""
|
||||
self.last_copied = record.report_text()
|
||||
clipboard = QGuiApplication.clipboard()
|
||||
if clipboard is not None:
|
||||
clipboard.setText(self.last_copied)
|
||||
return self.last_copied
|
||||
|
||||
def copy_location(self) -> str:
|
||||
"""Copy ``path:line`` of the "where it happened" frame."""
|
||||
record = self.current
|
||||
frame = record.focus_frame() if record else None
|
||||
if frame is None:
|
||||
return ""
|
||||
self.last_copied = f"{frame.file}:{frame.line}"
|
||||
clipboard = QGuiApplication.clipboard()
|
||||
if clipboard is not None:
|
||||
clipboard.setText(self.last_copied)
|
||||
return self.last_copied
|
||||
|
||||
def report_issue(self) -> str:
|
||||
"""Open a pre-filled issue for the current error in the browser and return the URL."""
|
||||
record = self.current
|
||||
if record is None:
|
||||
return ""
|
||||
url = record.issue_url()
|
||||
QDesktopServices.openUrl(QUrl(url))
|
||||
return url
|
||||
|
||||
# ------------------------------------------------------------------ view state
|
||||
|
||||
def view_state(self, now: float | None = None) -> dict:
|
||||
"""Everything the views need to render, as plain Python data."""
|
||||
now = time.time() if now is None else now
|
||||
ids = [e.id for e in self._errors]
|
||||
index = ids.index(self._current) if self._current in ids else -1
|
||||
state = {
|
||||
"count": len(self._errors),
|
||||
"index": index,
|
||||
"unseen": sum(1 for e in self._errors if not e.seen),
|
||||
"show_library": self.show_library,
|
||||
"mode": self.mode,
|
||||
"errors": [self._list_row(e, now) for e in self._errors],
|
||||
"current": None,
|
||||
}
|
||||
record = self.current
|
||||
if record is not None:
|
||||
state["current"] = self._detail(record, now)
|
||||
return state
|
||||
|
||||
@staticmethod
|
||||
def _list_row(record: ErrorRecord, now: float) -> dict:
|
||||
final = record.final
|
||||
return {
|
||||
"id": record.id,
|
||||
"type": final.short_type,
|
||||
"message": final.message.splitlines()[0] if final.message else "",
|
||||
"time": clock_time(record.updated),
|
||||
"age": _age(record.updated, now),
|
||||
"count": record.count,
|
||||
"source": record.source,
|
||||
"seen": record.seen,
|
||||
}
|
||||
|
||||
def _detail(self, record: ErrorRecord, now: float) -> dict:
|
||||
final = record.final
|
||||
focus = record.focus_frame()
|
||||
location = None
|
||||
if focus is not None:
|
||||
location = {
|
||||
"file": focus.file,
|
||||
"file_short": short_path(focus.file),
|
||||
"line": focus.line,
|
||||
"func": focus.func,
|
||||
"context": source_context(focus),
|
||||
}
|
||||
library_frames = sum(
|
||||
1 for exc in record.exceptions for f in exc.frames if not f.is_bec or f.is_plumbing
|
||||
)
|
||||
headline, _, more = final.message.partition("\n")
|
||||
return {
|
||||
"id": record.id,
|
||||
"title": record.title,
|
||||
"type": final.short_type,
|
||||
"qualified_type": final.type,
|
||||
"message": final.message,
|
||||
"headline": headline.strip(),
|
||||
"message_more": textwrap.dedent(more).strip("\n"),
|
||||
"source": record.source,
|
||||
"time": absolute_time(record.updated),
|
||||
"age": _age(record.updated, now),
|
||||
"count": record.count,
|
||||
"first_time": absolute_time(record.created),
|
||||
"location": location,
|
||||
"rows": self._rows(record, focus),
|
||||
"frame_count": sum(len(exc.frames) for exc in record.exceptions),
|
||||
"library_frame_count": library_frames,
|
||||
"raw": record.traceback.strip(),
|
||||
}
|
||||
|
||||
def _rows(self, record: ErrorRecord, focus: TraceFrame | None) -> list[dict]:
|
||||
"""Flat list of section, frame and folded-group rows, most recent call first."""
|
||||
rows: list[dict] = []
|
||||
chain = list(enumerate(record.exceptions))
|
||||
for position, (exc_index, exc) in enumerate(reversed(chain)):
|
||||
if position == 0:
|
||||
label = "Raised"
|
||||
else:
|
||||
label = record.exceptions[exc_index].relation or "Earlier"
|
||||
rows.append(
|
||||
{
|
||||
"kind": "section",
|
||||
"key": f"x{exc_index}",
|
||||
"label": label,
|
||||
"type": exc.short_type,
|
||||
"message": exc.message,
|
||||
"first": position == 0,
|
||||
}
|
||||
)
|
||||
pending: list[tuple[int, TraceFrame]] = []
|
||||
for frame_index in range(len(exc.frames) - 1, -1, -1):
|
||||
frame = exc.frames[frame_index]
|
||||
if frame.is_bec and not frame.is_plumbing:
|
||||
self._flush_group(rows, exc_index, pending, focus)
|
||||
rows.append(self._frame_row(exc_index, frame_index, frame, focus))
|
||||
else:
|
||||
pending.append((frame_index, frame))
|
||||
self._flush_group(rows, exc_index, pending, focus)
|
||||
return rows
|
||||
|
||||
def _flush_group(
|
||||
self,
|
||||
rows: list[dict],
|
||||
exc_index: int,
|
||||
pending: list[tuple[int, TraceFrame]],
|
||||
focus: TraceFrame | None,
|
||||
) -> None:
|
||||
"""Append a run of library frames as one foldable group row."""
|
||||
if not pending:
|
||||
return
|
||||
key = f"g{exc_index}.{pending[0][0]}"
|
||||
is_open = self.show_library or key in self._open_groups
|
||||
if not self.show_library:
|
||||
rows.append(
|
||||
{
|
||||
"kind": "group",
|
||||
"key": key,
|
||||
"count": len(pending),
|
||||
"open": is_open,
|
||||
"label": _group_label(pending),
|
||||
}
|
||||
)
|
||||
if is_open:
|
||||
rows.extend(self._frame_row(exc_index, i, f, focus) for i, f in pending)
|
||||
pending.clear()
|
||||
|
||||
def _frame_row(
|
||||
self, exc_index: int, frame_index: int, frame: TraceFrame, focus: TraceFrame | None
|
||||
) -> dict:
|
||||
key = f"f{exc_index}.{frame_index}"
|
||||
expanded = key in self._expanded_frames
|
||||
return {
|
||||
"kind": "frame",
|
||||
"key": key,
|
||||
"func": frame.func or "<module>",
|
||||
"file": frame.file,
|
||||
"file_short": short_path(frame.file),
|
||||
"line": frame.line,
|
||||
"code": frame.code,
|
||||
"bec": frame.is_bec and not frame.is_plumbing,
|
||||
"focus": frame is focus,
|
||||
"expanded": expanded,
|
||||
"context": source_context(frame) if expanded else [],
|
||||
}
|
||||
|
||||
|
||||
def _group_label(pending: list[tuple[int, TraceFrame]]) -> str:
|
||||
if all(frame.is_plumbing for _, frame in pending):
|
||||
return "SafeSlot error handling"
|
||||
packages = []
|
||||
for _, frame in pending:
|
||||
name = short_path(frame.file).split("/", 1)[0]
|
||||
name = name.removesuffix(".py")
|
||||
if name not in packages:
|
||||
packages.append(name)
|
||||
noun = "frame" if len(pending) == 1 else "frames"
|
||||
shown = ", ".join(packages[:3]) + (" …" if len(packages) > 3 else "")
|
||||
return f"{len(pending)} library {noun} · {shown}"
|
||||
|
||||
|
||||
def _age(timestamp: float, now: float) -> str:
|
||||
seconds = max(0, int(now - timestamp))
|
||||
if seconds < 10:
|
||||
return "just now"
|
||||
if seconds < 60:
|
||||
return f"{seconds} s ago"
|
||||
if seconds < 3600:
|
||||
return f"{seconds // 60} min ago"
|
||||
return clock_time(timestamp)
|
||||
|
||||
|
||||
class ErrorDialogWindowMixin:
|
||||
"""Window behaviour shared by both dialog versions: size, theme and presenting errors.
|
||||
|
||||
The dialog is not modal: errors that arrive while it is open are added to its list instead
|
||||
of stacking further dialogs, and the rest of the application stays usable. Closing it
|
||||
clears the list.
|
||||
"""
|
||||
|
||||
MIN_SIZE = (600, 420)
|
||||
DEFAULT_SIZE = (960, 680)
|
||||
|
||||
controller: ErrorDialogController
|
||||
|
||||
def init_window(self) -> None:
|
||||
"""Set flags, size and theme hooks. Call at the end of ``__init__``."""
|
||||
# pylint: disable=no-member
|
||||
self.setWindowTitle("Error")
|
||||
self.setWindowFlag(Qt.WindowType.Window, True)
|
||||
self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False)
|
||||
self.setModal(False)
|
||||
self.setSizeGripEnabled(True)
|
||||
self.setMinimumSize(*self.MIN_SIZE)
|
||||
width, height = self.DEFAULT_SIZE
|
||||
screen = self.screen() or QGuiApplication.primaryScreen()
|
||||
if screen is not None:
|
||||
available = screen.availableGeometry()
|
||||
width = min(width, int(available.width() * 0.85))
|
||||
height = min(height, int(available.height() * 0.85))
|
||||
self.resize(max(width, self.MIN_SIZE[0]), max(height, self.MIN_SIZE[1]))
|
||||
app = QApplication.instance()
|
||||
theme = getattr(app, "theme", None)
|
||||
if theme is not None:
|
||||
theme.theme_changed.connect(self._on_theme_changed)
|
||||
self.apply_theme()
|
||||
|
||||
def _on_theme_changed(self, *_args) -> None:
|
||||
self.apply_theme()
|
||||
|
||||
def apply_theme(self) -> None: # pragma: no cover - implemented by the views
|
||||
"""Restyle the view for the current theme."""
|
||||
raise NotImplementedError
|
||||
|
||||
def show_error(self, title: str, traceback_text: str, source: str = "") -> str:
|
||||
"""Add an error and bring the dialog to the front.
|
||||
|
||||
Args:
|
||||
title(str): Kind of error, e.g. ``"Method error"``.
|
||||
traceback_text(str): Formatted traceback.
|
||||
source(str): Where the error was raised, e.g. a slot's qualified name.
|
||||
|
||||
Returns:
|
||||
str: Id of the error in the list.
|
||||
"""
|
||||
error_id = self.controller.add_error(title, traceback_text, source)
|
||||
self.present()
|
||||
return error_id
|
||||
|
||||
def present(self) -> None:
|
||||
"""Show the dialog, or raise it if it is already open."""
|
||||
# pylint: disable=no-member
|
||||
if self.isMinimized():
|
||||
self.showNormal()
|
||||
self.show()
|
||||
self.raise_()
|
||||
self.activateWindow()
|
||||
|
||||
def closeEvent(self, event): # pylint: disable=invalid-name
|
||||
"""Closing the dialog clears its list of errors."""
|
||||
self.controller.clear()
|
||||
super().closeEvent(event) # pylint: disable=no-member
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Interactive demo of the error dialog: raises realistic errors through ``SafeSlot``.
|
||||
|
||||
Run with ``python -m bec_widgets.utils.error_dialog.error_dialog_demo [qwidget|qml|legacy]``.
|
||||
The errors go through the real ``SafeSlot(popup_error=True)`` path, so the dialog shows exactly
|
||||
what a user would see.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from qtpy.QtWidgets import QApplication, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
|
||||
class LimitError(ValueError):
|
||||
"""Raised when a move target is outside the soft limits (demo stand-in)."""
|
||||
|
||||
|
||||
class GridScanArgs(BaseModel):
|
||||
"""Arguments of a grid scan (demo stand-in for the scan control form)."""
|
||||
|
||||
motor_1: str
|
||||
start_1: float
|
||||
stop_1: float
|
||||
steps_1: int = Field(gt=1)
|
||||
motor_2: str
|
||||
steps_2: int = Field(gt=1)
|
||||
exp_time: float = Field(gt=0)
|
||||
|
||||
|
||||
def check_limits(device: str, target: float, limits: tuple[float, float]) -> None:
|
||||
"""Raise :class:`LimitError` if ``target`` is outside ``limits``."""
|
||||
low, high = limits
|
||||
if not low <= target <= high:
|
||||
raise LimitError(
|
||||
f"Target {target} is outside the soft limits [{low:g}, {high:g}] of {device}."
|
||||
)
|
||||
|
||||
|
||||
def load_profile(text: str) -> dict:
|
||||
"""Parse a saved dock profile."""
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
class ErrorDemo(QWidget):
|
||||
"""Buttons that raise the kinds of errors BEC widgets report."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._curves = {"bpm3i": object()}
|
||||
layout = QVBoxLayout(self)
|
||||
for label, slot in (
|
||||
("Move outside limits", self.on_setpoint_change),
|
||||
("Invalid scan arguments", self.on_scan_clicked),
|
||||
("Missing curve (chained)", self.on_curve_update),
|
||||
("Broken profile ×7", self.burst),
|
||||
("Toggle theme", self.toggle_theme),
|
||||
):
|
||||
button = QPushButton(label, self)
|
||||
button.clicked.connect(slot)
|
||||
layout.addWidget(button)
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def on_setpoint_change(self):
|
||||
"""Move samx to an out-of-range setpoint."""
|
||||
self.move_device("samx", 12.5)
|
||||
|
||||
def move_device(self, name: str, target: float) -> None:
|
||||
"""Check the limits, then move."""
|
||||
check_limits(name, target, (-10, 10))
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def on_scan_clicked(self):
|
||||
"""Validate the grid scan form."""
|
||||
args = self.collect_scan_args()
|
||||
return GridScanArgs(**args)
|
||||
|
||||
def collect_scan_args(self) -> dict:
|
||||
"""Values as typed in the form."""
|
||||
return {
|
||||
"motor_1": "samx",
|
||||
"start_1": -5,
|
||||
"stop_1": "5 mm",
|
||||
"steps_1": 1,
|
||||
"motor_2": "samy",
|
||||
"steps_2": 21,
|
||||
"exp_time": 0,
|
||||
}
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def on_curve_update(self):
|
||||
"""Update a curve that was removed from the plot."""
|
||||
try:
|
||||
curve = self._curves["bpm4i"]
|
||||
except KeyError:
|
||||
# no "from": shows the "while handling" chain in the dialog
|
||||
message = "Curve 'bpm4i' is not on this waveform; was it removed?"
|
||||
raise RuntimeError(message) # pylint: disable=raise-missing-from
|
||||
return curve
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def on_profile_loaded(self):
|
||||
"""Restore a dock profile from a truncated file."""
|
||||
return load_profile('{"docks": [{"name": "waveform", "geometry": [0, 0, 640')
|
||||
|
||||
def burst(self):
|
||||
"""Seven identical errors in a row, as a broken timer would produce."""
|
||||
for _ in range(7):
|
||||
self.on_profile_loaded()
|
||||
|
||||
def toggle_theme(self):
|
||||
"""Switch between light and dark."""
|
||||
theme = QApplication.instance().theme
|
||||
apply_theme("light" if theme.theme == "dark" else "dark")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
ui = sys.argv[1] if len(sys.argv) > 1 else "qwidget"
|
||||
if ui != "legacy":
|
||||
os.environ["BEC_ERROR_DIALOG"] = ui
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
demo = ErrorDemo()
|
||||
demo.setWindowTitle(f"Error dialog demo ({ui})")
|
||||
demo.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1,139 @@
|
||||
"""QML version of the error dialog.
|
||||
|
||||
A :class:`~qtpy.QtWidgets.QDialog` hosts a :class:`~qtpy.QtQuickWidgets.QQuickWidget` that renders
|
||||
``qml/ErrorDialog.qml``. The QWidget twin lives in
|
||||
:mod:`bec_widgets.utils.error_dialog.error_dialog_qwidget`; both share
|
||||
:class:`ErrorDialogController`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from qtpy.QtCore import Property, QObject, QSize, Signal, Slot
|
||||
from qtpy.QtWidgets import QDialog, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.error_dialog.error_dialog_common import (
|
||||
ErrorDialogController,
|
||||
ErrorDialogWindowMixin,
|
||||
)
|
||||
from bec_widgets.utils.quick.host import (
|
||||
ThemeTokens,
|
||||
create_quick_widget,
|
||||
quick_engine,
|
||||
release_quick_widget,
|
||||
)
|
||||
|
||||
QML_FILE = Path(__file__).parent / "qml" / "ErrorDialog.qml"
|
||||
|
||||
|
||||
class ErrorDialogBackend(QObject):
|
||||
"""Expose the controller state and actions to QML."""
|
||||
|
||||
stateChanged = Signal()
|
||||
closeRequested = Signal()
|
||||
|
||||
def __init__(self, controller: ErrorDialogController, parent: QObject | None = None):
|
||||
super().__init__(parent)
|
||||
self.controller = controller
|
||||
self._state = controller.view_state()
|
||||
controller.changed.connect(self._refresh)
|
||||
|
||||
def _refresh(self) -> None:
|
||||
self._state = self.controller.view_state()
|
||||
self.stateChanged.emit()
|
||||
|
||||
def _get_state(self) -> dict:
|
||||
return self._state
|
||||
|
||||
state = Property("QVariant", _get_state, notify=stateChanged)
|
||||
|
||||
# pylint: disable=missing-function-docstring
|
||||
@Slot(str)
|
||||
def select(self, error_id: str) -> None:
|
||||
self.controller.select(error_id)
|
||||
|
||||
@Slot(int)
|
||||
def step(self, delta: int) -> None:
|
||||
self.controller.step(delta)
|
||||
|
||||
@Slot()
|
||||
def dismiss(self) -> None:
|
||||
self.controller.dismiss()
|
||||
|
||||
@Slot()
|
||||
def clear(self) -> None:
|
||||
self.controller.clear()
|
||||
|
||||
@Slot(bool)
|
||||
def setShowLibrary(self, show: bool) -> None: # pylint: disable=invalid-name
|
||||
self.controller.set_show_library(show)
|
||||
|
||||
@Slot(str)
|
||||
def setMode(self, mode: str) -> None: # pylint: disable=invalid-name
|
||||
self.controller.set_mode(mode)
|
||||
|
||||
@Slot(str)
|
||||
def toggleFrame(self, key: str) -> None: # pylint: disable=invalid-name
|
||||
self.controller.toggle_frame(key)
|
||||
|
||||
@Slot(str)
|
||||
def toggleGroup(self, key: str) -> None: # pylint: disable=invalid-name
|
||||
self.controller.toggle_group(key)
|
||||
|
||||
@Slot(result=bool)
|
||||
def copyReport(self) -> bool: # pylint: disable=invalid-name
|
||||
return bool(self.controller.copy_report())
|
||||
|
||||
@Slot(result=bool)
|
||||
def copyLocation(self) -> bool: # pylint: disable=invalid-name
|
||||
return bool(self.controller.copy_location())
|
||||
|
||||
@Slot()
|
||||
def reportIssue(self) -> None: # pylint: disable=invalid-name
|
||||
self.controller.report_issue()
|
||||
|
||||
@Slot()
|
||||
def close(self) -> None:
|
||||
self.closeRequested.emit()
|
||||
|
||||
|
||||
class ErrorDialogQML(ErrorDialogWindowMixin, QDialog):
|
||||
"""Resizable error dialog with summary, navigable traceback and an error list (QML)."""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None, controller: ErrorDialogController = None):
|
||||
super().__init__(parent)
|
||||
self.controller = controller or ErrorDialogController(self)
|
||||
self.backend = ErrorDialogBackend(self.controller, self)
|
||||
self.backend.closeRequested.connect(self.close)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
quick_engine()
|
||||
self.view = create_quick_widget(self, QML_FILE, {"backend": self.backend})
|
||||
layout.addWidget(self.view)
|
||||
self.init_window()
|
||||
self.controller.changed.connect(self._update_title)
|
||||
|
||||
def _update_title(self) -> None:
|
||||
current = self.backend.state.get("current")
|
||||
if current:
|
||||
self.setWindowTitle(f"{current['type']} · {current['title']}")
|
||||
|
||||
def apply_theme(self) -> None:
|
||||
"""Match the window background to the theme; QML follows the shared theme bridge."""
|
||||
tokens = ThemeTokens()
|
||||
self.setStyleSheet(f"ErrorDialogQML {{ background: {tokens.bg.name()}; }}")
|
||||
if getattr(self, "view", None) is not None:
|
||||
self.view.setClearColor(tokens.bg)
|
||||
|
||||
def grab_image(self):
|
||||
"""Render the QML scene to an image, e.g. for screenshots."""
|
||||
return self.view.grabFramebuffer()
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Unload the scene before the backend goes away."""
|
||||
release_quick_widget(self.view)
|
||||
|
||||
def sizeHint(self) -> QSize: # pylint: disable=invalid-name
|
||||
"""Default size of the dialog."""
|
||||
return QSize(960, 680)
|
||||
@@ -0,0 +1,661 @@
|
||||
"""QWidget version of the error dialog.
|
||||
|
||||
The QML twin lives in :mod:`bec_widgets.utils.error_dialog.error_dialog_qml`; both render
|
||||
:meth:`ErrorDialogController.view_state` and share the controller, so they look and behave the same.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from html import escape
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QSize, Qt, QTimer, Signal
|
||||
from qtpy.QtGui import QFont, QFontDatabase, QKeySequence, QShortcut
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPlainTextEdit,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QStackedWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.error_dialog.error_dialog_common import (
|
||||
ErrorDialogController,
|
||||
ErrorDialogWindowMixin,
|
||||
)
|
||||
from bec_widgets.utils.quick.host import ThemeTokens
|
||||
from bec_widgets.utils.ux_kit import IconButton, TextButton, ToggleSwitch, refresh_kit_theme, rgba
|
||||
|
||||
|
||||
def _mono_font(pixel_size: int = 12) -> QFont:
|
||||
font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
|
||||
font.setPixelSize(pixel_size)
|
||||
return font
|
||||
|
||||
|
||||
def _clear_layout(layout) -> None:
|
||||
while layout.count():
|
||||
item = layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
widget.hide()
|
||||
widget.deleteLater()
|
||||
elif item.layout() is not None:
|
||||
_clear_layout(item.layout())
|
||||
|
||||
|
||||
class _ElidedLabel(QLabel):
|
||||
"""Plain-text label that elides in the middle instead of growing the layout."""
|
||||
|
||||
def __init__(self, text: str = "", parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self._full = ""
|
||||
self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
||||
self.setMinimumWidth(40)
|
||||
self.set_full_text(text)
|
||||
|
||||
def set_full_text(self, text: str) -> None:
|
||||
"""Set the text; the tooltip always shows it in full."""
|
||||
self._full = text
|
||||
self.setToolTip(text)
|
||||
self._elide()
|
||||
|
||||
def resizeEvent(self, event): # pylint: disable=invalid-name
|
||||
"""Re-elide for the new width."""
|
||||
super().resizeEvent(event)
|
||||
self._elide()
|
||||
|
||||
def _elide(self) -> None:
|
||||
self.setText(
|
||||
self.fontMetrics().elidedText(self._full, Qt.TextElideMode.ElideMiddle, self.width())
|
||||
)
|
||||
|
||||
|
||||
class _Clickable(QFrame): # pylint: disable=too-few-public-methods
|
||||
"""Frame that emits ``clicked`` and shows a hover background."""
|
||||
|
||||
clicked = Signal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_Hover, True)
|
||||
|
||||
def mouseReleaseEvent(self, event): # pylint: disable=invalid-name
|
||||
"""Emit ``clicked`` on a left click released inside the frame."""
|
||||
if event.button() == Qt.MouseButton.LeftButton and self.rect().contains(event.pos()):
|
||||
self.clicked.emit()
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
|
||||
def _code_block(context: list[dict], tokens: ThemeTokens, parent: QWidget) -> QLabel:
|
||||
"""Source lines with line numbers; the current line is tinted with the error colour."""
|
||||
width = max((len(str(row["no"])) for row in context), default=1)
|
||||
lines = []
|
||||
for row in context:
|
||||
number = str(row["no"]).rjust(width).replace(" ", " ")
|
||||
text = escape(row["text"]).replace(" ", " ") or " "
|
||||
if row["current"]:
|
||||
tint = tokens.soft(tokens.danger, 0.2).name()
|
||||
lines.append(
|
||||
f"<tr><td width='1%' style='color:{tokens.danger.name()}; background:{tint};"
|
||||
f" padding:0 10px 0 4px'>{number}</td>"
|
||||
f"<td style='color:{tokens.fg.name()}; background:{tint}'>{text}</td></tr>"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f"<tr><td width='1%' style='color:{tokens.fg_subtle.name()};"
|
||||
f" padding:0 10px 0 4px'>{number}</td>"
|
||||
f"<td style='color:{tokens.fg_muted.name()}'>{text}</td></tr>"
|
||||
)
|
||||
label = QLabel(
|
||||
f"<table cellspacing=0 cellpadding=1 width='100%'>{''.join(lines)}</table>", parent
|
||||
)
|
||||
label.setFont(_mono_font())
|
||||
label.setTextFormat(Qt.TextFormat.RichText)
|
||||
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
label.setObjectName("codeBlock")
|
||||
label.setStyleSheet(
|
||||
f"QLabel#codeBlock {{ background: {tokens.field.name()}; border: 1px solid"
|
||||
f" {tokens.border.name()}; border-radius: 6px; padding: 6px 8px; }}"
|
||||
)
|
||||
return label
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes, too-many-locals, too-many-statements
|
||||
class ErrorDialogWidget(ErrorDialogWindowMixin, QDialog):
|
||||
"""Resizable error dialog with summary, navigable traceback and an error list (QWidget)."""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None, controller: ErrorDialogController = None):
|
||||
super().__init__(parent)
|
||||
self.controller = controller or ErrorDialogController(self)
|
||||
self._tokens = ThemeTokens()
|
||||
self._state: dict = {}
|
||||
self._rendered_error: str | None = None
|
||||
self._build()
|
||||
self.init_window()
|
||||
self.controller.changed.connect(self.sync)
|
||||
self.sync()
|
||||
|
||||
# ------------------------------------------------------------------ construction
|
||||
|
||||
def _build(self) -> None:
|
||||
root = QHBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(0)
|
||||
|
||||
# error list, only shown with more than one error
|
||||
self.sidebar = QFrame(self)
|
||||
self.sidebar.setObjectName("sidebar")
|
||||
self.sidebar.setFixedWidth(264)
|
||||
side = QVBoxLayout(self.sidebar)
|
||||
side.setContentsMargins(12, 14, 12, 12)
|
||||
side.setSpacing(8)
|
||||
side_head = QHBoxLayout()
|
||||
self.list_title = QLabel(self.sidebar)
|
||||
self.list_title.setObjectName("sectionTitle")
|
||||
side_head.addWidget(self.list_title, 1)
|
||||
self.clear_button = TextButton("Clear all", "ghost", "delete_sweep", self.sidebar)
|
||||
self.clear_button.setToolTip("Remove every error from the list")
|
||||
self.clear_button.clicked.connect(self.controller.clear)
|
||||
side_head.addWidget(self.clear_button)
|
||||
side.addLayout(side_head)
|
||||
self.list_scroll = QScrollArea(self.sidebar)
|
||||
self.list_scroll.setWidgetResizable(True)
|
||||
self.list_scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.list_host = QWidget()
|
||||
self.list_host.setObjectName("transparent")
|
||||
self.list_layout = QVBoxLayout(self.list_host)
|
||||
self.list_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.list_layout.setSpacing(4)
|
||||
self.list_scroll.setWidget(self.list_host)
|
||||
side.addWidget(self.list_scroll, 1)
|
||||
root.addWidget(self.sidebar)
|
||||
|
||||
main = QVBoxLayout()
|
||||
main.setContentsMargins(20, 18, 20, 16)
|
||||
main.setSpacing(14)
|
||||
root.addLayout(main, 1)
|
||||
|
||||
# summary
|
||||
header = QHBoxLayout()
|
||||
header.setSpacing(14)
|
||||
self.badge = QLabel(self)
|
||||
self.badge.setFixedSize(40, 40)
|
||||
self.badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
header.addWidget(self.badge, 0, Qt.AlignmentFlag.AlignTop)
|
||||
summary = QVBoxLayout()
|
||||
summary.setSpacing(4)
|
||||
self.type_label = QLabel(self)
|
||||
self.type_label.setObjectName("typeLabel")
|
||||
self.type_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
summary.addWidget(self.type_label)
|
||||
self.message_label = QLabel(self)
|
||||
self.message_label.setObjectName("messageLabel")
|
||||
self.message_label.setWordWrap(True)
|
||||
self.message_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
summary.addWidget(self.message_label)
|
||||
self.message_more = QPlainTextEdit(self)
|
||||
self.message_more.setObjectName("messageMore")
|
||||
self.message_more.setReadOnly(True)
|
||||
self.message_more.setFont(_mono_font())
|
||||
self.message_more.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth)
|
||||
self.message_more.setMaximumHeight(132)
|
||||
summary.addWidget(self.message_more)
|
||||
self.meta_label = QLabel(self)
|
||||
self.meta_label.setObjectName("metaLabel")
|
||||
self.meta_label.setTextFormat(Qt.TextFormat.RichText)
|
||||
summary.addWidget(self.meta_label)
|
||||
header.addLayout(summary, 1)
|
||||
pager = QHBoxLayout()
|
||||
pager.setSpacing(2)
|
||||
self.prev_button = IconButton("chevron_left", "Newer error (Alt+Up)", self)
|
||||
self.prev_button.clicked.connect(lambda: self.controller.step(-1))
|
||||
self.pager_label = QLabel(self)
|
||||
self.pager_label.setObjectName("metaLabel")
|
||||
self.next_button = IconButton("chevron_right", "Older error (Alt+Down)", self)
|
||||
self.next_button.clicked.connect(lambda: self.controller.step(1))
|
||||
for widget in (self.prev_button, self.pager_label, self.next_button):
|
||||
pager.addWidget(widget)
|
||||
self.pager = QWidget(self)
|
||||
self.pager.setLayout(pager)
|
||||
pager.setContentsMargins(0, 0, 0, 0)
|
||||
header.addWidget(self.pager, 0, Qt.AlignmentFlag.AlignTop)
|
||||
main.addLayout(header)
|
||||
|
||||
# where it happened
|
||||
self.location_card = QFrame(self)
|
||||
self.location_card.setObjectName("card")
|
||||
loc = QVBoxLayout(self.location_card)
|
||||
loc.setContentsMargins(14, 10, 10, 12)
|
||||
loc.setSpacing(6)
|
||||
loc_head = QHBoxLayout()
|
||||
loc_head.setSpacing(8)
|
||||
caption = QLabel("WHERE IT HAPPENED", self.location_card)
|
||||
caption.setObjectName("caption")
|
||||
loc_head.addWidget(caption)
|
||||
self.location_func = QLabel(self.location_card)
|
||||
self.location_func.setFont(_mono_font(13))
|
||||
self.location_func.setObjectName("locationFunc")
|
||||
loc_head.addWidget(self.location_func)
|
||||
self.location_label = _ElidedLabel("", self.location_card)
|
||||
self.location_label.setObjectName("metaLabel")
|
||||
loc_head.addWidget(self.location_label, 1)
|
||||
self.copy_location_button = IconButton(
|
||||
"content_copy", "Copy file path and line", self.location_card, size=16
|
||||
)
|
||||
self.copy_location_button.clicked.connect(self._copy_location)
|
||||
loc_head.addWidget(self.copy_location_button)
|
||||
loc.addLayout(loc_head)
|
||||
self.location_code = QVBoxLayout()
|
||||
loc.addLayout(self.location_code)
|
||||
main.addWidget(self.location_card)
|
||||
|
||||
# traceback
|
||||
tb_head = QHBoxLayout()
|
||||
tb_head.setSpacing(10)
|
||||
tb_title = QLabel("Traceback", self)
|
||||
tb_title.setObjectName("sectionTitle")
|
||||
tb_head.addWidget(tb_title)
|
||||
self.tb_hint = QLabel(self)
|
||||
self.tb_hint.setObjectName("metaLabel")
|
||||
tb_head.addWidget(self.tb_hint, 1)
|
||||
self.library_switch = ToggleSwitch(self)
|
||||
self.library_switch.toggled.connect(self.controller.set_show_library)
|
||||
library_label = QLabel("Library frames", self)
|
||||
library_label.setObjectName("metaLabel")
|
||||
tb_head.addWidget(library_label)
|
||||
tb_head.addWidget(self.library_switch)
|
||||
self.segment = QFrame(self)
|
||||
self.segment.setObjectName("segment")
|
||||
seg = QHBoxLayout(self.segment)
|
||||
seg.setContentsMargins(2, 2, 2, 2)
|
||||
seg.setSpacing(2)
|
||||
self.frames_tab = TextButton("Frames", "ghost", "", self.segment)
|
||||
self.raw_tab = TextButton("Raw text", "ghost", "", self.segment)
|
||||
for button, mode in ((self.frames_tab, "frames"), (self.raw_tab, "raw")):
|
||||
button.setMinimumHeight(26)
|
||||
button.clicked.connect(lambda _=False, m=mode: self.controller.set_mode(m))
|
||||
seg.addWidget(button)
|
||||
tb_head.addWidget(self.segment)
|
||||
main.addLayout(tb_head)
|
||||
|
||||
self.stack = QStackedWidget(self)
|
||||
self.frames_scroll = QScrollArea(self)
|
||||
self.frames_scroll.setObjectName("framesScroll")
|
||||
self.frames_scroll.setWidgetResizable(True)
|
||||
self.frames_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.frames_host = QWidget()
|
||||
self.frames_host.setObjectName("framesHost")
|
||||
self.frames_layout = QVBoxLayout(self.frames_host)
|
||||
self.frames_layout.setContentsMargins(6, 6, 6, 6)
|
||||
self.frames_layout.setSpacing(2)
|
||||
self.frames_scroll.setWidget(self.frames_host)
|
||||
self.stack.addWidget(self.frames_scroll)
|
||||
self.raw_text = QPlainTextEdit(self)
|
||||
self.raw_text.setReadOnly(True)
|
||||
self.raw_text.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
||||
self.raw_text.setFont(_mono_font())
|
||||
self.stack.addWidget(self.raw_text)
|
||||
main.addWidget(self.stack, 1)
|
||||
|
||||
# actions
|
||||
footer = QHBoxLayout()
|
||||
footer.setSpacing(8)
|
||||
self.copy_button = TextButton("Copy report", "neutral", "content_copy", self)
|
||||
self.copy_button.setToolTip("Copy error, location, versions and traceback (Ctrl+Shift+C)")
|
||||
self.copy_button.clicked.connect(self._copy_report)
|
||||
self.report_button = TextButton("Report issue", "neutral", "bug_report", self)
|
||||
self.report_button.setToolTip("Open a pre-filled issue in the browser")
|
||||
self.report_button.clicked.connect(self.controller.report_issue)
|
||||
footer.addWidget(self.copy_button)
|
||||
footer.addWidget(self.report_button)
|
||||
footer.addStretch(1)
|
||||
self.dismiss_button = TextButton("Dismiss", "ghost", "", self)
|
||||
self.dismiss_button.setToolTip("Remove this error from the list")
|
||||
self.dismiss_button.clicked.connect(self.controller.dismiss)
|
||||
self.close_button = TextButton("Close", "primary", "", self)
|
||||
self.close_button.clicked.connect(self.close)
|
||||
footer.addWidget(self.dismiss_button)
|
||||
footer.addWidget(self.close_button)
|
||||
main.addLayout(footer)
|
||||
|
||||
QShortcut(QKeySequence("Alt+Up"), self, activated=lambda: self.controller.step(-1))
|
||||
QShortcut(QKeySequence("Alt+Down"), self, activated=lambda: self.controller.step(1))
|
||||
QShortcut(QKeySequence("Ctrl+Shift+C"), self, activated=self._copy_report)
|
||||
|
||||
self._copied_timer = QTimer(self)
|
||||
self._copied_timer.setSingleShot(True)
|
||||
self._copied_timer.setInterval(1500)
|
||||
self._copied_timer.timeout.connect(self._reset_copied)
|
||||
|
||||
# ------------------------------------------------------------------ actions
|
||||
|
||||
def _copy_report(self) -> None:
|
||||
if self.controller.copy_report():
|
||||
self.copy_button.setText("Copied")
|
||||
self._copied_timer.start()
|
||||
|
||||
def _copy_location(self) -> None:
|
||||
if self.controller.copy_location():
|
||||
self.copy_location_button.set_icon_name("check")
|
||||
self._copied_timer.start()
|
||||
|
||||
def _reset_copied(self) -> None:
|
||||
self.copy_button.setText("Copy report")
|
||||
self.copy_location_button.set_icon_name("content_copy")
|
||||
|
||||
# ------------------------------------------------------------------ theme
|
||||
|
||||
def apply_theme(self) -> None:
|
||||
"""Re-read the theme tokens and restyle everything."""
|
||||
self._tokens = tokens = refresh_kit_theme(self)
|
||||
self.setStyleSheet(f"""
|
||||
ErrorDialogWidget {{ background: {tokens.bg.name()}; }}
|
||||
QFrame#sidebar {{ background: {tokens.card.name()};
|
||||
border-right: 1px solid {tokens.border.name()}; }}
|
||||
QWidget#transparent, QScrollArea {{ background: transparent; }}
|
||||
QLabel {{ color: {tokens.fg.name()}; background: transparent; }}
|
||||
QLabel#typeLabel {{ font-size: 19px; font-weight: 700; }}
|
||||
QLabel#messageLabel {{ font-size: 14px; }}
|
||||
QLabel#metaLabel {{ color: {tokens.fg_muted.name()}; font-size: 12px; }}
|
||||
QLabel#sectionTitle {{ font-size: 13px; font-weight: 600; }}
|
||||
QLabel#locationFunc {{ font-weight: 600; }}
|
||||
QPlainTextEdit#messageMore {{ background: {tokens.field.name()};
|
||||
color: {tokens.fg_muted.name()}; border: 1px solid {tokens.border.name()};
|
||||
border-radius: 6px; padding: 2px 4px; }}
|
||||
QLabel#caption {{ color: {tokens.fg_subtle.name()}; font-size: 11px;
|
||||
font-weight: 600; letter-spacing: 1px; }}
|
||||
QFrame#card {{ background: {tokens.card.name()};
|
||||
border: 1px solid {tokens.border.name()}; border-radius: 10px; }}
|
||||
QFrame#segment {{ background: {tokens.field.name()};
|
||||
border: 1px solid {tokens.border.name()}; border-radius: 7px; }}
|
||||
QScrollArea#framesScroll {{ background: {tokens.card.name()};
|
||||
border: 1px solid {tokens.border.name()}; border-radius: 10px; }}
|
||||
QWidget#framesHost {{ background: {tokens.card.name()}; }}
|
||||
QPlainTextEdit {{ background: {tokens.card.name()}; color: {tokens.fg.name()};
|
||||
border: 1px solid {tokens.border.name()}; border-radius: 10px; padding: 6px;
|
||||
selection-background-color: {tokens.primary.name()}; }}
|
||||
""")
|
||||
self.badge.setPixmap(
|
||||
material_icon("error", size=(24, 24), color=tokens.danger, filled=True)
|
||||
)
|
||||
self.badge.setStyleSheet(
|
||||
f"background: {tokens.soft(tokens.danger, 0.18).name()}; border-radius: 20px;"
|
||||
)
|
||||
self.sync()
|
||||
|
||||
# ------------------------------------------------------------------ rendering
|
||||
|
||||
def sync(self) -> None:
|
||||
"""Render the controller state."""
|
||||
state = self.controller.view_state()
|
||||
tokens = self._tokens
|
||||
current = state["current"]
|
||||
many = state["count"] > 1
|
||||
self.sidebar.setVisible(many)
|
||||
self.pager.setVisible(many)
|
||||
self.dismiss_button.setVisible(many)
|
||||
self.clear_button.setVisible(many)
|
||||
self.list_title.setText(f"Errors ({state['count']})")
|
||||
self.pager_label.setText(f"{state['index'] + 1} of {state['count']}")
|
||||
self.prev_button.setEnabled(state["index"] > 0)
|
||||
self.next_button.setEnabled(0 <= state["index"] < state["count"] - 1)
|
||||
if many:
|
||||
self._render_list(state)
|
||||
self._state = state
|
||||
if current is None:
|
||||
self.type_label.setText("No errors")
|
||||
self.message_label.setText("")
|
||||
self.meta_label.setText("")
|
||||
self.location_card.hide()
|
||||
_clear_layout(self.frames_layout)
|
||||
self.raw_text.clear()
|
||||
return
|
||||
|
||||
self.setWindowTitle(f"{current['type']} · {current['title']}")
|
||||
self.type_label.setText(current["type"])
|
||||
self.type_label.setToolTip(current["qualified_type"])
|
||||
self.message_label.setText(current["headline"] or "(no message)")
|
||||
self.message_more.setVisible(bool(current["message_more"]))
|
||||
if self.message_more.toPlainText() != current["message_more"]:
|
||||
self.message_more.setPlainText(current["message_more"])
|
||||
lines = current["message_more"].count("\n") + 1
|
||||
self.message_more.setFixedHeight(min(132, 14 + lines * 17))
|
||||
meta = [
|
||||
f"<span style='color:{tokens.danger.name()}; font-weight:600'>"
|
||||
f"{escape(current['title'])}</span>"
|
||||
]
|
||||
if current["source"]:
|
||||
meta.append(f"in <b style='color:{tokens.fg.name()}'>{escape(current['source'])}</b>")
|
||||
meta.append(f"<span title='{current['time']}'>{current['time'][11:]}</span>")
|
||||
if current["count"] > 1:
|
||||
meta.append(
|
||||
f"<span style='color:{tokens.warning.name()}; font-weight:600'>"
|
||||
f"{current['count']}× since {current['first_time'][11:]}</span>"
|
||||
)
|
||||
self.meta_label.setText(" · ".join(meta))
|
||||
|
||||
location = current["location"]
|
||||
self.location_card.setVisible(location is not None)
|
||||
_clear_layout(self.location_code)
|
||||
if location is not None:
|
||||
self.location_func.setText(location["func"])
|
||||
self.location_label.set_full_text(f"{location['file_short']}:{location['line']}")
|
||||
if location["context"]:
|
||||
self.location_code.addWidget(
|
||||
_code_block(location["context"], tokens, self.location_card)
|
||||
)
|
||||
|
||||
hidden = current["library_frame_count"] if not state["show_library"] else 0
|
||||
hint = f"{current['frame_count']} frames, newest first"
|
||||
if hidden:
|
||||
hint += f" · {hidden} folded"
|
||||
self.tb_hint.setText(hint)
|
||||
self.library_switch.blockSignals(True)
|
||||
self.library_switch.setChecked(state["show_library"])
|
||||
self.library_switch.blockSignals(False)
|
||||
raw = state["mode"] == "raw"
|
||||
self.frames_tab.set_variant("ghost" if raw else "primary")
|
||||
self.raw_tab.set_variant("primary" if raw else "ghost")
|
||||
self.stack.setCurrentIndex(1 if raw else 0)
|
||||
if self.raw_text.toPlainText() != current["raw"]:
|
||||
self.raw_text.setPlainText(current["raw"])
|
||||
self._render_rows(current["rows"])
|
||||
|
||||
def _render_list(self, state: dict) -> None:
|
||||
tokens = self._tokens
|
||||
_clear_layout(self.list_layout)
|
||||
for row in state["errors"]:
|
||||
selected = state["current"] is not None and row["id"] == state["current"]["id"]
|
||||
item = _Clickable(self.list_host)
|
||||
item.setObjectName("listRow")
|
||||
bg = tokens.soft(tokens.primary, 0.16).name() if selected else "transparent"
|
||||
item.setStyleSheet(
|
||||
f"QFrame#listRow {{ background: {bg}; border-radius: 8px; }}"
|
||||
f"QFrame#listRow:hover {{ background: "
|
||||
f"{tokens.soft(tokens.primary, 0.16).name() if selected else tokens.hover.name()};"
|
||||
f" }}"
|
||||
)
|
||||
lay = QVBoxLayout(item)
|
||||
lay.setContentsMargins(10, 8, 10, 8)
|
||||
lay.setSpacing(2)
|
||||
top = QHBoxLayout()
|
||||
top.setSpacing(6)
|
||||
dot = QLabel("●" if not row["seen"] else "", item)
|
||||
dot.setStyleSheet(f"color: {tokens.primary.name()}; font-size: 9px;")
|
||||
dot.setVisible(not row["seen"])
|
||||
top.addWidget(dot)
|
||||
title = QLabel(row["type"], item)
|
||||
title.setStyleSheet("font-weight: 600; font-size: 13px;")
|
||||
top.addWidget(title, 1)
|
||||
if row["count"] > 1:
|
||||
count = QLabel(f"{row['count']}×", item)
|
||||
count.setStyleSheet(
|
||||
f"color: {tokens.warning.name()}; font-weight: 600; font-size: 12px;"
|
||||
)
|
||||
top.addWidget(count)
|
||||
when = QLabel(row["time"], item)
|
||||
when.setStyleSheet(f"color: {tokens.fg_subtle.name()}; font-size: 12px;")
|
||||
top.addWidget(when)
|
||||
lay.addLayout(top)
|
||||
message = QLabel(item)
|
||||
message.setStyleSheet(f"color: {tokens.fg_muted.name()}; font-size: 12px;")
|
||||
message.setText(
|
||||
message.fontMetrics().elidedText(row["message"], Qt.TextElideMode.ElideRight, 220)
|
||||
)
|
||||
message.setToolTip(row["message"])
|
||||
lay.addWidget(message)
|
||||
item.clicked.connect(lambda error_id=row["id"]: self.controller.select(error_id))
|
||||
self.list_layout.addWidget(item)
|
||||
self.list_layout.addStretch(1)
|
||||
|
||||
def _render_rows(self, rows: list[dict]) -> None:
|
||||
tokens = self._tokens
|
||||
scroll = self.frames_scroll.verticalScrollBar().value()
|
||||
same_error = self._rendered_error == self._state["current"]["id"]
|
||||
self.frames_host.setUpdatesEnabled(False)
|
||||
_clear_layout(self.frames_layout)
|
||||
for row in rows:
|
||||
if row["kind"] == "section":
|
||||
self.frames_layout.addWidget(self._section_row(row, tokens))
|
||||
elif row["kind"] == "group":
|
||||
self.frames_layout.addWidget(self._group_row(row, tokens))
|
||||
else:
|
||||
self.frames_layout.addWidget(self._frame_row(row, tokens))
|
||||
self.frames_layout.addStretch(1)
|
||||
self.frames_host.setUpdatesEnabled(True)
|
||||
self._rendered_error = self._state["current"]["id"]
|
||||
if same_error:
|
||||
QTimer.singleShot(0, lambda: self.frames_scroll.verticalScrollBar().setValue(scroll))
|
||||
|
||||
def _section_row(self, row: dict, tokens: ThemeTokens) -> QWidget:
|
||||
widget = QFrame(self.frames_host)
|
||||
lay = QVBoxLayout(widget)
|
||||
lay.setContentsMargins(8, 6 if row["first"] else 14, 8, 4)
|
||||
lay.setSpacing(2)
|
||||
label = QLabel(row["label"].upper(), widget)
|
||||
color = tokens.danger if row["first"] else tokens.fg_subtle
|
||||
label.setStyleSheet(
|
||||
f"color: {color.name()}; font-size: 11px; font-weight: 600; letter-spacing: 1px;"
|
||||
)
|
||||
lay.addWidget(label)
|
||||
text = QLabel(widget)
|
||||
text.setTextFormat(Qt.TextFormat.RichText)
|
||||
text.setWordWrap(True)
|
||||
text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
message = escape(row["message"].splitlines()[0] if row["message"] else "")
|
||||
text.setText(
|
||||
f"<b>{escape(row['type'])}</b><span style='color:{tokens.fg_muted.name()}'>"
|
||||
f"{': ' + message if message else ''}</span>"
|
||||
)
|
||||
text.setStyleSheet("font-size: 13px;")
|
||||
lay.addWidget(text)
|
||||
return widget
|
||||
|
||||
def _group_row(self, row: dict, tokens: ThemeTokens) -> QWidget:
|
||||
widget = _Clickable(self.frames_host)
|
||||
widget.setObjectName("groupRow")
|
||||
widget.setStyleSheet(
|
||||
f"QFrame#groupRow {{ border: 1px dashed {tokens.border.name()}; border-radius: 6px; }}"
|
||||
f"QFrame#groupRow:hover {{ background: {tokens.hover.name()}; }}"
|
||||
)
|
||||
lay = QHBoxLayout(widget)
|
||||
lay.setContentsMargins(8, 4, 8, 4)
|
||||
lay.setSpacing(6)
|
||||
icon = QLabel(widget)
|
||||
icon.setPixmap(
|
||||
material_icon(
|
||||
"expand_less" if row["open"] else "unfold_more",
|
||||
size=(16, 16),
|
||||
color=tokens.fg_subtle,
|
||||
)
|
||||
)
|
||||
lay.addWidget(icon)
|
||||
label = QLabel(row["label"], widget)
|
||||
label.setStyleSheet(f"color: {tokens.fg_subtle.name()}; font-size: 12px;")
|
||||
lay.addWidget(label, 1)
|
||||
widget.setToolTip("Hide these frames" if row["open"] else "Show these frames")
|
||||
widget.clicked.connect(lambda key=row["key"]: self.controller.toggle_group(key))
|
||||
return widget
|
||||
|
||||
def _frame_row(self, row: dict, tokens: ThemeTokens) -> QWidget:
|
||||
widget = QFrame(self.frames_host)
|
||||
widget.setObjectName("frameRow")
|
||||
accent = tokens.danger.name() if row["focus"] else "transparent"
|
||||
widget.setStyleSheet(
|
||||
f"QFrame#frameRow {{ border-left: 3px solid {accent}; border-radius: 0px;"
|
||||
f" background: {rgba(tokens.danger, 0.06) if row['focus'] else 'transparent'}; }}"
|
||||
)
|
||||
outer = QVBoxLayout(widget)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
outer.setSpacing(0)
|
||||
head = _Clickable(widget)
|
||||
head.setObjectName("frameHead")
|
||||
head.setStyleSheet(
|
||||
f"QFrame#frameHead {{ border-radius: 6px; }}"
|
||||
f"QFrame#frameHead:hover {{ background: {tokens.hover.name()}; }}"
|
||||
)
|
||||
lay = QHBoxLayout(head)
|
||||
lay.setContentsMargins(6, 5, 8, 5)
|
||||
lay.setSpacing(6)
|
||||
chevron = QLabel(head)
|
||||
chevron.setPixmap(
|
||||
material_icon(
|
||||
"expand_more" if row["expanded"] else "chevron_right",
|
||||
size=(16, 16),
|
||||
color=tokens.fg_subtle,
|
||||
)
|
||||
)
|
||||
lay.addWidget(chevron, 0, Qt.AlignmentFlag.AlignTop)
|
||||
text = QVBoxLayout()
|
||||
text.setSpacing(1)
|
||||
top = QHBoxLayout()
|
||||
top.setSpacing(8)
|
||||
func = QLabel(row["func"], head)
|
||||
func.setFont(_mono_font(13))
|
||||
fg = tokens.fg if row["bec"] else tokens.fg_muted
|
||||
func.setStyleSheet(f"color: {fg.name()}; font-weight: 600;")
|
||||
top.addWidget(func)
|
||||
if row["focus"]:
|
||||
pill = QLabel("raised here", head)
|
||||
pill.setStyleSheet(
|
||||
f"color: {tokens.danger.name()}; background: {rgba(tokens.danger, 0.14)};"
|
||||
f" border-radius: 8px; padding: 1px 7px; font-size: 11px; font-weight: 600;"
|
||||
)
|
||||
top.addWidget(pill)
|
||||
where = _ElidedLabel(f"{row['file_short']}:{row['line']}", head)
|
||||
where.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
where.setStyleSheet(f"color: {tokens.fg_subtle.name()}; font-size: 12px;")
|
||||
top.addWidget(where, 1)
|
||||
text.addLayout(top)
|
||||
if row["code"] and not row["expanded"]:
|
||||
code = QLabel(row["code"], head)
|
||||
code.setFont(_mono_font())
|
||||
code.setStyleSheet(f"color: {tokens.fg_muted.name()};")
|
||||
code.setTextFormat(Qt.TextFormat.PlainText)
|
||||
code.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
||||
text.addWidget(code)
|
||||
lay.addLayout(text, 1)
|
||||
head.setToolTip("Hide source" if row["expanded"] else "Show source")
|
||||
head.clicked.connect(lambda key=row["key"]: self.controller.toggle_frame(key))
|
||||
outer.addWidget(head)
|
||||
if row["expanded"] and row["context"]:
|
||||
holder = QHBoxLayout()
|
||||
holder.setContentsMargins(28, 0, 8, 6)
|
||||
holder.addWidget(_code_block(row["context"], tokens, widget))
|
||||
outer.addLayout(holder)
|
||||
return widget
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Stop the "Copied" feedback timer."""
|
||||
self._copied_timer.stop()
|
||||
|
||||
def sizeHint(self) -> QSize: # pylint: disable=invalid-name
|
||||
"""Default size of the dialog."""
|
||||
return QSize(960, 680)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""One shared error dialog per application, used by :mod:`bec_widgets.utils.error_popups`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shiboken6
|
||||
from qtpy.QtCore import QObject, Qt
|
||||
|
||||
from bec_widgets.utils.error_dialog.error_dialog_qwidget import ErrorDialogWidget
|
||||
|
||||
_DIALOGS: dict[str, QObject] = {}
|
||||
|
||||
|
||||
def show_error_dialog(
|
||||
title: str, traceback_text: str, source: str = "", parent=None, ui: str = "qwidget"
|
||||
):
|
||||
"""Show an error in the application's error dialog, creating it on first use.
|
||||
|
||||
There is one dialog per application and version; further errors join its list.
|
||||
|
||||
Args:
|
||||
title(str): Kind of error, e.g. ``"Method error"`` or ``"Application error"``.
|
||||
traceback_text(str): Formatted traceback.
|
||||
source(str): Where the error was raised.
|
||||
parent(QWidget | None): Parent used when the dialog is created.
|
||||
ui(str): ``"qwidget"`` or ``"qml"``.
|
||||
|
||||
Returns:
|
||||
The dialog.
|
||||
"""
|
||||
dialog = _DIALOGS.get(ui)
|
||||
if dialog is not None and not shiboken6.isValid(dialog):
|
||||
dialog = None
|
||||
if dialog is None:
|
||||
if ui == "qml":
|
||||
# only the QML version pulls in Qt Quick
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from bec_widgets.utils.error_dialog.error_dialog_qml import ErrorDialogQML
|
||||
|
||||
dialog = ErrorDialogQML(parent)
|
||||
else:
|
||||
dialog = ErrorDialogWidget(parent)
|
||||
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False)
|
||||
_DIALOGS[ui] = dialog
|
||||
dialog.show_error(title, traceback_text, source)
|
||||
return dialog
|
||||
|
||||
|
||||
def reset_error_dialogs() -> None:
|
||||
"""Close and forget the shared dialogs, e.g. between tests."""
|
||||
for dialog in list(_DIALOGS.values()):
|
||||
if shiboken6.isValid(dialog):
|
||||
dialog.close()
|
||||
cleanup = getattr(dialog, "cleanup", None)
|
||||
if callable(cleanup):
|
||||
cleanup()
|
||||
dialog.deleteLater()
|
||||
_DIALOGS.clear()
|
||||
@@ -0,0 +1,638 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
import QtQuick.Layouts
|
||||
import BecUi
|
||||
|
||||
// Error dialog: summary first, then where it happened, then a navigable traceback.
|
||||
// State comes from ErrorDialogController.view_state() through backend.state.
|
||||
Rectangle {
|
||||
id: root
|
||||
required property QtObject backend
|
||||
readonly property var st: backend ? backend.state : ({ count: 0, index: -1, errors: [] })
|
||||
readonly property var cur: st.current
|
||||
readonly property bool hasCur: !!cur
|
||||
readonly property bool many: st.count > 1
|
||||
readonly property string mono: "monospace"
|
||||
property bool copied: false
|
||||
property bool locationCopied: false
|
||||
|
||||
color: theme.bg
|
||||
|
||||
Timer { id: copiedTimer; interval: 1500; onTriggered: { root.copied = false; root.locationCopied = false } }
|
||||
|
||||
Shortcut { sequence: "Alt+Up"; onActivated: backend.step(-1) }
|
||||
Shortcut { sequence: "Alt+Down"; onActivated: backend.step(1) }
|
||||
Shortcut { sequence: "Ctrl+Shift+C"; onActivated: copyReport() }
|
||||
|
||||
function copyReport() {
|
||||
if (backend.copyReport()) { root.copied = true; copiedTimer.restart() }
|
||||
}
|
||||
|
||||
component Caption: Text {
|
||||
color: theme.fgSubtle
|
||||
font.pixelSize: 11
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 1
|
||||
font.capitalization: Font.AllUppercase
|
||||
}
|
||||
|
||||
// Source lines with line numbers; the current line is tinted with the error colour.
|
||||
component CodeBlock: Rectangle {
|
||||
id: code
|
||||
property var lines: []
|
||||
implicitHeight: codeCol.implicitHeight + 12
|
||||
radius: 6
|
||||
color: theme.field
|
||||
border.color: theme.border
|
||||
clip: true
|
||||
Column {
|
||||
id: codeCol
|
||||
x: 4; y: 6
|
||||
width: parent.width - 8
|
||||
Repeater {
|
||||
model: code.lines
|
||||
Rectangle {
|
||||
required property var modelData
|
||||
width: codeCol.width
|
||||
height: 18
|
||||
color: modelData.current ? Qt.tint(theme.field, Qt.alpha(theme.danger, 0.2)) : "transparent"
|
||||
Row {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 10
|
||||
Text {
|
||||
width: 34
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: modelData.no
|
||||
font.family: root.mono
|
||||
font.pixelSize: 12
|
||||
color: modelData.current ? theme.danger : theme.fgSubtle
|
||||
}
|
||||
Text {
|
||||
text: modelData.text
|
||||
textFormat: Text.PlainText
|
||||
font.family: root.mono
|
||||
font.pixelSize: 12
|
||||
color: modelData.current ? theme.fg : theme.fgMuted
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
// ---------------------------------------------------------------- error list
|
||||
Rectangle {
|
||||
visible: root.many
|
||||
Layout.fillHeight: true
|
||||
Layout.preferredWidth: 264
|
||||
color: theme.card
|
||||
Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: theme.border }
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
anchors.topMargin: 14
|
||||
spacing: 8
|
||||
RowLayout {
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "Errors (" + root.st.count + ")"
|
||||
color: theme.fg
|
||||
font.pixelSize: 13
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
TextButton {
|
||||
text: "Clear all"
|
||||
variant: "ghost"
|
||||
iconName: "delete_sweep"
|
||||
tip: "Remove every error from the list"
|
||||
onClicked: backend.clear()
|
||||
}
|
||||
}
|
||||
ListView {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
clip: true
|
||||
spacing: 4
|
||||
model: root.st.errors
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
ScrollBar.vertical: ScrollBar {}
|
||||
delegate: Rectangle {
|
||||
id: item
|
||||
required property var modelData
|
||||
readonly property bool selected: root.hasCur && modelData.id === root.cur.id
|
||||
width: ListView.view.width
|
||||
height: itemCol.implicitHeight + 16
|
||||
radius: 8
|
||||
color: selected ? Qt.tint(theme.card, Qt.alpha(theme.primary, 0.16))
|
||||
: itemMouse.containsMouse ? theme.hover : "transparent"
|
||||
MouseArea {
|
||||
id: itemMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: backend.select(item.modelData.id)
|
||||
}
|
||||
ColumnLayout {
|
||||
id: itemCol
|
||||
x: 10; y: 8
|
||||
width: parent.width - 20
|
||||
spacing: 2
|
||||
RowLayout {
|
||||
spacing: 6
|
||||
Rectangle {
|
||||
visible: !item.modelData.seen
|
||||
width: 7; height: 7; radius: 3.5
|
||||
color: theme.primary
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: item.modelData.type
|
||||
color: theme.fg
|
||||
font.pixelSize: 13
|
||||
font.weight: Font.DemiBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
visible: item.modelData.count > 1
|
||||
text: item.modelData.count + "×"
|
||||
color: theme.warning
|
||||
font.pixelSize: 12
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text { text: item.modelData.time; color: theme.fgSubtle; font.pixelSize: 12 }
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: item.modelData.message
|
||||
color: theme.fgMuted
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- details
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.leftMargin: 20
|
||||
Layout.rightMargin: 20
|
||||
Layout.topMargin: 18
|
||||
Layout.bottomMargin: 16
|
||||
spacing: 14
|
||||
|
||||
// summary
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 14
|
||||
Rectangle {
|
||||
Layout.alignment: Qt.AlignTop
|
||||
width: 40; height: 40; radius: 20
|
||||
color: Qt.tint(theme.card, Qt.alpha(theme.danger, 0.18))
|
||||
Icon { anchors.centerIn: parent; name: "error"; filled: true; size: 24; color: theme.danger }
|
||||
}
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
TextEdit {
|
||||
Layout.fillWidth: true
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
text: root.cur ? root.cur.type : "No errors"
|
||||
color: theme.fg
|
||||
font.pixelSize: 19
|
||||
font.weight: Font.Bold
|
||||
HoverHandler { id: typeHover }
|
||||
ToolTip.visible: typeHover.hovered && root.hasCur
|
||||
ToolTip.delay: 500
|
||||
ToolTip.text: root.cur ? root.cur.qualified_type : ""
|
||||
}
|
||||
TextEdit {
|
||||
Layout.fillWidth: true
|
||||
visible: root.hasCur
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: Text.Wrap
|
||||
text: root.cur ? (root.cur.headline || "(no message)") : ""
|
||||
color: theme.fg
|
||||
font.pixelSize: 14
|
||||
}
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
visible: root.hasCur && !!root.cur.message_more
|
||||
Layout.preferredHeight: Math.min(132, moreText.implicitHeight + 8)
|
||||
radius: 6
|
||||
color: theme.field
|
||||
border.color: theme.border
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 4
|
||||
clip: true
|
||||
TextArea {
|
||||
id: moreText
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: TextEdit.Wrap
|
||||
text: root.cur ? root.cur.message_more : ""
|
||||
color: theme.fgMuted
|
||||
font.family: root.mono
|
||||
font.pixelSize: 12
|
||||
padding: 2
|
||||
background: null
|
||||
}
|
||||
}
|
||||
}
|
||||
Text {
|
||||
visible: root.hasCur
|
||||
textFormat: Text.StyledText
|
||||
color: theme.fgMuted
|
||||
font.pixelSize: 12
|
||||
text: {
|
||||
if (!root.cur) return ""
|
||||
const sep = " · "
|
||||
let parts = ["<font color='" + theme.danger + "'><b>" + root.cur.title + "</b></font>"]
|
||||
if (root.cur.source)
|
||||
parts.push("in <font color='" + theme.fg + "'><b>" + root.cur.source + "</b></font>")
|
||||
parts.push(root.cur.time.substring(11))
|
||||
if (root.cur.count > 1)
|
||||
parts.push("<font color='" + theme.warning + "'><b>" + root.cur.count
|
||||
+ "× since " + root.cur.first_time.substring(11) + "</b></font>")
|
||||
return parts.join(sep)
|
||||
}
|
||||
}
|
||||
}
|
||||
RowLayout {
|
||||
visible: root.many
|
||||
Layout.alignment: Qt.AlignTop
|
||||
spacing: 2
|
||||
IconButton {
|
||||
iconName: "chevron_left"
|
||||
tip: "Newer error (Alt+Up)"
|
||||
enabled: root.st.index > 0
|
||||
onClicked: backend.step(-1)
|
||||
}
|
||||
Text { text: (root.st.index + 1) + " of " + root.st.count; color: theme.fgMuted; font.pixelSize: 12 }
|
||||
IconButton {
|
||||
iconName: "chevron_right"
|
||||
tip: "Older error (Alt+Down)"
|
||||
enabled: root.st.index >= 0 && root.st.index < root.st.count - 1
|
||||
onClicked: backend.step(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// where it happened
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
visible: root.hasCur && !!root.cur.location
|
||||
implicitHeight: locCol.implicitHeight + 22
|
||||
radius: 10
|
||||
color: theme.card
|
||||
border.color: theme.border
|
||||
ColumnLayout {
|
||||
id: locCol
|
||||
x: 14; y: 10
|
||||
width: parent.width - 24
|
||||
spacing: 6
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
Caption { text: "Where it happened" }
|
||||
Text {
|
||||
text: root.cur && root.cur.location ? root.cur.location.func : ""
|
||||
color: theme.fg
|
||||
font.family: root.mono
|
||||
font.pixelSize: 13
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: root.cur && root.cur.location ? root.cur.location.file_short + ":" + root.cur.location.line : ""
|
||||
color: theme.fgMuted
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
IconButton {
|
||||
iconName: root.locationCopied ? "check" : "content_copy"
|
||||
iconSize: 16
|
||||
tip: "Copy file path and line"
|
||||
onClicked: if (backend.copyLocation()) { root.locationCopied = true; copiedTimer.restart() }
|
||||
}
|
||||
}
|
||||
CodeBlock {
|
||||
Layout.fillWidth: true
|
||||
visible: lines.length > 0
|
||||
lines: root.cur && root.cur.location ? root.cur.location.context : []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// traceback header
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: root.hasCur
|
||||
spacing: 10
|
||||
Text { text: "Traceback"; color: theme.fg; font.pixelSize: 13; font.weight: Font.DemiBold }
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
color: theme.fgMuted
|
||||
font.pixelSize: 12
|
||||
text: {
|
||||
if (!root.cur) return ""
|
||||
let hint = root.cur.frame_count + " frames, newest first"
|
||||
if (!root.st.show_library && root.cur.library_frame_count > 0)
|
||||
hint += " · " + root.cur.library_frame_count + " folded"
|
||||
return hint
|
||||
}
|
||||
}
|
||||
Text { text: "Library frames"; color: theme.fgMuted; font.pixelSize: 12 }
|
||||
SwitchField {
|
||||
checked: !!root.st.show_library
|
||||
onToggled: backend.setShowLibrary(checked)
|
||||
}
|
||||
Rectangle {
|
||||
implicitWidth: segRow.implicitWidth + 4
|
||||
implicitHeight: 32
|
||||
radius: 7
|
||||
color: theme.field
|
||||
border.color: theme.border
|
||||
Row {
|
||||
id: segRow
|
||||
anchors.centerIn: parent
|
||||
spacing: 2
|
||||
TextButton {
|
||||
text: "Frames"
|
||||
implicitHeight: 26
|
||||
variant: root.st.mode === "frames" ? "primary" : "ghost"
|
||||
onClicked: backend.setMode("frames")
|
||||
}
|
||||
TextButton {
|
||||
text: "Raw text"
|
||||
implicitHeight: 26
|
||||
variant: root.st.mode === "raw" ? "primary" : "ghost"
|
||||
onClicked: backend.setMode("raw")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// traceback body
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
visible: root.hasCur
|
||||
radius: 10
|
||||
color: theme.card
|
||||
border.color: theme.border
|
||||
clip: true
|
||||
|
||||
ScrollView {
|
||||
id: framesView
|
||||
visible: root.st.mode === "frames"
|
||||
anchors.fill: parent
|
||||
anchors.margins: 6
|
||||
contentWidth: availableWidth
|
||||
clip: true
|
||||
Column {
|
||||
width: framesView.availableWidth
|
||||
spacing: 2
|
||||
Repeater {
|
||||
model: root.cur ? root.cur.rows : []
|
||||
delegate: Loader {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
sourceComponent: modelData.kind === "section" ? sectionRow
|
||||
: modelData.kind === "group" ? groupRow : frameRow
|
||||
property var row: modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
visible: root.st.mode === "raw"
|
||||
anchors.fill: parent
|
||||
anchors.margins: 4
|
||||
clip: true
|
||||
TextArea {
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: TextEdit.NoWrap
|
||||
text: root.cur ? root.cur.raw : ""
|
||||
color: theme.fg
|
||||
selectionColor: theme.primary
|
||||
font.family: root.mono
|
||||
font.pixelSize: 12
|
||||
background: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true; visible: root.cur === null }
|
||||
|
||||
// actions
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
TextButton {
|
||||
text: root.copied ? "Copied" : "Copy report"
|
||||
iconName: root.copied ? "check" : "content_copy"
|
||||
tip: "Copy error, location, versions and traceback (Ctrl+Shift+C)"
|
||||
enabled: root.hasCur
|
||||
onClicked: root.copyReport()
|
||||
}
|
||||
TextButton {
|
||||
text: "Report issue"
|
||||
iconName: "bug_report"
|
||||
tip: "Open a pre-filled issue in the browser"
|
||||
enabled: root.hasCur
|
||||
onClicked: backend.reportIssue()
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
TextButton {
|
||||
visible: root.many
|
||||
text: "Dismiss"
|
||||
variant: "ghost"
|
||||
tip: "Remove this error from the list"
|
||||
onClicked: backend.dismiss()
|
||||
}
|
||||
TextButton {
|
||||
text: "Close"
|
||||
variant: "primary"
|
||||
onClicked: backend.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- traceback rows
|
||||
|
||||
Component {
|
||||
id: sectionRow
|
||||
Item {
|
||||
readonly property var r: parent ? parent.row : null
|
||||
implicitHeight: secCol.implicitHeight + (r && r.first ? 10 : 18)
|
||||
ColumnLayout {
|
||||
id: secCol
|
||||
x: 8
|
||||
y: r && r.first ? 6 : 14
|
||||
width: parent.width - 16
|
||||
spacing: 2
|
||||
Caption {
|
||||
text: r ? r.label : ""
|
||||
color: r && r.first ? theme.danger : theme.fgSubtle
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.Wrap
|
||||
textFormat: Text.StyledText
|
||||
font.pixelSize: 13
|
||||
color: theme.fg
|
||||
text: {
|
||||
if (!r) return ""
|
||||
const first = r.message ? r.message.split("\n")[0] : ""
|
||||
return "<b>" + r.type + "</b>" + (first ? "<font color='" + theme.fgMuted + "'>: "
|
||||
+ first.replace(/&/g, "&").replace(/</g, "<") + "</font>" : "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: groupRow
|
||||
Rectangle {
|
||||
readonly property var r: parent ? parent.row : null
|
||||
implicitHeight: 26
|
||||
radius: 6
|
||||
color: groupMouse.containsMouse ? theme.hover : "transparent"
|
||||
border.color: theme.border
|
||||
// dashed look: a solid hairline reads the same at this size
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
spacing: 6
|
||||
Icon { name: r && r.open ? "expand_less" : "unfold_more"; size: 16; color: theme.fgSubtle }
|
||||
Text { Layout.fillWidth: true; text: r ? r.label : ""; color: theme.fgSubtle; font.pixelSize: 12; elide: Text.ElideRight }
|
||||
}
|
||||
MouseArea {
|
||||
id: groupMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: backend.toggleGroup(r.key)
|
||||
ToolTip.visible: containsMouse
|
||||
ToolTip.delay: 500
|
||||
ToolTip.text: r && r.open ? "Hide these frames" : "Show these frames"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: frameRow
|
||||
Rectangle {
|
||||
readonly property var r: parent ? parent.row : null
|
||||
implicitHeight: frameCol.implicitHeight
|
||||
color: r && r.focus ? Qt.alpha(theme.danger, 0.06) : "transparent"
|
||||
Rectangle {
|
||||
visible: r && r.focus
|
||||
width: 3; height: parent.height
|
||||
color: theme.danger
|
||||
}
|
||||
Column {
|
||||
id: frameCol
|
||||
x: 3
|
||||
width: parent.width - 3
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: headCol.implicitHeight + 10
|
||||
radius: 6
|
||||
color: headMouse.containsMouse ? theme.hover : "transparent"
|
||||
MouseArea {
|
||||
id: headMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: backend.toggleFrame(r.key)
|
||||
}
|
||||
Icon {
|
||||
x: 4; y: 6
|
||||
name: r && r.expanded ? "expand_more" : "chevron_right"
|
||||
size: 16
|
||||
color: theme.fgSubtle
|
||||
}
|
||||
ColumnLayout {
|
||||
id: headCol
|
||||
x: 26; y: 5
|
||||
width: parent.width - 34
|
||||
spacing: 1
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
Text {
|
||||
text: r ? r.func : ""
|
||||
font.family: root.mono
|
||||
font.pixelSize: 13
|
||||
font.weight: Font.DemiBold
|
||||
color: r && r.bec ? theme.fg : theme.fgMuted
|
||||
}
|
||||
Rectangle {
|
||||
visible: r && r.focus
|
||||
implicitWidth: pillText.implicitWidth + 14
|
||||
implicitHeight: 18
|
||||
radius: 9
|
||||
color: Qt.alpha(theme.danger, 0.14)
|
||||
Text {
|
||||
id: pillText
|
||||
anchors.centerIn: parent
|
||||
text: "raised here"
|
||||
color: theme.danger
|
||||
font.pixelSize: 11
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: r ? r.file_short + ":" + r.line : ""
|
||||
color: theme.fgSubtle
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: r && r.code !== "" && !r.expanded
|
||||
text: r ? r.code : ""
|
||||
textFormat: Text.PlainText
|
||||
font.family: root.mono
|
||||
font.pixelSize: 12
|
||||
color: theme.fgMuted
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
Item {
|
||||
visible: r && r.expanded && r.context.length > 0
|
||||
width: parent.width
|
||||
height: visible ? ctx.implicitHeight + 6 : 0
|
||||
CodeBlock {
|
||||
id: ctx
|
||||
x: 25
|
||||
width: parent.width - 33
|
||||
lines: r ? r.context : []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import functools
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from typing import Any, Callable, Literal
|
||||
@@ -111,7 +112,9 @@ def SafeProperty(
|
||||
error_msg = traceback.format_exc()
|
||||
|
||||
if popup_error:
|
||||
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=True)
|
||||
ErrorPopupUtility().custom_exception_hook(
|
||||
*sys.exc_info(), popup_error=True, source=py_getter.__qualname__
|
||||
)
|
||||
logger.error(f"SafeProperty error in GETTER of '{prop_name}':\n{error_msg}")
|
||||
return default
|
||||
|
||||
@@ -173,7 +176,7 @@ def SafeProperty(
|
||||
|
||||
if popup_error:
|
||||
ErrorPopupUtility().custom_exception_hook(
|
||||
*sys.exc_info(), popup_error=True
|
||||
*sys.exc_info(), popup_error=True, source=setter_func.__qualname__
|
||||
)
|
||||
logger.error(f"SafeProperty error in SETTER of '{prop_name}':\n{error_msg}")
|
||||
return
|
||||
@@ -298,7 +301,9 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
|
||||
slot_name = f"{method.__module__}.{method.__qualname__}"
|
||||
error_msg = traceback.format_exc()
|
||||
if call_params["popup_error"]:
|
||||
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=True)
|
||||
ErrorPopupUtility().custom_exception_hook(
|
||||
*sys.exc_info(), popup_error=True, source=method.__qualname__
|
||||
)
|
||||
logger.error(f"SafeSlot error in slot '{slot_name}':\n{error_msg}")
|
||||
if call_params["raise_error"]:
|
||||
raise
|
||||
@@ -339,6 +344,12 @@ class WarningPopupUtility(QObject):
|
||||
_popup_utility_instance = None
|
||||
|
||||
|
||||
def _error_dialog_ui() -> str | None:
|
||||
"""Return ``"qwidget"`` or ``"qml"`` when ``BEC_ERROR_DIALOG`` selects the new dialog."""
|
||||
value = os.environ.get("BEC_ERROR_DIALOG", "").strip().lower()
|
||||
return value if value in ("qwidget", "qml") else None
|
||||
|
||||
|
||||
class _ErrorPopupUtility(QObject):
|
||||
"""
|
||||
Utility class to manage error popups in the application to show error messages to the users.
|
||||
@@ -346,10 +357,14 @@ class _ErrorPopupUtility(QObject):
|
||||
"""
|
||||
|
||||
error_occurred = Signal(str, str, QWidget)
|
||||
# title, traceback, source, dialog version; used instead of error_occurred when the
|
||||
# redesigned error dialog is selected with BEC_ERROR_DIALOG
|
||||
error_reported = Signal(str, str, str, str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self.error_occurred.connect(self.show_error_message)
|
||||
self.error_reported.connect(self.show_error_dialog)
|
||||
self.enable_error_popup = False
|
||||
self._initialized = True
|
||||
sys.excepthook = self.custom_exception_hook
|
||||
@@ -370,6 +385,21 @@ class _ErrorPopupUtility(QObject):
|
||||
msg.setMinimumHeight(400)
|
||||
msg.exec_()
|
||||
|
||||
@SafeSlot(str, str, str, str)
|
||||
def show_error_dialog(self, title: str, message: str, source: str, ui: str):
|
||||
"""Show the error in the redesigned, resizable error dialog.
|
||||
|
||||
Args:
|
||||
title(str): Kind of error.
|
||||
message(str): Formatted traceback.
|
||||
source(str): Where the error was raised.
|
||||
ui(str): ``"qwidget"`` or ``"qml"``.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from bec_widgets.utils.error_dialog.error_dialog_registry import show_error_dialog
|
||||
|
||||
show_error_dialog(title, message, source=source, parent=self.parent(), ui=ui)
|
||||
|
||||
def show_property_error(self, title, message, widget):
|
||||
"""
|
||||
Show a property-specific error message.
|
||||
@@ -415,12 +445,17 @@ class _ErrorPopupUtility(QObject):
|
||||
def get_error_message(self, exctype, value, tb):
|
||||
return "".join(traceback.format_exception(exctype, value, tb))
|
||||
|
||||
def custom_exception_hook(self, exctype, value, tb, popup_error=False):
|
||||
def custom_exception_hook(self, exctype, value, tb, popup_error=False, source=None):
|
||||
if popup_error or self.enable_error_popup:
|
||||
title = "Method error" if popup_error else "Application Error"
|
||||
ui = _error_dialog_ui()
|
||||
if ui is not None:
|
||||
self.error_reported.emit(
|
||||
title, self.get_error_message(exctype, value, tb), source or "", ui
|
||||
)
|
||||
return
|
||||
self.error_occurred.emit(
|
||||
"Method error" if popup_error else "Application Error",
|
||||
self.get_error_message(exctype, value, tb),
|
||||
self.parent(),
|
||||
title, self.get_error_message(exctype, value, tb), self.parent()
|
||||
)
|
||||
else:
|
||||
sys.__excepthook__(exctype, value, tb) # Call the original excepthook
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
# pylint: disable=missing-function-docstring, redefined-outer-name, protected-access
|
||||
import traceback
|
||||
|
||||
import pytest
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.error_dialog import error_dialog_common as common
|
||||
from bec_widgets.utils.error_dialog import error_dialog_registry as registry
|
||||
from bec_widgets.utils.error_dialog.error_dialog_common import (
|
||||
ErrorDialogController,
|
||||
classify_path,
|
||||
parse_traceback,
|
||||
short_path,
|
||||
)
|
||||
from bec_widgets.utils.error_dialog.error_dialog_demo import ErrorDemo, check_limits
|
||||
from bec_widgets.utils.error_popups import ErrorPopupUtility
|
||||
|
||||
|
||||
def _format(func) -> str:
|
||||
try:
|
||||
func()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
return "".join(traceback.format_exception(exc))
|
||||
raise AssertionError("no error raised")
|
||||
|
||||
|
||||
def _limit_error():
|
||||
check_limits("samx", 12.5, (-10, 10))
|
||||
|
||||
|
||||
def _chained():
|
||||
try:
|
||||
{}["bpm4i"]
|
||||
except KeyError as exc:
|
||||
raise RuntimeError("curve missing\nsecond line") from exc
|
||||
|
||||
|
||||
def _json_error():
|
||||
import json # pylint: disable=import-outside-toplevel
|
||||
|
||||
json.loads("{bad")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_dialogs(monkeypatch):
|
||||
monkeypatch.delenv("BEC_ERROR_DIALOG", raising=False)
|
||||
yield
|
||||
registry.reset_error_dialogs()
|
||||
QApplication.processEvents()
|
||||
|
||||
|
||||
def test_parse_traceback_chain_and_carets():
|
||||
text = _format(_chained)
|
||||
exceptions = parse_traceback(text)
|
||||
assert [e.short_type for e in exceptions] == ["KeyError", "RuntimeError"]
|
||||
assert exceptions[0].relation == "Caused by"
|
||||
assert exceptions[1].message == "curve missing\nsecond line"
|
||||
frame = exceptions[0].frames[-1]
|
||||
assert frame.func == "_chained"
|
||||
assert "^" not in frame.code and frame.code
|
||||
|
||||
|
||||
def test_parse_traceback_while_handling():
|
||||
text = "\n".join(
|
||||
[
|
||||
"Traceback (most recent call last):",
|
||||
' File "/x/a.py", line 3, in f',
|
||||
" g()",
|
||||
"KeyError: 'a'",
|
||||
"",
|
||||
"During handling of the above exception, another exception occurred:",
|
||||
"",
|
||||
"Traceback (most recent call last):",
|
||||
' File "/x/a.py", line 5, in f',
|
||||
" raise ValueError",
|
||||
"ValueError",
|
||||
]
|
||||
)
|
||||
exceptions = parse_traceback(text)
|
||||
assert exceptions[0].relation == "While handling"
|
||||
assert exceptions[1].type == "ValueError" and exceptions[1].message == ""
|
||||
|
||||
|
||||
def test_parse_plain_text():
|
||||
exceptions = parse_traceback("something went wrong")
|
||||
assert len(exceptions) == 1
|
||||
assert exceptions[0].type == "Error"
|
||||
assert exceptions[0].message == "something went wrong"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path, kind, short",
|
||||
[
|
||||
("/usr/lib/python3.13/json/decoder.py", "library", "json/decoder.py"),
|
||||
("/venv/lib/python3.13/site-packages/pydantic/main.py", "library", "pydantic/main.py"),
|
||||
("/venv/lib/python3.13/site-packages/bec_lib/client.py", "bec", "bec_lib/client.py"),
|
||||
("/home/u/bec_widgets/bec_widgets/utils/x.py", "bec", "bec_widgets/utils/x.py"),
|
||||
("<frozen runpy>", "library", "<frozen runpy>"),
|
||||
],
|
||||
)
|
||||
def test_classify_and_shorten(path, kind, short):
|
||||
assert classify_path(path) == kind
|
||||
assert short_path(path) == short
|
||||
|
||||
|
||||
def test_short_path_uses_package_root():
|
||||
assert short_path(common.__file__) == "bec_widgets/utils/error_dialog/error_dialog_common.py"
|
||||
|
||||
|
||||
def test_controller_folds_repeats_and_follows_newest(qtbot):
|
||||
controller = ErrorDialogController()
|
||||
first = controller.add_error("Method error", _format(_limit_error), "A.slot")
|
||||
again = controller.add_error("Method error", _format(_limit_error), "A.slot")
|
||||
assert first == again
|
||||
assert controller.current.count == 2
|
||||
other = controller.add_error("Method error", _format(_chained), "B.slot")
|
||||
assert controller.current.id == other
|
||||
state = controller.view_state()
|
||||
assert state["count"] == 2
|
||||
assert [row["type"] for row in state["errors"]] == ["RuntimeError", "LimitError"]
|
||||
|
||||
|
||||
def test_controller_keeps_selection_when_reading_older(qtbot):
|
||||
controller = ErrorDialogController()
|
||||
old = controller.add_error("Method error", _format(_limit_error))
|
||||
controller.add_error("Method error", _format(_chained))
|
||||
controller.select(old)
|
||||
new = controller.add_error("Method error", _format(_json_error))
|
||||
assert controller.current.id == old
|
||||
unseen = [row for row in controller.view_state()["errors"] if not row["seen"]]
|
||||
assert [row["id"] for row in unseen] == [new]
|
||||
|
||||
|
||||
def test_controller_dismiss_step_clear(qtbot):
|
||||
controller = ErrorDialogController()
|
||||
ids = [
|
||||
controller.add_error("Method error", _format(f))
|
||||
for f in (_limit_error, _chained, _json_error)
|
||||
]
|
||||
assert controller.current.id == ids[2]
|
||||
controller.step(1)
|
||||
assert controller.current.id == ids[1]
|
||||
controller.dismiss()
|
||||
assert controller.current.id == ids[0]
|
||||
assert len(controller.errors) == 2
|
||||
controller.clear()
|
||||
assert controller.current is None
|
||||
assert controller.view_state()["current"] is None
|
||||
|
||||
|
||||
def test_rows_fold_library_frames(qtbot):
|
||||
controller = ErrorDialogController()
|
||||
controller.add_error("Method error", _format(_json_error))
|
||||
rows = controller.view_state()["current"]["rows"]
|
||||
assert rows[0]["kind"] == "section" and rows[0]["label"] == "Raised"
|
||||
groups = [row for row in rows if row["kind"] == "group"]
|
||||
assert groups and "json" in groups[0]["label"]
|
||||
assert not any(row["kind"] == "frame" and not row["bec"] for row in rows)
|
||||
controller.toggle_group(groups[0]["key"])
|
||||
rows = controller.view_state()["current"]["rows"]
|
||||
assert any(row["kind"] == "frame" and not row["bec"] for row in rows)
|
||||
controller.set_show_library(True)
|
||||
rows = controller.view_state()["current"]["rows"]
|
||||
assert not any(row["kind"] == "group" for row in rows)
|
||||
|
||||
|
||||
def test_focus_frame_and_context(qtbot):
|
||||
controller = ErrorDialogController()
|
||||
controller.add_error("Method error", _format(_limit_error))
|
||||
location = controller.view_state()["current"]["location"]
|
||||
assert location["func"] == "check_limits"
|
||||
assert any(row["current"] and "raise LimitError" in row["text"] for row in location["context"])
|
||||
frame = next(r for r in controller.view_state()["current"]["rows"] if r["kind"] == "frame")
|
||||
controller.toggle_frame(frame["key"])
|
||||
expanded = next(
|
||||
r for r in controller.view_state()["current"]["rows"] if r["key"] == frame["key"]
|
||||
)
|
||||
assert expanded["expanded"] and expanded["context"]
|
||||
|
||||
|
||||
def test_report_and_issue_url(qtbot):
|
||||
controller = ErrorDialogController()
|
||||
controller.add_error("Method error", _format(_limit_error), "Demo.slot")
|
||||
report = controller.copy_report()
|
||||
assert "LimitError: Target 12.5" in report.splitlines()[0]
|
||||
assert "Raised in: Demo.slot" in report and "bec_widgets" in report
|
||||
url = controller.current.issue_url()
|
||||
assert url.startswith(common.ISSUE_URL)
|
||||
assert len(url) < 12000
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ui", ["qwidget", "qml"])
|
||||
def test_safeslot_routes_to_new_dialog(qtbot, monkeypatch, ui):
|
||||
monkeypatch.setenv("BEC_ERROR_DIALOG", ui)
|
||||
demo = ErrorDemo()
|
||||
qtbot.addWidget(demo)
|
||||
utility = ErrorPopupUtility()
|
||||
with qtbot.assertNotEmitted(utility.error_occurred):
|
||||
with qtbot.waitSignal(utility.error_reported, timeout=1000):
|
||||
demo.on_setpoint_change()
|
||||
qtbot.waitUntil(lambda: ui in registry._DIALOGS)
|
||||
dialog = registry._DIALOGS[ui]
|
||||
assert dialog.isVisible()
|
||||
assert dialog.controller.current.source == "ErrorDemo.on_setpoint_change"
|
||||
demo.burst()
|
||||
qtbot.waitUntil(lambda: len(dialog.controller.errors) == 2)
|
||||
assert dialog.controller.current.count == 7
|
||||
assert dialog.width() >= dialog.MIN_SIZE[0] and dialog.height() >= dialog.MIN_SIZE[1]
|
||||
dialog.close()
|
||||
assert dialog.controller.errors == []
|
||||
|
||||
|
||||
def test_qwidget_dialog_renders_rows(qtbot):
|
||||
from bec_widgets.utils.error_dialog.error_dialog_qwidget import ( # pylint: disable=import-outside-toplevel
|
||||
ErrorDialogWidget,
|
||||
)
|
||||
|
||||
dialog = ErrorDialogWidget()
|
||||
qtbot.addWidget(dialog)
|
||||
dialog.show_error("Method error", _format(_chained), "X.y")
|
||||
assert dialog.type_label.text() == "RuntimeError"
|
||||
assert dialog.message_label.text() == "curve missing"
|
||||
assert dialog.message_more.isVisible()
|
||||
assert not dialog.sidebar.isVisible()
|
||||
dialog.show_error("Method error", _format(_limit_error), "X.z")
|
||||
assert dialog.sidebar.isVisible() and dialog.pager_label.text() == "1 of 2"
|
||||
dialog.controller.set_mode("raw")
|
||||
assert dialog.stack.currentIndex() == 1
|
||||
assert "LimitError" in dialog.raw_text.toPlainText()
|
||||
dialog.cleanup()
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_legacy_popup_unchanged_without_env(qtbot):
|
||||
utility = ErrorPopupUtility()
|
||||
shown = []
|
||||
utility.error_occurred.disconnect(utility.show_error_message)
|
||||
utility.error_occurred.connect(lambda *args: shown.append(args))
|
||||
try:
|
||||
demo = ErrorDemo()
|
||||
qtbot.addWidget(demo)
|
||||
with qtbot.assertNotEmitted(utility.error_reported):
|
||||
demo.on_setpoint_change()
|
||||
assert shown and shown[0][0] == "Method error"
|
||||
finally:
|
||||
utility.error_occurred.disconnect()
|
||||
utility.error_occurred.connect(utility.show_error_message)
|
||||
Reference in new issue
Block a user