feat(forms): unified pydantic and scan control adapter for pydantic models

This commit is contained in:
2026-06-10 09:38:53 +02:00
parent bc99cfd2e8
commit f9cbeda30c
9 changed files with 773 additions and 426 deletions
@@ -0,0 +1,49 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from pydantic import BaseModel
from pydantic_core import PydanticUndefined
from bec_widgets.utils.scan_arg_metadata import ui_config_from_metadata
NUMERIC_BOUND_KEYS = {"gt", "ge", "lt", "le"}
def pydantic_model_input_configs(model: type[BaseModel]) -> list[dict[str, Any]]:
"""Return scan-control-style field items for a Pydantic model."""
configs = []
for name, info in model.model_fields.items():
metadata: dict[str, Any] = {}
for entry in info.metadata:
for key in NUMERIC_BOUND_KEYS:
value = getattr(entry, key, None)
if value is not None:
metadata.setdefault(key, value)
if isinstance(info.json_schema_extra, Mapping):
metadata.update(dict(info.json_schema_extra))
if info.description and metadata.get("description") is None:
metadata["description"] = info.description
default: Any
if info.default is not PydanticUndefined:
default = info.default
elif info.default_factory is not None:
default = info.get_default(call_default_factory=True)
else:
default = None
display_name = metadata.get("display_name") or info.title
if display_name is None:
display_name = name.replace("_", " ").capitalize()
item = ui_config_from_metadata(
name=name, metadata=metadata, default=default, display_name=display_name
)
item.update({key: value for key, value in metadata.items() if key not in item})
configs.append(item)
return configs
@@ -1,12 +1,11 @@
from __future__ import annotations
from types import NoneType, UnionType
from typing import Any, Literal, Union, get_args, get_origin
from types import NoneType
from typing import Any, Literal, get_args, get_origin
from bec_lib.device import DeviceBase, Signal
from pydantic import BaseModel, ValidationError
from pydantic.fields import FieldInfo
from pydantic_core import PydanticUndefined
from qtpy.QtCore import Qt
from qtpy.QtCore import Signal as QtSignal
from qtpy.QtWidgets import (
@@ -20,6 +19,16 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.utils.forms_from_types.pydantic_model_info_adapter import (
NUMERIC_BOUND_KEYS,
pydantic_model_input_configs,
)
from bec_widgets.utils.scan_arg_metadata import (
apply_numeric_limits,
apply_numeric_precision,
apply_unit_metadata,
device_units,
)
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
@@ -96,6 +105,7 @@ class PydanticWidgetForm(QWidget):
self._client = client
self._read_only_fields = set(read_only_fields or set())
self._widgets: dict[str, QWidget] = {}
self._field_configs: dict[str, dict[str, Any]] = {}
self._baseline: dict[str, Any] = {}
self._layout = QFormLayout()
@@ -138,7 +148,7 @@ class PydanticWidgetForm(QWidget):
def set_model(self, model: type[BaseModel], data: dict[str, Any] | None = None) -> None:
old_data = self.raw_data()
self._clear()
self.cleanup()
self._model = model
self._populate()
if data is None:
@@ -155,6 +165,7 @@ class PydanticWidgetForm(QWidget):
if name not in self._widgets:
continue
self._set_widget_value(name, value)
self._refresh_reference_units()
self.changed.emit()
def raw_data(self) -> dict[str, Any]:
@@ -181,7 +192,15 @@ class PydanticWidgetForm(QWidget):
fields = set(current) | set(self._baseline)
dirty = set()
for field in fields:
if self._values_differ(current.get(field), self._baseline.get(field)):
current_value = current.get(field)
baseline_value = self._baseline.get(field)
if current_value is None or baseline_value is None:
changed = current_value is not None or baseline_value is not None
elif isinstance(current_value, float) or isinstance(baseline_value, float):
changed = abs(float(current_value) - float(baseline_value)) >= 1e-9
else:
changed = current_value != baseline_value
if changed:
dirty.add(field)
return dirty
@@ -206,62 +225,77 @@ class PydanticWidgetForm(QWidget):
}
def cleanup(self) -> None:
self._clear(delete_later=False)
while self._layout.count():
item = self._layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.close()
widget.deleteLater()
self._widgets.clear()
self._field_configs.clear()
def closeEvent(self, event) -> None: # noqa: N802
self.cleanup()
super().closeEvent(event)
def _populate(self) -> None:
for name, info in self._model.model_fields.items():
for config in pydantic_model_input_configs(self._model):
name = config["name"]
info = self._model.model_fields[name]
widget = self._create_widget(name, info)
label_text = info.title or self._format_label(name)
label_text = config["display_name"]
self._layout.addRow(label_text, widget)
label = self._layout.labelForField(widget)
if label is not None:
label.setProperty("_model_field_name", name)
if info.description:
widget.setToolTip(info.description)
if label is not None:
label.setToolTip(info.description)
if config.get("tooltip") and label is not None:
label.setToolTip(config["tooltip"])
widget.setEnabled(name not in self._read_only_fields)
self._widgets[name] = widget
self._set_widget_value(name, self._field_default(info))
self._connect_widget(name, widget)
self._field_configs[name] = config
self._set_widget_value(name, config["default"])
self._apply_field_metadata(name)
self._connect_widget(widget)
self._connect_device_signal_widgets()
def _clear(self, *, delete_later: bool = True) -> None:
while self._layout.count():
item = self._layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.close()
if delete_later:
widget.deleteLater()
self._widgets.clear()
self._connect_reference_unit_widgets()
self._refresh_reference_units()
def _create_widget(self, name: str, info: FieldInfo) -> QWidget:
annotation = info.annotation
optional = self._is_optional(annotation)
value_annotation = self._without_none(annotation)
args = get_args(annotation)
optional = NoneType in args
non_none_args = tuple(arg for arg in args if arg is not NoneType)
value_annotation = non_none_args[0] if len(non_none_args) == 1 else annotation
widget = self._create_value_widget(name, value_annotation, info)
if optional and (self._is_numeric_annotation(value_annotation) or value_annotation is bool):
widget = self._create_value_widget(name, value_annotation)
numeric = value_annotation in (int, float) or any(
arg in (int, float) for arg in get_args(value_annotation)
)
if optional and (numeric or value_annotation is bool):
return OptionalValueWidget(widget, parent=self)
return widget
def _create_value_widget(self, name: str, annotation: Any, info: FieldInfo) -> QWidget:
if self._contains_type(annotation, Signal):
def _create_value_widget(self, name: str, annotation: Any) -> QWidget:
args = get_args(annotation)
if (
isinstance(annotation, type)
and issubclass(annotation, Signal)
or any(isinstance(arg, type) and issubclass(arg, Signal) for arg in args)
):
return SignalComboBox(
parent=self,
client=self._client,
require_device=self._model_has_device_field(),
arg_name=name,
)
if self._contains_type(annotation, DeviceBase):
if (
isinstance(annotation, type)
and issubclass(annotation, DeviceBase)
or any(isinstance(arg, type) and issubclass(arg, DeviceBase) for arg in args)
):
return DeviceComboBox(parent=self, client=self._client, arg_name=name)
if self._is_literal(annotation):
if get_origin(annotation) is Literal:
widget = QComboBox(self)
widget.addItems([str(value) for value in get_args(annotation)])
return widget
@@ -274,11 +308,24 @@ class PydanticWidgetForm(QWidget):
if annotation is float:
spin_box = BECSpinBox(self)
spin_box.setRange(-1_000_000_000, 1_000_000_000)
spin_box.setDecimals(int((info.json_schema_extra or {}).get("precision", 6)))
return spin_box
return QLineEdit(self)
def _connect_widget(self, _name: str, widget: QWidget) -> None:
def _apply_field_metadata(self, name: str) -> None:
config = self._field_configs[name]
field_widget = self._widgets[name]
input_widget = self.input_widget(name)
if config.get("precision") is not None:
apply_numeric_precision(input_widget, config)
if any(config.get(key) is not None for key in NUMERIC_BOUND_KEYS):
apply_numeric_limits(input_widget, config)
apply_unit_metadata(field_widget, config)
if input_widget is not field_widget:
apply_unit_metadata(input_widget, config)
def _connect_widget(self, widget: QWidget) -> None:
if isinstance(widget, OptionalValueWidget):
widget.value_changed.connect(lambda _value: self.changed.emit())
return
@@ -300,6 +347,47 @@ class PydanticWidgetForm(QWidget):
if device_widget.currentText().strip():
signal_widget.set_device(device_widget.currentText().strip())
def _connect_reference_unit_widgets(self) -> None:
for name, widget in self.input_widgets().items():
if not isinstance(widget, DeviceComboBox):
continue
widget.device_selected.connect(
lambda _device_name, field_name=name: self._update_reference_units(field_name)
)
widget.device_reset.connect(
lambda field_name=name: self._apply_reference_units(field_name, None)
)
widget.currentTextChanged.connect(
lambda text, field_name=name: self._handle_reference_device_text(field_name, text)
)
def _refresh_reference_units(self) -> None:
for name, widget in self.input_widgets().items():
if isinstance(widget, DeviceComboBox):
self._update_reference_units(name)
def _update_reference_units(self, source_name: str) -> None:
widget = self.input_widget(source_name)
if not isinstance(widget, DeviceComboBox) or not widget.is_valid_input:
self._apply_reference_units(source_name, None)
return
self._apply_reference_units(source_name, device_units(widget.get_current_device()))
def _apply_reference_units(self, source_name: str, units: str | None) -> None:
for field_name, config in self._field_configs.items():
if config.get("reference_units") != source_name:
continue
field_widget = self.field_widget(field_name)
input_widget = self.input_widget(field_name)
apply_unit_metadata(field_widget, config, units)
if input_widget is not field_widget:
apply_unit_metadata(input_widget, config, units)
def _handle_reference_device_text(self, source_name: str, device_name: str) -> None:
widget = self.input_widget(source_name)
if isinstance(widget, DeviceComboBox) and not widget.validate_device(device_name):
self._apply_reference_units(source_name, None)
def _validate_domain_widgets(self) -> None:
for widget in self._widgets.values():
if isinstance(widget, DeviceComboBox):
@@ -320,8 +408,8 @@ class PydanticWidgetForm(QWidget):
return widget.value()
if isinstance(widget, QLineEdit):
value = WidgetIO.get_value(widget)
return None if self._is_optional(info.annotation) and value == "" else value
if isinstance(widget, QComboBox) and self._is_literal(self._without_none(info.annotation)):
return None if NoneType in get_args(info.annotation) and value == "" else value
if isinstance(widget, QComboBox) and get_origin(info.annotation) is Literal:
return WidgetIO.get_value(widget, as_string=True)
return WidgetIO.get_value(widget)
@@ -339,91 +427,45 @@ class PydanticWidgetForm(QWidget):
value = 0
WidgetIO.set_value(widget, value)
@staticmethod
def _values_differ(current: Any, baseline: Any) -> bool:
if current is None or baseline is None:
return current is not None or baseline is not None
if isinstance(current, float) or isinstance(baseline, float):
return abs(float(current) - float(baseline)) >= 1e-9
return current != baseline
@staticmethod
def _field_default(info: FieldInfo) -> Any:
if info.default is not PydanticUndefined:
return info.default
if info.default_factory is not None:
return info.get_default(call_default_factory=True)
return None
@staticmethod
def _format_label(name: str) -> str:
return name.replace("_", " ").capitalize()
@staticmethod
def _is_literal(annotation: Any) -> bool:
return get_origin(annotation) is Literal
@classmethod
def _is_optional(cls, annotation: Any) -> bool:
return NoneType in cls._annotation_args(annotation)
@classmethod
def _without_none(cls, annotation: Any) -> Any:
args = [arg for arg in cls._annotation_args(annotation) if arg is not NoneType]
if not args:
return annotation
if len(args) == 1:
return args[0]
return annotation
@classmethod
def _annotation_args(cls, annotation: Any) -> tuple[Any, ...]:
origin = get_origin(annotation)
if origin in (Union, UnionType) or isinstance(annotation, UnionType):
return get_args(annotation)
return ()
@classmethod
def _contains_type(cls, annotation: Any, expected: type) -> bool:
if isinstance(annotation, type):
return issubclass(annotation, expected)
return any(
isinstance(arg, type) and issubclass(arg, expected)
for arg in cls._annotation_args(annotation)
)
def _model_has_device_field(self) -> bool:
return any(
self._is_device_annotation(field.annotation)
for field in self._model.model_fields.values()
)
@classmethod
def _is_device_annotation(cls, annotation: Any) -> bool:
return cls._contains_type(annotation, DeviceBase) and not cls._contains_type(
annotation, Signal
)
@classmethod
def _is_numeric_annotation(cls, annotation: Any) -> bool:
if annotation in (int, float):
return True
return any(arg in (int, float) for arg in cls._annotation_args(annotation))
for field in self._model.model_fields.values():
annotation = field.annotation
args = get_args(annotation)
has_device = (
isinstance(annotation, type)
and issubclass(annotation, DeviceBase)
or any(isinstance(arg, type) and issubclass(arg, DeviceBase) for arg in args)
)
has_signal = (
isinstance(annotation, type)
and issubclass(annotation, Signal)
or any(isinstance(arg, type) and issubclass(arg, Signal) for arg in args)
)
if has_device and not has_signal:
return True
return False
if __name__ == "__main__": # pragma: no cover
import json
import sys
from bec_lib.scan_args import ScanArgument
from pydantic import Field
from qtpy.QtWidgets import QApplication, QLabel, QPushButton, QTabWidget, QTextEdit, QVBoxLayout
from bec_widgets.utils.colors import apply_theme
class BasicScanConfig(BaseModel):
"""Plain Pydantic fields without GUI metadata."""
sample_name: str
enabled: bool = True
repeats: int = 3
class LimitConfig(BaseModel):
"""Normal Pydantic Field metadata."""
mode: Literal["monitor", "scan", "calibration"] = "scan"
low_limit: (
float | None
@@ -441,6 +483,58 @@ if __name__ == "__main__": # pragma: no cover
json_schema_extra={"precision": 4},
)
class ScanArgumentConfig(BaseModel):
"""ScanArgument metadata applied through Field extras."""
settling_time: float = Field(
default=0.0,
**ScanArgument(
display_name="Settling time",
description="Time to wait after moving.",
units="s",
precision=3,
ge=0,
).model_dump(),
)
frames: int = Field(
default=1,
**ScanArgument(
display_name="Frames", description="Number of frames per trigger.", ge=1
).model_dump(),
)
class DeviceSignalLimitsConfig(BaseModel):
"""Device, signal, and numeric fields whose units follow the selected device."""
model_config = {"arbitrary_types_allowed": True}
device: DeviceBase | str = Field(
default="",
**ScanArgument(display_name="Device", description="Positioner device.").model_dump(),
)
signal: Signal | str | None = Field(
default=None,
**ScanArgument(display_name="Signal", description="Device signal.").model_dump(),
)
low_limit: float | None = Field(
default=None,
**ScanArgument(
display_name="Low limit",
description="Optional lower limit.",
reference_units="device",
precision=4,
).model_dump(),
)
high_limit: float | None = Field(
default=None,
**ScanArgument(
display_name="High limit",
description="Optional upper limit.",
reference_units="device",
precision=4,
).model_dump(),
)
class DisplayConfig(BaseModel):
title: str | None = Field(
default=None, title="Title", description="Optional display title."
@@ -500,7 +594,6 @@ if __name__ == "__main__": # pragma: no cover
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("PydanticWidgetForm example")
self.resize(720, 520)
self._tabs = QTabWidget(self)
self._output = QTextEdit(self)
@@ -510,8 +603,10 @@ if __name__ == "__main__": # pragma: no cover
self._add_form("Basic", PydanticWidgetForm(BasicScanConfig))
self._add_form("Limits", PydanticWidgetForm(LimitConfig))
self._add_form("ScanArgument", PydanticWidgetForm(ScanArgumentConfig))
self._add_form("Display", PydanticWidgetForm(DisplayConfig))
self._add_form("Device + signal", PydanticWidgetForm(DeviceAndSignalConfig))
self._add_form("Device limits", PydanticWidgetForm(DeviceSignalLimitsConfig))
self._add_form("Device only", PydanticWidgetForm(DeviceOnlyConfig))
self._add_form("Signal only", PydanticWidgetForm(SignalOnlyConfig))
@@ -552,6 +647,7 @@ if __name__ == "__main__": # pragma: no cover
self._show_current_data(validate=False)
app = QApplication(sys.argv)
apply_theme("dark")
window = ExampleWindow()
window.show()
sys.exit(app.exec())
+155
View File
@@ -0,0 +1,155 @@
from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any
from bec_lib import bec_logger
from qtpy.QtWidgets import QDoubleSpinBox, QSpinBox, QWidget
logger = bec_logger.logger
UNIT_TOOLTIP_PREFIXES = ("Units:", "Units from:")
def format_display_name(name: str) -> str:
"""Convert a raw argument name into a user-facing label."""
parts = re.split(r"(_|\d+)", name)
return " ".join(part.capitalize() for part in parts if part.isalnum()).strip()
def resolve_tooltip(scan_argument: Mapping[str, Any]) -> str | None:
"""Resolve explicit tooltip text, falling back to the description."""
return scan_argument.get("tooltip") or scan_argument.get("description")
def ui_config_from_metadata(
name: str,
metadata: Mapping[str, Any],
*,
default: Any = None,
input_type: Any = None,
arg: bool = False,
display_name: str | None = None,
) -> dict[str, Any]:
"""Build the normalized scan-input item consumed by form widgets."""
return {
"arg": arg,
"name": name,
"type": input_type,
"display_name": display_name or metadata.get("display_name") or format_display_name(name),
"tooltip": resolve_tooltip(metadata),
"default": default,
"expert": metadata.get("expert", False),
"hidden": metadata.get("hidden", False),
"precision": metadata.get("precision"),
"units": metadata.get("units"),
"reference_units": metadata.get("reference_units"),
"reference_limits": metadata.get("reference_limits"),
"gt": metadata.get("gt"),
"ge": metadata.get("ge"),
"lt": metadata.get("lt"),
"le": metadata.get("le"),
"alternative_group": metadata.get("alternative_group"),
}
def unit_tooltip(item: Mapping[str, Any], units: str | None = None) -> str | None:
"""Build tooltip text from scan argument unit metadata."""
tooltip = item.get("tooltip")
reference_units = item.get("reference_units")
units = units or item.get("units")
tooltip_parts = [tooltip] if tooltip else []
if units:
tooltip_parts.append(f"Units: {units}")
elif reference_units:
tooltip_parts.append(f"Units from: {reference_units}")
if tooltip_parts:
return "\n".join(str(part) for part in tooltip_parts)
return None
def strip_unit_tooltip(tooltip: str) -> str:
"""Remove unit lines added by :func:`apply_unit_metadata`."""
return "\n".join(
line for line in tooltip.splitlines() if not line.startswith(UNIT_TOOLTIP_PREFIXES)
).strip()
def apply_unit_metadata(widget: QWidget, item: Mapping[str, Any], units: str | None = None) -> None:
"""Apply unit tooltip text and numeric suffix metadata to a widget."""
units = units or item.get("units")
tooltip = unit_tooltip(item, units)
existing_tooltip = strip_unit_tooltip(widget.toolTip())
base_tooltip = item.get("tooltip")
if base_tooltip and existing_tooltip == base_tooltip:
existing_tooltip = ""
if tooltip:
widget.setToolTip(f"{existing_tooltip}\n{tooltip}" if existing_tooltip else tooltip)
else:
widget.setToolTip(existing_tooltip)
if hasattr(widget, "setSuffix"):
widget.setSuffix(f" {units}" if units else "")
def device_units(device: object) -> str | None:
"""Return engineering units from a BEC device object when available."""
egu = getattr(device, "egu", None)
if not callable(egu):
return None
try:
return egu()
except Exception:
logger.exception("Failed to fetch engineering units from device %s", device)
return None
def apply_numeric_precision(widget: QWidget, item: Mapping[str, Any]) -> None:
"""Apply decimal precision metadata to spinboxes supporting ``setDecimals``."""
if not hasattr(widget, "setDecimals"):
return
precision = item.get("precision")
if precision is None:
return
try:
widget.setDecimals(max(0, int(precision)))
except (TypeError, ValueError):
logger.warning(
"Ignoring invalid precision %r for parameter %s", precision, item.get("name")
)
def apply_numeric_limits(widget: QWidget, item: Mapping[str, Any]) -> None:
"""Apply ``gt/ge/lt/le`` numeric bounds to Qt spinboxes."""
if isinstance(widget, QSpinBox) and not isinstance(widget, QDoubleSpinBox):
minimum = -2147483647
maximum = 2147483647
if item.get("ge") is not None:
minimum = int(item["ge"])
if item.get("gt") is not None:
minimum = int(item["gt"]) + 1
if item.get("le") is not None:
maximum = int(item["le"])
if item.get("lt") is not None:
maximum = int(item["lt"]) - 1
widget.setRange(minimum, maximum)
return
if isinstance(widget, QDoubleSpinBox):
minimum = -float("inf")
maximum = float("inf")
step = 10 ** (-widget.decimals())
if item.get("ge") is not None:
minimum = float(item["ge"])
if item.get("gt") is not None:
minimum = float(item["gt"]) + step
if item.get("le") is not None:
maximum = float(item["le"])
if item.get("lt") is not None:
maximum = float(item["lt"]) - step
widget.setRange(minimum, maximum)
@@ -20,6 +20,12 @@ from qtpy.QtWidgets import (
QVBoxLayout,
)
from bec_widgets.utils.scan_arg_metadata import (
apply_numeric_limits,
apply_numeric_precision,
apply_unit_metadata,
device_units,
)
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
BECDeviceFilter,
@@ -285,8 +291,8 @@ class ScanGroupBox(QGroupBox):
)
else:
widget = widget_class(parent=self.parent(), arg_name=arg_name, default=default)
self._apply_numeric_precision(widget, item)
self._apply_numeric_limits(widget, item)
apply_numeric_precision(widget, item)
apply_numeric_limits(widget, item)
if isinstance(widget, DeviceComboBox):
self.selected_devices[widget] = ""
widget.device_selected.connect(self.emit_device_selected)
@@ -298,7 +304,7 @@ class ScanGroupBox(QGroupBox):
if isinstance(widget, ScanLiteralsComboBox):
widget.set_literals(item["type"].get("Literal", []))
self._widget_configs[widget] = item
self._apply_unit_metadata(widget, item)
apply_unit_metadata(widget, item)
self.layout.addWidget(widget, row, column_index)
self.widgets.append(widget)
@@ -307,7 +313,7 @@ class ScanGroupBox(QGroupBox):
sender = self.sender()
self.selected_devices[sender] = device_name.strip()
if isinstance(sender, DeviceComboBox):
units = self._device_units(sender.get_current_device())
units = device_units(sender.get_current_device())
self._update_reference_units(sender, units)
self._emit_reference_units_changed(sender, units)
selected_devices_str = " ".join(self.selected_devices.values())
@@ -453,57 +459,11 @@ class ScanGroupBox(QGroupBox):
WidgetIO.set_value(widget, value)
break
@staticmethod
def _unit_tooltip(item: dict, units: str | None = None) -> str | None:
tooltip = item.get("tooltip", None)
reference_units = item.get("reference_units", None)
units = units or item.get("units", None)
tooltip_parts = [tooltip] if tooltip else []
if units:
tooltip_parts.append(f"Units: {units}")
elif reference_units:
tooltip_parts.append(f"Units from: {reference_units}")
if tooltip_parts:
return "\n".join(tooltip_parts)
return None
def _apply_unit_metadata(self, widget, item: dict, units: str | None = None) -> None:
units = units or item.get("units", None)
tooltip = self._unit_tooltip(item, units)
existing_tooltip = widget.toolTip()
if existing_tooltip:
# strip the existing unit info from the tooltip if it exists
# to avoid tooltip bloat on multiple updates
existing_tooltip = "\n".join(
line
for line in existing_tooltip.splitlines()
if not (line.startswith("Units:") or line.startswith("Units from:"))
).strip()
if tooltip:
if existing_tooltip:
widget.setToolTip(f"{existing_tooltip}\n{tooltip}")
else:
widget.setToolTip(tooltip)
if hasattr(widget, "setSuffix"):
widget.setSuffix(f" {units}" if units else "")
def _refresh_column_label(self, column: int, item: dict) -> None:
if column not in self._column_labels:
return
self._column_labels[column].setText(item.get("display_name", item.get("name", None)))
@staticmethod
def _device_units(device) -> str | None:
egu = getattr(device, "egu", None)
if not callable(egu):
return None
try:
return egu()
except Exception:
logger.exception("Failed to fetch engineering units from device %s", device)
return None
def _widget_position(self, widget) -> tuple[int, int] | None:
for row in range(self.layout.rowCount()):
for column in range(self.layout.columnCount()):
@@ -529,7 +489,7 @@ class ScanGroupBox(QGroupBox):
row, column = widget_position
if self.box_type == "args" and row != source_row:
continue
self._apply_unit_metadata(widget, item, units)
apply_unit_metadata(widget, item, units)
self._refresh_column_label(column, item)
def apply_reference_units(self, reference_name: str, units: str | None) -> None:
@@ -543,7 +503,7 @@ class ScanGroupBox(QGroupBox):
item = self._widget_configs.get(widget, {})
if item.get("reference_units") != reference_name:
continue
self._apply_unit_metadata(widget, item, units)
apply_unit_metadata(widget, item, units)
position = self._widget_position(widget)
if position is not None:
_, column = position
@@ -562,49 +522,3 @@ class ScanGroupBox(QGroupBox):
self.selected_devices[device_widget] = ""
self._update_reference_units(device_widget, None)
self._emit_reference_units_changed(device_widget, None)
@staticmethod
def _apply_numeric_precision(widget: ScanDoubleSpinBox, item: dict) -> None:
if not isinstance(widget, ScanDoubleSpinBox):
return
precision = item.get("precision")
if precision is None:
return
try:
widget.setDecimals(max(0, int(precision)))
except (TypeError, ValueError):
logger.warning(
"Ignoring invalid precision %r for parameter %s", precision, item.get("name")
)
@staticmethod
def _apply_numeric_limits(widget: ScanDoubleSpinBox | ScanSpinBox, item: dict) -> None:
if isinstance(widget, ScanSpinBox):
minimum = -2147483647 # largest int which qt allows
maximum = 2147483647
if item.get("ge") is not None:
minimum = int(item["ge"])
if item.get("gt") is not None:
minimum = int(item["gt"]) + 1
if item.get("le") is not None:
maximum = int(item["le"])
if item.get("lt") is not None:
maximum = int(item["lt"]) - 1
widget.setRange(minimum, maximum)
return
if isinstance(widget, ScanDoubleSpinBox):
minimum = -float("inf")
maximum = float("inf")
step = 10 ** (-widget.decimals())
if item.get("ge") is not None:
minimum = float(item["ge"])
if item.get("gt") is not None:
minimum = float(item["gt"]) + step
if item.get("le") is not None:
maximum = float(item["le"])
if item.get("lt") is not None:
maximum = float(item["lt"]) - step
widget.setRange(minimum, maximum)
@@ -2,9 +2,12 @@
from __future__ import annotations
import re
from typing import Any
from bec_widgets.utils.scan_arg_metadata import format_display_name as format_scan_display_name
from bec_widgets.utils.scan_arg_metadata import resolve_tooltip as resolve_scan_tooltip
from bec_widgets.utils.scan_arg_metadata import ui_config_from_metadata
AnnotationValue = str | dict[str, Any] | list[Any] | None
ScanArgumentMetadata = dict[str, Any]
SignatureEntry = dict[str, Any]
@@ -74,8 +77,7 @@ class ScanInfoAdapter:
Returns:
str: Formatted display label such as ``Exp Time``.
"""
parts = re.split(r"(_|\d+)", name)
return " ".join(part.capitalize() for part in parts if part.isalnum()).strip()
return format_scan_display_name(name)
@staticmethod
def resolve_tooltip(scan_argument: ScanArgumentMetadata) -> str | None:
@@ -87,7 +89,7 @@ class ScanInfoAdapter:
Returns:
str | None: Explicit tooltip text if provided, otherwise the description fallback.
"""
return scan_argument.get("tooltip") or scan_argument.get("description")
return resolve_scan_tooltip(scan_argument)
@staticmethod
def parse_annotation(
@@ -204,24 +206,13 @@ class ScanInfoAdapter:
Returns:
ScanInputConfig: Normalized input configuration.
"""
return {
"arg": arg,
"name": name,
"type": self.scan_arg_type_from_annotation(annotation),
"display_name": scan_argument.get("display_name") or self.format_display_name(name),
"tooltip": self.resolve_tooltip(scan_argument),
"default": default,
"expert": scan_argument.get("expert", False),
"hidden": scan_argument.get("hidden", False),
"precision": scan_argument.get("precision"),
"units": scan_argument.get("units"),
"reference_units": scan_argument.get("reference_units"),
"gt": scan_argument.get("gt"),
"ge": scan_argument.get("ge"),
"lt": scan_argument.get("lt"),
"le": scan_argument.get("le"),
"alternative_group": scan_argument.get("alternative_group"),
}
return ui_config_from_metadata(
name=name,
metadata=scan_argument,
input_type=self.scan_arg_type_from_annotation(annotation),
default=default,
arg=arg,
)
def build_scan_ui_config(self, scan_info: ScanInfo) -> ScanUIConfig:
"""Normalize one available-scan entry into the widget UI configuration.
@@ -1236,6 +1236,9 @@ class BeamlineStateManager(BECWidget, QWidget):
state_type = getattr(state, "state_type", None)
if state_type is not None:
state_dict.setdefault("state_type", state_type)
title = getattr(state, "title", None)
if title is not None and not state_dict.get("title"):
state_dict["title"] = title
return state_dict
return {"name": getattr(state, "name", None), "title": getattr(state, "title", None)}
+191 -200
View File
@@ -1,6 +1,3 @@
from typing import Any, Generator
import pytest
import shiboken6
from bec_lib import bl_states
from qtpy.QtCore import QCoreApplication, QEvent, Qt
@@ -16,17 +13,13 @@ from bec_widgets.widgets.services.beamline_states.beamline_state_pill import (
from bec_widgets.widgets.services.beamline_states.dialogs import AddBeamlineStateDialog
from .client_mocks import mocked_client
from .conftest import create_widget
@pytest.fixture
def pill(qtbot, mocked_client) -> Generator[BeamlineStatePill, Any, None]:
widget = BeamlineStatePill(state_name="shutter_open", title="Shutter", client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_beamline_state_pill_updates_from_message(pill):
def test_beamline_state_pill_updates_from_message(qtbot, mocked_client):
pill = create_widget(
qtbot, BeamlineStatePill, state_name="shutter_open", title="Shutter", client=mocked_client
)
pill.update_state({"name": "shutter_open", "status": "valid", "label": "Shutter is open."}, {})
assert pill._state_name == "shutter_open"
@@ -37,7 +30,10 @@ def test_beamline_state_pill_updates_from_message(pill):
assert pill.toolTip() == "Shutter is open."
def test_beamline_state_pill_ignores_other_states(pill):
def test_beamline_state_pill_ignores_other_states(qtbot, mocked_client):
pill = create_widget(
qtbot, BeamlineStatePill, state_name="shutter_open", title="Shutter", client=mocked_client
)
pill.update_state(
{"name": "other_state", "status": "invalid", "label": "Should be ignored."}, {}
)
@@ -47,10 +43,10 @@ def test_beamline_state_pill_ignores_other_states(pill):
def test_beamline_state_pill_expands_and_emits_updated_limits(qtbot, mocked_client):
widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
widget.set_state_config(
limits_pill = create_widget(
qtbot, BeamlineStatePill, state_name="limits", title="Limits", client=mocked_client
)
limits_pill.set_state_config(
{
"name": "limits",
"title": "Limits",
@@ -67,25 +63,27 @@ def test_beamline_state_pill_expands_and_emits_updated_limits(qtbot, mocked_clie
}
)
assert widget._settings.isHidden()
assert widget._config_form is None
assert not widget._update_button.isEnabled()
assert not widget._revert_button.isEnabled()
assert limits_pill._settings.isHidden()
assert limits_pill._config_form is None
assert not limits_pill._update_button.isEnabled()
assert not limits_pill._revert_button.isEnabled()
qtbot.mouseClick(widget._header, Qt.MouseButton.LeftButton)
assert widget._config_form is not None
high_limit = widget._config_form.input_widget("high_limit")
qtbot.mouseClick(limits_pill._header, Qt.MouseButton.LeftButton)
assert limits_pill._config_form is not None
high_limit = limits_pill._config_form.input_widget("high_limit")
high_limit.setValue(20.0)
assert not widget._settings.isHidden()
assert widget._update_button.isEnabled()
assert widget._revert_button.isEnabled()
assert widget._config_form.field_widget("high_limit").property("beamlineStateDirty") is True
assert widget._config_form.get_data()["device"] == "samx"
assert widget.edited_config().high_limit == 20.0
assert not limits_pill._settings.isHidden()
assert limits_pill._update_button.isEnabled()
assert limits_pill._revert_button.isEnabled()
assert (
limits_pill._config_form.field_widget("high_limit").property("beamlineStateDirty") is True
)
assert limits_pill._config_form.get_data()["device"] == "samx"
assert limits_pill.edited_config().high_limit == 20.0
with qtbot.waitSignal(widget.update_requested) as signal:
widget._update_button.click()
with qtbot.waitSignal(limits_pill.update_requested) as signal:
limits_pill._update_button.click()
assert signal.args[0] == "limits"
assert isinstance(signal.args[1], bl_states.DeviceWithinLimitsState.CONFIG_CLASS)
@@ -94,12 +92,15 @@ def test_beamline_state_pill_expands_and_emits_updated_limits(qtbot, mocked_clie
assert signal.args[1].low_limit == 0.0
assert signal.args[1].high_limit == 20.0
assert signal.args[1].tolerance == 0.1
assert not widget._settings.isHidden()
assert not limits_pill._settings.isHidden()
def test_beamline_state_pill_first_expand_uses_config_class_without_rebuild(
qtbot, mocked_client, monkeypatch
):
limits_pill = create_widget(
qtbot, BeamlineStatePill, state_name="limits", title="Limits", client=mocked_client
)
set_model_calls = []
original_set_model = pill_module.PydanticWidgetForm.set_model
@@ -108,9 +109,7 @@ def test_beamline_state_pill_first_expand_uses_config_class_without_rebuild(
return original_set_model(self, model, data=data)
monkeypatch.setattr(pill_module.PydanticWidgetForm, "set_model", set_model_spy)
widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client)
qtbot.addWidget(widget)
widget.set_state_config(
limits_pill.set_state_config(
{
"name": "limits",
"title": "Limits",
@@ -125,15 +124,16 @@ def test_beamline_state_pill_first_expand_uses_config_class_without_rebuild(
}
)
widget.set_expanded(True)
assert widget._config_form is not None
limits_pill.set_expanded(True)
assert limits_pill._config_form is not None
assert set_model_calls == []
def test_beamline_state_pill_reverts_changed_settings(qtbot, mocked_client):
widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client)
qtbot.addWidget(widget)
widget.set_state_config(
limits_pill = create_widget(
qtbot, BeamlineStatePill, state_name="limits", title="Limits", client=mocked_client
)
limits_pill.set_state_config(
{
"name": "limits",
"title": "Limits",
@@ -148,28 +148,31 @@ def test_beamline_state_pill_reverts_changed_settings(qtbot, mocked_client):
}
)
widget.set_expanded(True)
assert widget._config_form is not None
low_limit = widget._config_form.input_widget("low_limit")
limits_pill.set_expanded(True)
assert limits_pill._config_form is not None
low_limit = limits_pill._config_form.input_widget("low_limit")
low_limit.setValue(-5.0)
assert widget._update_button.isEnabled()
assert widget._config_form.field_widget("low_limit").property("beamlineStateDirty") is True
assert limits_pill._update_button.isEnabled()
assert limits_pill._config_form.field_widget("low_limit").property("beamlineStateDirty") is True
widget._revert_button.click()
limits_pill._revert_button.click()
assert low_limit.value() == 0.0
assert not widget._update_button.isEnabled()
assert not widget._revert_button.isEnabled()
assert widget._config_form.field_widget("low_limit").property("beamlineStateDirty") is False
assert not limits_pill._update_button.isEnabled()
assert not limits_pill._revert_button.isEnabled()
assert (
limits_pill._config_form.field_widget("low_limit").property("beamlineStateDirty") is False
)
def test_beamline_state_pill_does_not_override_themed_input_controls(qtbot, mocked_client):
widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client)
qtbot.addWidget(widget)
widget.set_expanded(True)
limits_pill = create_widget(
qtbot, BeamlineStatePill, state_name="limits", title="Limits", client=mocked_client
)
limits_pill.set_expanded(True)
stylesheet = widget.styleSheet()
stylesheet = limits_pill.styleSheet()
assert "QAbstractSpinBox" not in stylesheet
assert "QComboBox" not in stylesheet
@@ -177,15 +180,16 @@ def test_beamline_state_pill_does_not_override_themed_input_controls(qtbot, mock
def test_beamline_state_manager_adds_and_removes_pills(qtbot, mocked_client):
widget = BeamlineStateManager(client=mocked_client)
qtbot.addWidget(widget)
widget.update_available_states(
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states(
{
"states": [
messages.BeamlineStateConfig(
name="shutter_open", title="Shutter", state_type="ShutterState", parameters={}
),
{
"name": "shutter_open",
"title": "Shutter",
"state_type": "ShutterState",
"parameters": {},
},
{
"name": "limits",
"title": "Limits",
@@ -197,12 +201,12 @@ def test_beamline_state_manager_adds_and_removes_pills(qtbot, mocked_client):
{},
)
assert sorted(widget._state_pills) == ["limits", "shutter_open"]
assert widget._model.rowCount() == 2
assert widget._state_pills["shutter_open"]._name_label.text() == "Shutter"
assert not widget._empty_label.isVisible()
assert sorted(beamline_state_manager._state_pills) == ["limits", "shutter_open"]
assert beamline_state_manager._model.rowCount() == 2
assert beamline_state_manager._state_pills["shutter_open"]._name_label.text() == "Shutter"
assert not beamline_state_manager._empty_label.isVisible()
widget.update_available_states(
beamline_state_manager.update_available_states(
{
"states": [
{
@@ -216,13 +220,12 @@ def test_beamline_state_manager_adds_and_removes_pills(qtbot, mocked_client):
{},
)
assert sorted(widget._state_pills) == ["limits"]
assert widget._model.rowCount() == 1
assert sorted(beamline_state_manager._state_pills) == ["limits"]
assert beamline_state_manager._model.rowCount() == 1
def test_beamline_state_manager_ignores_unchanged_available_states(qtbot, mocked_client):
widget = BeamlineStateManager(client=mocked_client)
qtbot.addWidget(widget)
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
content = {
"states": [
{
@@ -240,18 +243,17 @@ def test_beamline_state_manager_ignores_unchanged_available_states(qtbot, mocked
]
}
widget.update_available_states(content, {})
pill = widget._state_pills["limits"]
beamline_state_manager.update_available_states(content, {})
pill = beamline_state_manager._state_pills["limits"]
widget.update_available_states(content, {})
beamline_state_manager.update_available_states(content, {})
assert widget._state_pills["limits"] is pill
assert beamline_state_manager._state_pills["limits"] is pill
assert pill._config_form is None
def test_beamline_state_manager_adds_state_without_recreating_existing_pills(qtbot, mocked_client):
widget = BeamlineStateManager(client=mocked_client)
qtbot.addWidget(widget)
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
limits_state = {
"name": "limits",
"title": "Limits",
@@ -271,24 +273,22 @@ def test_beamline_state_manager_adds_state_without_recreating_existing_pills(qtb
"parameters": {},
}
widget.update_available_states({"states": [limits_state]}, {})
pill = widget._state_pills["limits"]
beamline_state_manager.update_available_states({"states": [limits_state]}, {})
pill = beamline_state_manager._state_pills["limits"]
pill.set_expanded(True)
config_form = pill._config_form
widget.update_available_states({"states": [limits_state, shutter_state]}, {})
beamline_state_manager.update_available_states({"states": [limits_state, shutter_state]}, {})
assert widget._state_pills["limits"] is pill
assert beamline_state_manager._state_pills["limits"] is pill
assert pill._config_form is config_form
assert pill.is_expanded()
assert sorted(widget._state_pills) == ["limits", "shutter_open"]
assert sorted(beamline_state_manager._state_pills) == ["limits", "shutter_open"]
def test_beamline_state_manager_does_not_force_horizontal_minimum(qtbot, mocked_client):
widget = BeamlineStateManager(client=mocked_client)
qtbot.addWidget(widget)
widget.update_available_states(
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states(
{
"states": [
{
@@ -308,20 +308,19 @@ def test_beamline_state_manager_does_not_force_horizontal_minimum(qtbot, mocked_
{},
)
index = widget._model.index_for_name("limits")
hint = widget._delegate.sizeHint(QStyleOptionViewItem(), index)
index = beamline_state_manager._model.index_for_name("limits")
hint = beamline_state_manager._delegate.sizeHint(QStyleOptionViewItem(), index)
assert widget.minimumWidth() == 0
assert widget.minimumSizeHint().width() == 0
assert widget._view.minimumWidth() == 0
assert widget._view.minimumSizeHint().width() == 0
assert beamline_state_manager.minimumWidth() == 0
assert beamline_state_manager.minimumSizeHint().width() == 0
assert beamline_state_manager._view.minimumWidth() == 0
assert beamline_state_manager._view.minimumSizeHint().width() == 0
assert hint.width() == 0
def test_beamline_state_manager_header_click_expands_pill_once(qtbot, mocked_client):
widget = BeamlineStateManager(client=mocked_client)
qtbot.addWidget(widget)
widget.update_available_states(
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states(
{
"states": [
{
@@ -335,7 +334,7 @@ def test_beamline_state_manager_header_click_expands_pill_once(qtbot, mocked_cli
{},
)
pill = widget._state_pills["limits"]
pill = beamline_state_manager._state_pills["limits"]
assert pill._settings.isHidden()
qtbot.mouseClick(pill._header, Qt.MouseButton.LeftButton)
@@ -344,28 +343,27 @@ def test_beamline_state_manager_header_click_expands_pill_once(qtbot, mocked_cli
def test_beamline_state_manager_preserves_expanded_pill_on_refresh(qtbot, mocked_client):
widget = BeamlineStateManager(client=mocked_client)
qtbot.addWidget(widget)
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
state = {
"name": "limits",
"title": "Limits",
"state_type": "DeviceWithinLimitsState",
"parameters": {"device": "samx", "high_limit": 10.0},
}
widget.update_available_states({"states": [state]}, {})
beamline_state_manager.update_available_states({"states": [state]}, {})
widget._state_pills["limits"].set_expanded(True)
widget.update_available_states({"states": [state]}, {})
beamline_state_manager._state_pills["limits"].set_expanded(True)
beamline_state_manager.update_available_states({"states": [state]}, {})
assert widget._state_pills["limits"].is_expanded()
assert not widget._state_pills["limits"]._settings.isHidden()
assert beamline_state_manager._state_pills["limits"].is_expanded()
assert not beamline_state_manager._state_pills["limits"]._settings.isHidden()
def test_beamline_state_manager_propagates_idle_card_background(qtbot, mocked_client):
widget = BeamlineStateManager(client=mocked_client, idle_card_background=True)
qtbot.addWidget(widget)
widget.update_available_states(
idle_card_manager = create_widget(
qtbot, BeamlineStateManager, client=mocked_client, idle_card_background=True
)
idle_card_manager.update_available_states(
{
"states": [
{
@@ -379,18 +377,16 @@ def test_beamline_state_manager_propagates_idle_card_background(qtbot, mocked_cl
{},
)
assert widget._state_pills["limits"]._idle_card_background is True
assert idle_card_manager._state_pills["limits"]._idle_card_background is True
widget.idle_card_background = False
idle_card_manager.idle_card_background = False
assert widget._state_pills["limits"]._idle_card_background is False
assert idle_card_manager._state_pills["limits"]._idle_card_background is False
def test_beamline_state_manager_filters_status(qtbot, mocked_client):
widget = BeamlineStateManager(client=mocked_client)
qtbot.addWidget(widget)
widget.update_available_states(
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states(
{
"states": [
{
@@ -410,38 +406,44 @@ def test_beamline_state_manager_filters_status(qtbot, mocked_client):
{},
)
assert isinstance(widget._toolbar, ModularToolBar)
assert isinstance(beamline_state_manager._toolbar, ModularToolBar)
widget._state_pills["limits"].update_state(
beamline_state_manager._state_pills["limits"].update_state(
{"name": "limits", "status": "valid", "label": "Within limits."}, {}
)
widget._state_pills["shutter_open"].update_state(
beamline_state_manager._state_pills["shutter_open"].update_state(
{"name": "shutter_open", "status": "invalid", "label": "Closed."}, {}
)
widget._selected_statuses = {"valid"}
widget._apply_filters()
beamline_state_manager._selected_statuses = {"valid"}
beamline_state_manager._apply_filters()
assert not widget._hidden_summary.isHidden()
assert "1 state is hidden" in widget._hidden_summary.text()
assert not widget._view.isRowHidden(widget._model.index_for_name("limits").row())
assert widget._view.isRowHidden(widget._model.index_for_name("shutter_open").row())
assert not beamline_state_manager._hidden_summary.isHidden()
assert "1 state is hidden" in beamline_state_manager._hidden_summary.text()
assert not beamline_state_manager._view.isRowHidden(
beamline_state_manager._model.index_for_name("limits").row()
)
assert beamline_state_manager._view.isRowHidden(
beamline_state_manager._model.index_for_name("shutter_open").row()
)
widget._hidden_summary.click()
beamline_state_manager._hidden_summary.click()
assert not widget._view.isRowHidden(widget._model.index_for_name("shutter_open").row())
assert shiboken6.isValid(widget._state_pills["shutter_open"])
assert not beamline_state_manager._view.isRowHidden(
beamline_state_manager._model.index_for_name("shutter_open").row()
)
assert shiboken6.isValid(beamline_state_manager._state_pills["shutter_open"])
widget._hidden_summary.click()
beamline_state_manager._hidden_summary.click()
assert widget._view.isRowHidden(widget._model.index_for_name("shutter_open").row())
assert shiboken6.isValid(widget._state_pills["shutter_open"])
assert beamline_state_manager._view.isRowHidden(
beamline_state_manager._model.index_for_name("shutter_open").row()
)
assert shiboken6.isValid(beamline_state_manager._state_pills["shutter_open"])
def test_beamline_state_manager_status_filter_reacts_to_state_changes(qtbot, mocked_client):
widget = BeamlineStateManager(client=mocked_client)
qtbot.addWidget(widget)
widget.update_available_states(
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states(
{
"states": [
{
@@ -455,26 +457,26 @@ def test_beamline_state_manager_status_filter_reacts_to_state_changes(qtbot, moc
{},
)
widget._selected_statuses = {"valid"}
widget._state_pills["limits"].update_state(
beamline_state_manager._selected_statuses = {"valid"}
beamline_state_manager._state_pills["limits"].update_state(
{"name": "limits", "status": "valid", "label": "Within limits."}, {}
)
assert widget._hidden_summary.isHidden()
assert beamline_state_manager._hidden_summary.isHidden()
widget._state_pills["limits"].update_state(
beamline_state_manager._state_pills["limits"].update_state(
{"name": "limits", "status": "invalid", "label": "Out of limits."}, {}
)
assert not widget._hidden_summary.isHidden()
assert widget._view.isRowHidden(widget._model.index_for_name("limits").row())
assert not beamline_state_manager._hidden_summary.isHidden()
assert beamline_state_manager._view.isRowHidden(
beamline_state_manager._model.index_for_name("limits").row()
)
def test_beamline_state_manager_filters_devices(qtbot, mocked_client, monkeypatch):
widget = BeamlineStateManager(client=mocked_client)
qtbot.addWidget(widget)
widget.update_available_states(
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states(
{
"states": [
{
@@ -494,11 +496,11 @@ def test_beamline_state_manager_filters_devices(qtbot, mocked_client, monkeypatc
{},
)
widget._device_filter_text = "samx"
widget._apply_filters()
beamline_state_manager._device_filter_text = "samx"
beamline_state_manager._apply_filters()
assert not widget._hidden_summary.isHidden()
assert "1 state is hidden" in widget._hidden_summary.text()
assert not beamline_state_manager._hidden_summary.isHidden()
assert "1 state is hidden" in beamline_state_manager._hidden_summary.text()
captured = {}
@@ -514,17 +516,16 @@ def test_beamline_state_manager_filters_devices(qtbot, mocked_client, monkeypatc
monkeypatch.setattr(pill_module, "DeviceFilterDialog", FakeDeviceFilterDialog)
widget.open_device_filter_dialog()
beamline_state_manager.open_device_filter_dialog()
assert captured["devices"] == ["samx", "samy"]
assert captured["device_filter_text"] == "samx"
assert captured["parent"] is widget
assert captured["parent"] is beamline_state_manager
def test_beamline_state_manager_updates_state_parameters(qtbot, mocked_client):
widget = BeamlineStateManager(client=mocked_client)
qtbot.addWidget(widget)
widget.update_available_states(
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states(
{
"states": [
{
@@ -556,17 +557,16 @@ def test_beamline_state_manager_updates_state_parameters(qtbot, mocked_client):
self.limits = StateClient()
mocked_client.beamline_states = StateManager()
pill = widget._state_pills["limits"]
pill = beamline_state_manager._state_pills["limits"]
pill.set_expanded(True)
high_limit = pill._config_form.input_widget("high_limit")
high_limit.setValue(20.0)
assert pill._update_button.isEnabled()
widget._update_state_parameters("limits", pill.edited_config())
beamline_state_manager._update_state_parameters("limits", pill.edited_config())
assert mocked_client.beamline_states.limits.parameters == {
"title": "Limits",
"device": "samx",
"signal": "samx",
"low_limit": 0.0,
@@ -578,8 +578,7 @@ def test_beamline_state_manager_updates_state_parameters(qtbot, mocked_client):
def test_beamline_state_manager_removes_state(qtbot, mocked_client, monkeypatch):
widget = BeamlineStateManager(client=mocked_client)
qtbot.addWidget(widget)
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
class StateManager:
def __init__(self):
@@ -593,39 +592,35 @@ def test_beamline_state_manager_removes_state(qtbot, mocked_client, monkeypatch)
QMessageBox, "question", lambda *args, **kwargs: QMessageBox.StandardButton.Yes
)
widget._remove_state_requested("limits")
beamline_state_manager._remove_state_requested("limits")
assert mocked_client.beamline_states.deleted == "limits"
def test_add_beamline_state_dialog_uses_generated_widgets_and_normalizes_name(qtbot, mocked_client):
dialog = AddBeamlineStateDialog(client=mocked_client)
qtbot.addWidget(dialog)
limits_index = dialog._type_combo.findText(bl_states.DeviceWithinLimitsState.__name__)
add_state_dialog = create_widget(qtbot, AddBeamlineStateDialog, client=mocked_client)
limits_index = add_state_dialog._type_combo.findText(bl_states.DeviceWithinLimitsState.__name__)
assert limits_index >= 0
dialog._type_combo.setCurrentIndex(limits_index)
add_state_dialog._type_combo.setCurrentIndex(limits_index)
assert dialog._config_form.model is bl_states.DeviceWithinLimitsState.CONFIG_CLASS
assert add_state_dialog._config_form.model is bl_states.DeviceWithinLimitsState.CONFIG_CLASS
name = dialog._config_form.input_widget("name")
title = dialog._config_form.input_widget("title")
device = dialog._config_form.input_widget("device")
signal = dialog._config_form.input_widget("signal")
low_limit = dialog._config_form.field_widget("low_limit")
high_limit = dialog._config_form.field_widget("high_limit")
name = add_state_dialog._config_form.input_widget("name")
device = add_state_dialog._config_form.input_widget("device")
signal = add_state_dialog._config_form.input_widget("signal")
low_limit = add_state_dialog._config_form.field_widget("low_limit")
high_limit = add_state_dialog._config_form.field_widget("high_limit")
name.setText("samx-limits")
title.setText("samx-limits-15")
WidgetIO.set_value(device, "samx")
WidgetIO.set_value(signal, "samx")
low_limit.checkbox.setChecked(True)
high_limit.checkbox.setChecked(True)
high_limit.value_widget.setValue(15.0)
config = dialog.config()
config = add_state_dialog.config()
assert config.name == "samx_limits"
assert config.title == "samx-limits-15"
assert config.device == "samx"
assert config.signal == "samx"
assert config.low_limit == 0.0
@@ -635,10 +630,9 @@ def test_add_beamline_state_dialog_uses_generated_widgets_and_normalizes_name(qt
def test_add_beamline_state_dialog_generates_name_only_after_valid_device_selection(
qtbot, mocked_client
):
dialog = AddBeamlineStateDialog(client=mocked_client)
qtbot.addWidget(dialog)
name = dialog._config_form.input_widget("name")
device = dialog._config_form.input_widget("device")
add_state_dialog = create_widget(qtbot, AddBeamlineStateDialog, client=mocked_client)
name = add_state_dialog._config_form.input_widget("name")
device = add_state_dialog._config_form.input_widget("device")
device.setCurrentText("s")
@@ -650,46 +644,43 @@ def test_add_beamline_state_dialog_generates_name_only_after_valid_device_select
def test_add_beamline_state_dialog_switches_state_type_without_collapsing(qtbot, mocked_client):
dialog = AddBeamlineStateDialog(client=mocked_client)
qtbot.addWidget(dialog)
initial_height = dialog.height()
limits_index = dialog._type_combo.findText("DeviceWithinLimitsState")
add_state_dialog = create_widget(qtbot, AddBeamlineStateDialog, client=mocked_client)
initial_height = add_state_dialog.height()
limits_index = add_state_dialog._type_combo.findText("DeviceWithinLimitsState")
assert limits_index >= 0
shutter_index = dialog._type_combo.findText("ShutterState")
shutter_index = add_state_dialog._type_combo.findText("ShutterState")
assert shutter_index >= 0
dialog._type_combo.setCurrentIndex(shutter_index)
add_state_dialog._type_combo.setCurrentIndex(shutter_index)
qtbot.wait(0)
assert dialog._config_form.model is bl_states.DeviceStateConfig
assert dialog._config_form_host.count() == 1
assert not dialog._config_form.isHidden()
assert not dialog._buttons.isHidden()
assert dialog.sizeHint().height() > dialog._buttons.sizeHint().height()
assert dialog.minimumWidth() == 280
assert dialog.maximumWidth() > dialog.minimumWidth()
assert dialog.minimumHeight() == dialog.maximumHeight()
assert add_state_dialog._config_form.model is bl_states.DeviceStateConfig
assert add_state_dialog._config_form_host.count() == 1
assert not add_state_dialog._config_form.isHidden()
assert not add_state_dialog._buttons.isHidden()
assert add_state_dialog.sizeHint().height() > add_state_dialog._buttons.sizeHint().height()
assert add_state_dialog.minimumWidth() == 280
assert add_state_dialog.maximumWidth() > add_state_dialog.minimumWidth()
assert add_state_dialog.minimumHeight() == add_state_dialog.maximumHeight()
dialog._type_combo.setCurrentIndex(limits_index)
add_state_dialog._type_combo.setCurrentIndex(limits_index)
qtbot.wait(0)
assert dialog._config_form.model is bl_states.DeviceWithinLimitsState.CONFIG_CLASS
assert dialog.height() >= initial_height
assert dialog.minimumHeight() == dialog.maximumHeight()
assert add_state_dialog._config_form.model is bl_states.DeviceWithinLimitsState.CONFIG_CLASS
assert add_state_dialog.height() >= initial_height
assert add_state_dialog.minimumHeight() == add_state_dialog.maximumHeight()
def test_add_beamline_state_dialog_cleanup_deletes_device_widgets(qtbot, mocked_client):
dialog = AddBeamlineStateDialog(client=mocked_client)
qtbot.addWidget(dialog)
device = dialog._config_form.input_widget("device")
signal = dialog._config_form.input_widget("signal")
add_state_dialog = create_widget(qtbot, AddBeamlineStateDialog, client=mocked_client)
device = add_state_dialog._config_form.input_widget("device")
signal = add_state_dialog._config_form.input_widget("signal")
dialog.reject()
add_state_dialog.reject()
assert shiboken6.isValid(device)
assert shiboken6.isValid(signal)
dialog.cleanup()
add_state_dialog.cleanup()
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
assert not shiboken6.isValid(device)
@@ -1,7 +1,9 @@
from decimal import Decimal
from unittest.mock import patch
import pytest
from bec_lib.device import Device, Signal
from bec_lib.scan_args import ScanArgument
from pydantic import BaseModel, Field
from qtpy.QtWidgets import QCheckBox, QLabel, QLineEdit
@@ -11,6 +13,7 @@ from bec_widgets.utils.forms_from_types.pydantic_widget_form import (
OptionalValueWidget,
PydanticWidgetForm,
)
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox
@@ -76,6 +79,35 @@ class GeneratedSignalOnlySchema(BaseModel):
model_config = {"arbitrary_types_allowed": True}
class GeneratedScanArgumentSchema(BaseModel):
device: Device | str = Field(
default="", **ScanArgument(display_name="Device", description="Device source.").model_dump()
)
signal: Signal | str | None = Field(
default=None,
**ScanArgument(display_name="Signal", description="Signal source.").model_dump(),
)
low_limit: float | None = Field(
default=None,
**ScanArgument(
display_name="Low limit",
description="Optional lower bound.",
reference_units="device",
precision=4,
ge=-5,
le=5,
).model_dump(),
)
exposure: float = Field(
default=0.1,
**ScanArgument(
display_name="Exposure", tooltip="Camera exposure.", units="s", precision=3, gt=0
).model_dump(),
)
model_config = {"arbitrary_types_allowed": True}
class GeneratedRequiredNumericAndOptionalBoolSchema(BaseModel):
enabled: bool | None = None
retry_count: int
@@ -182,6 +214,37 @@ def test_pydantic_widget_form_plain_field_has_generated_label_and_no_tooltip(qtb
assert form.field_widget("sample_name").toolTip() == ""
def test_pydantic_widget_form_uses_scan_argument_metadata(qtbot, mocked_client):
form = PydanticWidgetForm(GeneratedScanArgumentSchema, client=mocked_client)
qtbot.addWidget(form)
low_limit = form.field_widget("low_limit")
low_limit_input = form.input_widget("low_limit")
exposure = form.input_widget("exposure")
low_limit_label = form.layout().labelForField(low_limit)
assert isinstance(low_limit_label, QLabel)
assert low_limit_label.text() == "Low limit"
assert low_limit.toolTip() == "Optional lower bound.\nUnits from: device"
assert low_limit_input.toolTip() == "Optional lower bound.\nUnits from: device"
assert low_limit_input.decimals() == 4
assert low_limit_input.minimum() == pytest.approx(-5)
assert low_limit_input.maximum() == pytest.approx(5)
assert form.field_widget("exposure").toolTip() == "Camera exposure.\nUnits: s"
assert exposure.toolTip() == "Camera exposure.\nUnits: s"
assert exposure.suffix() == " s"
assert exposure.decimals() == 3
assert exposure.minimum() == pytest.approx(0.001)
with patch.object(mocked_client.device_manager.devices.samx, "egu", return_value="mm"):
WidgetIO.set_value(form.input_widget("device"), "samx")
assert low_limit.toolTip() == "Optional lower bound.\nUnits: mm"
assert low_limit_input.toolTip() == "Optional lower bound.\nUnits: mm"
assert low_limit_input.suffix() == " mm"
def test_pydantic_widget_form_cleans_up_on_close(qtbot):
form = PydanticWidgetForm(GeneratedPlainSchema)
qtbot.addWidget(form)
@@ -0,0 +1,85 @@
import pytest
from qtpy.QtWidgets import QDoubleSpinBox, QSpinBox
from bec_widgets.utils.scan_arg_metadata import (
apply_numeric_limits,
apply_numeric_precision,
apply_unit_metadata,
device_units,
ui_config_from_metadata,
unit_tooltip,
)
from .conftest import create_widget
def test_unit_tooltip_and_cleanup(qtbot):
widget = create_widget(qtbot, QDoubleSpinBox)
item = {"tooltip": "Move start", "reference_units": "device"}
assert unit_tooltip(item) == "Move start\nUnits from: device"
apply_unit_metadata(widget, item)
assert widget.toolTip() == "Move start\nUnits from: device"
assert widget.suffix() == ""
apply_unit_metadata(widget, item, "mm")
assert widget.toolTip() == "Move start\nUnits: mm"
assert widget.suffix() == " mm"
apply_unit_metadata(widget, item, "deg")
assert widget.toolTip() == "Move start\nUnits: deg"
assert widget.suffix() == " deg"
def test_numeric_precision_and_limits(qtbot):
float_widget = create_widget(qtbot, QDoubleSpinBox)
int_widget = create_widget(qtbot, QSpinBox)
apply_numeric_precision(float_widget, {"name": "position", "precision": 3})
apply_numeric_limits(float_widget, {"ge": -1.5, "lt": 2.0})
apply_numeric_limits(int_widget, {"gt": 2, "le": 8})
assert float_widget.decimals() == 3
assert float_widget.minimum() == pytest.approx(-1.5)
assert float_widget.maximum() == pytest.approx(1.999)
assert int_widget.minimum() == 3
assert int_widget.maximum() == 8
def test_device_units_uses_egu():
class Device:
def egu(self):
return "mm"
assert device_units(Device()) == "mm"
assert device_units(object()) is None
def test_ui_config_from_metadata_matches_scan_control_item_shape():
item = ui_config_from_metadata(
name="exp_time",
input_type="float",
default=0.1,
metadata={"tooltip": "Exposure", "units": "s", "precision": 3, "ge": 0},
)
assert item == {
"arg": False,
"name": "exp_time",
"type": "float",
"display_name": "Exp Time",
"tooltip": "Exposure",
"default": 0.1,
"expert": False,
"hidden": False,
"precision": 3,
"units": "s",
"reference_units": None,
"reference_limits": None,
"gt": None,
"ge": 0,
"lt": None,
"le": None,
"alternative_group": None,
}