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:
Claude committed 2026-10-09 21:52:30 +00:00
1 parent bfeb30901f
commit be6e546e0e
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(" ", "&nbsp;")
text = escape(row["text"]).replace(" ", "&nbsp;") or "&nbsp;"
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("&nbsp;&nbsp;·&nbsp;&nbsp;".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 = "&nbsp;&nbsp;·&nbsp;&nbsp;"
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, "&amp;").replace(/</g, "&lt;") + "</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 : []
}
}
}
}
}
}
+42 -7
View File
@@ -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
+247
View File
@@ -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)