From be6e546e0eb4a10379f763be4217cd2ed2286224 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 21:52:30 +0000 Subject: [PATCH] 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 --- bec_widgets/utils/error_dialog/__init__.py | 0 .../utils/error_dialog/error_dialog_common.py | 755 ++++++++++++++++++ .../utils/error_dialog/error_dialog_demo.py | 132 +++ .../utils/error_dialog/error_dialog_qml.py | 139 ++++ .../error_dialog/error_dialog_qwidget.py | 661 +++++++++++++++ .../error_dialog/error_dialog_registry.py | 57 ++ .../utils/error_dialog/qml/ErrorDialog.qml | 638 +++++++++++++++ bec_widgets/utils/error_popups.py | 49 +- tests/unit_tests/test_error_dialog.py | 247 ++++++ 9 files changed, 2671 insertions(+), 7 deletions(-) create mode 100644 bec_widgets/utils/error_dialog/__init__.py create mode 100644 bec_widgets/utils/error_dialog/error_dialog_common.py create mode 100644 bec_widgets/utils/error_dialog/error_dialog_demo.py create mode 100644 bec_widgets/utils/error_dialog/error_dialog_qml.py create mode 100644 bec_widgets/utils/error_dialog/error_dialog_qwidget.py create mode 100644 bec_widgets/utils/error_dialog/error_dialog_registry.py create mode 100644 bec_widgets/utils/error_dialog/qml/ErrorDialog.qml create mode 100644 tests/unit_tests/test_error_dialog.py diff --git a/bec_widgets/utils/error_dialog/__init__.py b/bec_widgets/utils/error_dialog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/utils/error_dialog/error_dialog_common.py b/bec_widgets/utils/error_dialog/error_dialog_common.py new file mode 100644 index 00000000..883f2043 --- /dev/null +++ b/bec_widgets/utils/error_dialog/error_dialog_common.py @@ -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[^"]+)", line (?P\d+)(?:, in (?P.+))?$') +_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(">"): + # , and friends + return "library" if posix.startswith(" 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 "", + "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 diff --git a/bec_widgets/utils/error_dialog/error_dialog_demo.py b/bec_widgets/utils/error_dialog/error_dialog_demo.py new file mode 100644 index 00000000..24dc30d1 --- /dev/null +++ b/bec_widgets/utils/error_dialog/error_dialog_demo.py @@ -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_()) diff --git a/bec_widgets/utils/error_dialog/error_dialog_qml.py b/bec_widgets/utils/error_dialog/error_dialog_qml.py new file mode 100644 index 00000000..7771d8c1 --- /dev/null +++ b/bec_widgets/utils/error_dialog/error_dialog_qml.py @@ -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) diff --git a/bec_widgets/utils/error_dialog/error_dialog_qwidget.py b/bec_widgets/utils/error_dialog/error_dialog_qwidget.py new file mode 100644 index 00000000..0df47a96 --- /dev/null +++ b/bec_widgets/utils/error_dialog/error_dialog_qwidget.py @@ -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"{number}" + f"{text}" + ) + else: + lines.append( + f"{number}" + f"{text}" + ) + label = QLabel( + f"{''.join(lines)}
", 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"" + f"{escape(current['title'])}" + ] + if current["source"]: + meta.append(f"in {escape(current['source'])}") + meta.append(f"{current['time'][11:]}") + if current["count"] > 1: + meta.append( + f"" + f"{current['count']}× since {current['first_time'][11:]}" + ) + 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"{escape(row['type'])}" + f"{': ' + message if message else ''}" + ) + 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) diff --git a/bec_widgets/utils/error_dialog/error_dialog_registry.py b/bec_widgets/utils/error_dialog/error_dialog_registry.py new file mode 100644 index 00000000..49f29d11 --- /dev/null +++ b/bec_widgets/utils/error_dialog/error_dialog_registry.py @@ -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() diff --git a/bec_widgets/utils/error_dialog/qml/ErrorDialog.qml b/bec_widgets/utils/error_dialog/qml/ErrorDialog.qml new file mode 100644 index 00000000..fd73ac5a --- /dev/null +++ b/bec_widgets/utils/error_dialog/qml/ErrorDialog.qml @@ -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 = ["" + root.cur.title + ""] + if (root.cur.source) + parts.push("in " + root.cur.source + "") + parts.push(root.cur.time.substring(11)) + if (root.cur.count > 1) + parts.push("" + root.cur.count + + "× since " + root.cur.first_time.substring(11) + "") + 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 "" + r.type + "" + (first ? ": " + + first.replace(/&/g, "&").replace(/" : "") + } + } + } + } + } + + 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 : [] + } + } + } + } + } +} diff --git a/bec_widgets/utils/error_popups.py b/bec_widgets/utils/error_popups.py index 993e2f36..9690994b 100644 --- a/bec_widgets/utils/error_popups.py +++ b/bec_widgets/utils/error_popups.py @@ -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 diff --git a/tests/unit_tests/test_error_dialog.py b/tests/unit_tests/test_error_dialog.py new file mode 100644 index 00000000..7f5b0789 --- /dev/null +++ b/tests/unit_tests/test_error_dialog.py @@ -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"), + ("", "library", ""), + ], +) +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)