mirror of
https://github.com/bec-project/bec_widgets.git
synced 2025-07-14 03:31:50 +02:00
feat: generated form for scan metadata
This commit is contained in:
7
bec_widgets/widgets/editors/scan_metadata/__init__.py
Normal file
7
bec_widgets/widgets/editors/scan_metadata/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
from bec_widgets.widgets.editors.scan_metadata.additional_metadata_table import (
|
||||
AdditionalMetadataTable,
|
||||
AdditionalMetadataTableModel,
|
||||
)
|
||||
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
|
||||
|
||||
__all__ = ["ScanMetadata", "AdditionalMetadataTable", "AdditionalMetadataTableModel"]
|
275
bec_widgets/widgets/editors/scan_metadata/_metadata_widgets.py
Normal file
275
bec_widgets/widgets/editors/scan_metadata/_metadata_widgets.py
Normal file
@ -0,0 +1,275 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING, Callable, get_args
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel, Field
|
||||
from qtpy.QtCore import Signal # type: ignore
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QDoubleSpinBox,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLayout,
|
||||
QLineEdit,
|
||||
QRadioButton,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.widgets.editors.scan_metadata._util import (
|
||||
clearable_required,
|
||||
field_default,
|
||||
field_limits,
|
||||
field_maxlen,
|
||||
field_minlen,
|
||||
field_precision,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ClearableBoolEntry(QWidget):
|
||||
stateChanged = Signal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._layout = QHBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
|
||||
self._entry = QButtonGroup()
|
||||
self._true = QRadioButton("true", parent=self)
|
||||
self._false = QRadioButton("false", parent=self)
|
||||
for button in [self._true, self._false]:
|
||||
self._layout.addWidget(button)
|
||||
self._entry.addButton(button)
|
||||
button.toggled.connect(self.stateChanged)
|
||||
|
||||
def clear(self):
|
||||
self._entry.setExclusive(False)
|
||||
self._true.setChecked(False)
|
||||
self._false.setChecked(False)
|
||||
self._entry.setExclusive(True)
|
||||
|
||||
def isChecked(self) -> bool | None:
|
||||
if not self._true.isChecked() and not self._false.isChecked():
|
||||
return None
|
||||
return self._true.isChecked()
|
||||
|
||||
def setChecked(self, value: bool | None):
|
||||
if value is None:
|
||||
self.clear()
|
||||
elif value:
|
||||
self._true.setChecked(True)
|
||||
self._false.setChecked(False)
|
||||
else:
|
||||
self._true.setChecked(False)
|
||||
self._false.setChecked(True)
|
||||
|
||||
def setToolTip(self, tooltip: str):
|
||||
self._true.setToolTip(tooltip)
|
||||
self._false.setToolTip(tooltip)
|
||||
|
||||
|
||||
class MetadataWidget(QWidget):
|
||||
|
||||
valueChanged = Signal()
|
||||
|
||||
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._info = info
|
||||
self._layout = QHBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetMaximumSize)
|
||||
self._default = field_default(self._info)
|
||||
self._desc = self._info.description
|
||||
self.setLayout(self._layout)
|
||||
self._add_main_widget()
|
||||
if clearable_required(info):
|
||||
self._add_clear_button()
|
||||
|
||||
@abstractmethod
|
||||
def getValue(self): ...
|
||||
|
||||
@abstractmethod
|
||||
def setValue(self, value): ...
|
||||
|
||||
@abstractmethod
|
||||
def _add_main_widget(self) -> None:
|
||||
"""Add the main data entry widget to self._main_widget and appply any
|
||||
constraints from the field info"""
|
||||
|
||||
def _describe(self, pad=" "):
|
||||
return pad + (self._desc if self._desc else "")
|
||||
|
||||
def _add_clear_button(self):
|
||||
self._clear_button = QToolButton()
|
||||
self._clear_button.setIcon(
|
||||
material_icon(icon_name="close", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
self._layout.addWidget(self._clear_button)
|
||||
# the widget added in _add_main_widget must implement .clear() if value is not required
|
||||
self._clear_button.setToolTip("Clear value or reset to default.")
|
||||
self._clear_button.clicked.connect(self._main_widget.clear) # type: ignore
|
||||
|
||||
def _value_changed(self, *_, **__):
|
||||
self.valueChanged.emit()
|
||||
|
||||
|
||||
class StrMetadataField(MetadataWidget):
|
||||
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
|
||||
super().__init__(info, parent)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QLineEdit()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
min_length, max_length = field_minlen(self._info), field_maxlen(self._info)
|
||||
if max_length:
|
||||
self._main_widget.setMaxLength(max_length)
|
||||
self._main_widget.setToolTip(
|
||||
f"(length min: {min_length} max: {max_length}){self._describe()}"
|
||||
)
|
||||
if self._default:
|
||||
self._main_widget.setText(self._default)
|
||||
self._add_clear_button()
|
||||
|
||||
def getValue(self):
|
||||
if self._main_widget.text() == "":
|
||||
return self._default
|
||||
return self._main_widget.text()
|
||||
|
||||
def setValue(self, value: str):
|
||||
if value is None:
|
||||
self._main_widget.setText("")
|
||||
self._main_widget.setText(value)
|
||||
|
||||
|
||||
class IntMetadataField(MetadataWidget):
|
||||
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
|
||||
super().__init__(info, parent)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QSpinBox()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
min_, max_ = field_limits(self._info, int)
|
||||
self._main_widget.setMinimum(min_)
|
||||
self._main_widget.setMaximum(max_)
|
||||
self._main_widget.setToolTip(f"(range {min_} to {max_}){self._describe()}")
|
||||
if self._default is not None:
|
||||
self._main_widget.setValue(self._default)
|
||||
self._add_clear_button()
|
||||
else:
|
||||
self._main_widget.clear()
|
||||
|
||||
def getValue(self):
|
||||
if self._main_widget.text() == "":
|
||||
return self._default
|
||||
return self._main_widget.value()
|
||||
|
||||
def setValue(self, value: int):
|
||||
if value is None:
|
||||
self._main_widget.clear()
|
||||
self._main_widget.setValue(value)
|
||||
|
||||
|
||||
class FloatDecimalMetadataField(MetadataWidget):
|
||||
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
|
||||
super().__init__(info, parent)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QDoubleSpinBox()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
min_, max_ = field_limits(self._info, int)
|
||||
self._main_widget.setMinimum(min_)
|
||||
self._main_widget.setMaximum(max_)
|
||||
precision = field_precision(self._info)
|
||||
if precision:
|
||||
self._main_widget.setDecimals(precision)
|
||||
minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
|
||||
maxstr = f"{float(max_):.3f}" if abs(max_) <= 1000 else f"{float(max_):.3e}"
|
||||
self._main_widget.setToolTip(f"(range {minstr} to {maxstr}){self._describe()}")
|
||||
if self._default is not None:
|
||||
self._main_widget.setValue(self._default)
|
||||
self._add_clear_button()
|
||||
else:
|
||||
self._main_widget.clear()
|
||||
|
||||
def getValue(self):
|
||||
if self._main_widget.text() == "":
|
||||
return self._default
|
||||
return self._main_widget.value()
|
||||
|
||||
def setValue(self, value: float):
|
||||
if value is None:
|
||||
self._main_widget.clear()
|
||||
self._main_widget.setValue(value)
|
||||
|
||||
|
||||
class BoolMetadataField(MetadataWidget):
|
||||
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
|
||||
super().__init__(info, parent)
|
||||
self._main_widget.stateChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
if clearable_required(self._info):
|
||||
self._main_widget = ClearableBoolEntry()
|
||||
else:
|
||||
self._main_widget = QCheckBox()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
self._main_widget.setToolTip(self._describe(""))
|
||||
self._main_widget.setChecked(self._default) # type: ignore # if there is no default then it will be ClearableBoolEntry and can be set with None
|
||||
|
||||
def getValue(self):
|
||||
return self._main_widget.isChecked()
|
||||
|
||||
def setValue(self, value):
|
||||
self._main_widget.setChecked(value)
|
||||
|
||||
|
||||
def widget_from_type(annotation: type | None) -> Callable[[FieldInfo], MetadataWidget]:
|
||||
if annotation in [str, str | None]:
|
||||
return StrMetadataField
|
||||
if annotation in [int, int | None]:
|
||||
return IntMetadataField
|
||||
if annotation in [float, float | None, Decimal, Decimal | None]:
|
||||
return FloatDecimalMetadataField
|
||||
if annotation in [bool, bool | None]:
|
||||
return BoolMetadataField
|
||||
else:
|
||||
logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
|
||||
return StrMetadataField
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
class TestModel(BaseModel):
|
||||
value1: str | None = Field(None)
|
||||
value2: bool | None = Field(None)
|
||||
value3: bool = Field(True)
|
||||
value4: int = Field(123)
|
||||
value5: int | None = Field()
|
||||
|
||||
app = QApplication([])
|
||||
w = QWidget()
|
||||
layout = QGridLayout()
|
||||
w.setLayout(layout)
|
||||
for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
|
||||
layout.addWidget(QLabel(field_name), i, 0)
|
||||
layout.addWidget(widget_from_type(info.annotation)(info), i, 1)
|
||||
|
||||
w.show()
|
||||
app.exec()
|
67
bec_widgets/widgets/editors/scan_metadata/_util.py
Normal file
67
bec_widgets/widgets/editors/scan_metadata/_util.py
Normal file
@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
from math import inf, nextafter
|
||||
from typing import TYPE_CHECKING, TypeVar, get_args
|
||||
|
||||
from annotated_types import Ge, Gt, Le, Lt
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic_core import PydanticUndefined
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
_MININT = -2147483648
|
||||
_MAXINT = 2147483647
|
||||
_MINFLOAT = -sys.float_info.max
|
||||
_MAXFLOAT = sys.float_info.max
|
||||
|
||||
T = TypeVar("T", int, float, Decimal)
|
||||
|
||||
|
||||
def field_limits(info: FieldInfo, type_: type[T]) -> tuple[T, T]:
|
||||
_min = _MININT if type_ is int else _MINFLOAT
|
||||
_max = _MAXINT if type_ is int else _MAXFLOAT
|
||||
for md in info.metadata:
|
||||
if isinstance(md, Ge):
|
||||
_min = type_(md.ge) # type: ignore
|
||||
if isinstance(md, Gt):
|
||||
_min = type_(md.gt) + 1 if type_ is int else nextafter(type_(md.gt), inf) # type: ignore
|
||||
if isinstance(md, Lt):
|
||||
_max = type_(md.lt) - 1 if type_ is int else nextafter(type_(md.lt), -inf) # type: ignore
|
||||
if isinstance(md, Le):
|
||||
_max = type_(md.le) # type: ignore
|
||||
return _min, _max # type: ignore
|
||||
|
||||
|
||||
def _get_anno(info: FieldInfo, annotation: str, default):
|
||||
for md in info.metadata:
|
||||
if hasattr(md, annotation):
|
||||
return getattr(md, annotation)
|
||||
return default
|
||||
|
||||
|
||||
def field_precision(info: FieldInfo):
|
||||
return _get_anno(info, "decimal_places", 307)
|
||||
|
||||
|
||||
def field_maxlen(info: FieldInfo):
|
||||
return _get_anno(info, "max_length", None)
|
||||
|
||||
|
||||
def field_minlen(info: FieldInfo):
|
||||
return _get_anno(info, "min_length", None)
|
||||
|
||||
|
||||
def field_default(info: FieldInfo):
|
||||
if info.default is PydanticUndefined:
|
||||
return
|
||||
return info.default
|
||||
|
||||
|
||||
def clearable_required(info: FieldInfo):
|
||||
return type(None) in get_args(info.annotation) or info.is_required()
|
@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ignore
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QTableView,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
|
||||
|
||||
class AdditionalMetadataTableModel(QAbstractTableModel):
|
||||
def __init__(self, data):
|
||||
super().__init__()
|
||||
self._data: list[list[str]] = data
|
||||
self._disallowed_keys: list[str] = []
|
||||
|
||||
def headerData(
|
||||
self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole()
|
||||
) -> Any:
|
||||
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
|
||||
return "Key" if section == 0 else "Value"
|
||||
return super().headerData(section, orientation, role)
|
||||
|
||||
def rowCount(self, index: QModelIndex = QModelIndex()):
|
||||
return 0 if index.isValid() else len(self._data)
|
||||
|
||||
def columnCount(self, index: QModelIndex = QModelIndex()):
|
||||
return 0 if index.isValid() else 2
|
||||
|
||||
def data(self, index, role=Qt.ItemDataRole):
|
||||
if index.isValid():
|
||||
if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole:
|
||||
return str(self._data[index.row()][index.column()])
|
||||
|
||||
def setData(self, index, value, role):
|
||||
if role == Qt.ItemDataRole.EditRole:
|
||||
if value in self._disallowed_keys or value in self._other_keys(index.row()):
|
||||
return False
|
||||
self._data[index.row()][index.column()] = str(value)
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_disallowed_keys(self, keys: list[str]):
|
||||
self._disallowed_keys = keys
|
||||
for i, item in enumerate(self._data):
|
||||
if item[0] in self._disallowed_keys:
|
||||
self._data[i][0] = ""
|
||||
self.dataChanged.emit(self.index(i, 0), self.index(i, 0))
|
||||
|
||||
def _other_keys(self, row: int):
|
||||
return [r[0] for r in self._data[:row] + self._data[row + 1 :]]
|
||||
|
||||
def flags(self, _):
|
||||
return Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsEditable
|
||||
|
||||
def insertRows(self, row, number, index):
|
||||
"""We only support adding one at a time for now"""
|
||||
if row != self.rowCount() or number != 1:
|
||||
return False
|
||||
self.beginInsertRows(QModelIndex(), 0, 0)
|
||||
self._data.append(["", ""])
|
||||
self.endInsertRows()
|
||||
return True
|
||||
|
||||
def removeRows(self, row, number, index):
|
||||
"""This can only be consecutive, so instead of trying to be clever, only support removing one at a time"""
|
||||
if number != 1:
|
||||
return False
|
||||
self.beginRemoveRows(QModelIndex(), row, row)
|
||||
del self._data[row]
|
||||
self.endRemoveRows()
|
||||
return True
|
||||
|
||||
@SafeSlot()
|
||||
def add_row(self):
|
||||
self.insertRow(self.rowCount())
|
||||
|
||||
@SafeSlot(list)
|
||||
def delete_rows(self, rows: list[int]):
|
||||
# delete from the end so indices stay correct
|
||||
for row in sorted(rows, reverse=True):
|
||||
self.removeRows(row, 1, QModelIndex())
|
||||
|
||||
def dump_dict(self):
|
||||
if self._data == [[]]:
|
||||
return {}
|
||||
return dict(self._data)
|
||||
|
||||
|
||||
class AdditionalMetadataTable(QWidget):
|
||||
|
||||
delete_rows = Signal(list)
|
||||
|
||||
def __init__(self, initial_data: list[list[str]]):
|
||||
super().__init__()
|
||||
|
||||
self._layout = QHBoxLayout()
|
||||
self.setLayout(self._layout)
|
||||
self._table_model = AdditionalMetadataTableModel(initial_data)
|
||||
self._table_view = QTableView()
|
||||
self._table_view.setModel(self._table_model)
|
||||
self._table_view.horizontalHeader().setStretchLastSection(True)
|
||||
self._layout.addWidget(self._table_view)
|
||||
|
||||
self._buttons = QVBoxLayout()
|
||||
self._layout.addLayout(self._buttons)
|
||||
self._add_button = QPushButton("+")
|
||||
self._add_button.setToolTip("add a new row")
|
||||
self._remove_button = QPushButton("-")
|
||||
self._remove_button.setToolTip("delete rows containing any selected cells")
|
||||
self._buttons.addWidget(self._add_button)
|
||||
self._buttons.addWidget(self._remove_button)
|
||||
self._add_button.clicked.connect(self._table_model.add_row)
|
||||
self._remove_button.clicked.connect(self.delete_selected_rows)
|
||||
self.delete_rows.connect(self._table_model.delete_rows)
|
||||
|
||||
def delete_selected_rows(self):
|
||||
cells: list[QModelIndex] = self._table_view.selectionModel().selectedIndexes()
|
||||
row_indices = list({r.row() for r in cells})
|
||||
if row_indices:
|
||||
self.delete_rows.emit(row_indices)
|
||||
|
||||
def dump_dict(self):
|
||||
return self._table_model.dump_dict()
|
||||
|
||||
def update_disallowed_keys(self, keys: list[str]):
|
||||
self._table_model.update_disallowed_keys(keys)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
app = QApplication([])
|
||||
set_theme("dark")
|
||||
|
||||
window = AdditionalMetadataTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
|
||||
window.show()
|
||||
app.exec()
|
196
bec_widgets/widgets/editors/scan_metadata/scan_metadata.py
Normal file
196
bec_widgets/widgets/editors/scan_metadata/scan_metadata.py
Normal file
@ -0,0 +1,196 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.metadata_schema import get_metadata_schema_for_scan
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import Field, ValidationError
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QGridLayout,
|
||||
QLabel,
|
||||
QLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.editors.scan_metadata._metadata_widgets import widget_from_type
|
||||
from bec_widgets.widgets.editors.scan_metadata.additional_metadata_table import (
|
||||
AdditionalMetadataTable,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ScanMetadata(BECWidget, QWidget):
|
||||
"""Dynamically generates a form for inclusion of metadata for a scan. Uses the
|
||||
metadata schema registry supplied in the plugin repo to find pydantic models
|
||||
associated with the scan type. Sets limits for numerical values if specified."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
scan_name: str | None = None,
|
||||
initial_extras: list[list[str]] | None = None,
|
||||
):
|
||||
super().__init__(client=client)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
|
||||
self.set_schema(scan_name)
|
||||
|
||||
self._layout = QVBoxLayout()
|
||||
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
|
||||
self.setLayout(self._layout)
|
||||
self._layout.addWidget(QLabel("<b>Required scan metadata:</b>"))
|
||||
self._md_grid = QWidget()
|
||||
self._layout.addWidget(self._md_grid)
|
||||
self._grid_container = QVBoxLayout()
|
||||
self._md_grid.setLayout(self._grid_container)
|
||||
self._new_grid_layout()
|
||||
self._grid_container.addLayout(self._md_grid_layout)
|
||||
self._layout.addWidget(QLabel("<b>Additional metadata:</b>"))
|
||||
self._additional_metadata = AdditionalMetadataTable(initial_extras or [])
|
||||
self._layout.addWidget(self._additional_metadata)
|
||||
|
||||
self._validity = CompactPopupWidget()
|
||||
self._validity.compact_view = True # type: ignore
|
||||
self._validity.label = "Validity" # type: ignore
|
||||
self._validity.compact_show_popup.setIcon(
|
||||
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
self._validity_message = QLabel("Not yet validated")
|
||||
self._validity.addWidget(self._validity_message)
|
||||
self._layout.addWidget(self._validity)
|
||||
|
||||
self.populate()
|
||||
|
||||
@SafeSlot(str)
|
||||
def update_with_new_scan(self, scan_name: str):
|
||||
self.set_schema(scan_name)
|
||||
self.populate()
|
||||
self.validate_form()
|
||||
|
||||
def validate_form(self, *_):
|
||||
try:
|
||||
self._md_schema.model_validate(self.get_full_model_dict())
|
||||
self._validity.set_global_state("success")
|
||||
self._validity_message.setText("No errors!")
|
||||
except ValidationError as e:
|
||||
self._validity.set_global_state("emergency")
|
||||
self._validity_message.setText(str(e))
|
||||
|
||||
def get_full_model_dict(self):
|
||||
"""Get the entered metadata as a dict"""
|
||||
return self._additional_metadata.dump_dict() | self._dict_from_grid()
|
||||
|
||||
def set_schema(self, scan_name: str | None = None):
|
||||
self._scan_name = scan_name or ""
|
||||
self._md_schema = get_metadata_schema_for_scan(self._scan_name)
|
||||
|
||||
def populate(self):
|
||||
self._clear_grid()
|
||||
self._populate()
|
||||
|
||||
def _populate(self):
|
||||
self._additional_metadata.update_disallowed_keys(list(self._md_schema.model_fields.keys()))
|
||||
for i, (field_name, info) in enumerate(self._md_schema.model_fields.items()):
|
||||
self._add_griditem(field_name, info, i)
|
||||
|
||||
def _add_griditem(self, field_name: str, info: FieldInfo, row: int):
|
||||
grid = self._md_grid_layout
|
||||
label = QLabel(info.title or field_name)
|
||||
label.setProperty("_model_field_name", field_name)
|
||||
label.setToolTip(info.description or field_name)
|
||||
grid.addWidget(label, row, 0)
|
||||
widget = widget_from_type(info.annotation)(info)
|
||||
widget.valueChanged.connect(self.validate_form)
|
||||
grid.addWidget(widget, row, 1)
|
||||
|
||||
def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]:
|
||||
grid = self._md_grid_layout
|
||||
return {
|
||||
grid.itemAtPosition(i, 0).widget().property("_model_field_name"): grid.itemAtPosition(i, 1).widget().getValue() # type: ignore # we only add 'MetadataWidget's here
|
||||
for i in range(grid.rowCount())
|
||||
}
|
||||
|
||||
def _clear_grid(self):
|
||||
while self._md_grid_layout.count():
|
||||
item = self._md_grid_layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
widget.deleteLater()
|
||||
self._md_grid_layout.deleteLater()
|
||||
self._new_grid_layout()
|
||||
self._grid_container.addLayout(self._md_grid_layout)
|
||||
self._md_grid.adjustSize()
|
||||
self.adjustSize()
|
||||
|
||||
def _new_grid_layout(self):
|
||||
self._md_grid_layout = QGridLayout()
|
||||
self._md_grid_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._md_grid_layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from unittest.mock import patch
|
||||
|
||||
from bec_lib.metadata_schema import BasicScanMetadata
|
||||
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
|
||||
class ExampleSchema1(BasicScanMetadata):
|
||||
abc: int = Field(gt=0, lt=2000, description="Heating temperature abc", title="A B C")
|
||||
foo: str = Field(max_length=12, description="Sample database code", default="DEF123")
|
||||
xyz: Decimal = Field(decimal_places=4)
|
||||
baz: bool
|
||||
|
||||
class ExampleSchema2(BasicScanMetadata):
|
||||
checkbox_up_top: bool
|
||||
checkbox_again: bool = Field(
|
||||
title="Checkbox Again", description="this one defaults to True", default=True
|
||||
)
|
||||
different_items: int | None = Field(
|
||||
None, description="This is just one different item...", gt=-100, lt=0
|
||||
)
|
||||
length_limited_string: str = Field(max_length=32)
|
||||
float_with_2dp: Decimal = Field(decimal_places=2)
|
||||
|
||||
class ExampleSchema3(BasicScanMetadata):
|
||||
optional_with_regex: str | None = Field(None, pattern=r"^\d+-\d+$")
|
||||
|
||||
with patch(
|
||||
"bec_lib.metadata_schema._get_metadata_schema_registry",
|
||||
lambda: {"scan1": ExampleSchema1, "scan2": ExampleSchema2, "scan3": ExampleSchema3},
|
||||
):
|
||||
|
||||
app = QApplication([])
|
||||
w = QWidget()
|
||||
selection = QComboBox()
|
||||
selection.addItems(["grid_scan", "scan1", "scan2", "scan3"])
|
||||
|
||||
layout = QVBoxLayout()
|
||||
w.setLayout(layout)
|
||||
|
||||
scan_metadata = ScanMetadata(
|
||||
scan_name="grid_scan",
|
||||
initial_extras=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]],
|
||||
)
|
||||
selection.currentTextChanged.connect(scan_metadata.update_with_new_scan)
|
||||
|
||||
layout.addWidget(selection)
|
||||
layout.addWidget(scan_metadata)
|
||||
|
||||
set_theme("dark")
|
||||
window = w
|
||||
window.show()
|
||||
app.exec()
|
209
tests/unit_tests/test_scan_metadata.py
Normal file
209
tests/unit_tests/test_scan_metadata.py
Normal file
@ -0,0 +1,209 @@
|
||||
from decimal import Decimal
|
||||
from typing import Annotated
|
||||
|
||||
import pytest
|
||||
from bec_lib.metadata_schema import BasicScanMetadata
|
||||
from pydantic import Field
|
||||
from pydantic.types import Json
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QCheckBox, QDoubleSpinBox, QLineEdit, QSpinBox, QWidget
|
||||
|
||||
from bec_widgets.widgets.editors.scan_metadata import AdditionalMetadataTableModel, ScanMetadata
|
||||
from bec_widgets.widgets.editors.scan_metadata._metadata_widgets import (
|
||||
BoolMetadataField,
|
||||
FloatDecimalMetadataField,
|
||||
IntMetadataField,
|
||||
MetadataWidget,
|
||||
StrMetadataField,
|
||||
)
|
||||
from bec_widgets.widgets.editors.scan_metadata.additional_metadata_table import (
|
||||
AdditionalMetadataTable,
|
||||
)
|
||||
|
||||
|
||||
class ExampleSchema(BasicScanMetadata):
|
||||
str_optional: str | None = Field(
|
||||
None, title="Optional string", description="an optional string", max_length=23
|
||||
)
|
||||
str_required: str
|
||||
bool_optional: bool | None = Field(None)
|
||||
bool_required_default: bool = Field(True)
|
||||
bool_required_nodefault: bool = Field()
|
||||
int_default: int = Field(123)
|
||||
int_nodefault_optional: int | None = Field(lt=-1, ge=-44)
|
||||
float_nodefault: float
|
||||
decimal_dp_limits_nodefault: Decimal = Field(Decimal(1.23), decimal_places=2, gt=1, le=34.5)
|
||||
unsupported_class: Json = Field(default_factory=dict)
|
||||
|
||||
|
||||
pytest.approx(0.1)
|
||||
|
||||
TEST_DICT = {
|
||||
"sample_name": "test name",
|
||||
"str_optional": None,
|
||||
"str_required": "something",
|
||||
"bool_optional": None,
|
||||
"bool_required_default": True,
|
||||
"bool_required_nodefault": False,
|
||||
"int_default": 21,
|
||||
"int_nodefault_optional": -10,
|
||||
"float_nodefault": pytest.approx(0.1),
|
||||
"decimal_dp_limits_nodefault": pytest.approx(34),
|
||||
"unsupported_class": '{"key": "value"}',
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def example_md():
|
||||
return ExampleSchema.model_validate(TEST_DICT)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_metadata_widget():
|
||||
widget = ScanMetadata()
|
||||
widget._additional_metadata._table_model._data = [["extra_field", "extra_data"]]
|
||||
yield widget
|
||||
widget._clear_grid()
|
||||
widget.deleteLater()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def metadata_widget(empty_metadata_widget: ScanMetadata):
|
||||
widget = empty_metadata_widget
|
||||
widget._md_schema = ExampleSchema
|
||||
widget.populate()
|
||||
|
||||
sample_name = widget._md_grid_layout.itemAtPosition(0, 1).widget()
|
||||
str_optional = widget._md_grid_layout.itemAtPosition(1, 1).widget()
|
||||
str_required = widget._md_grid_layout.itemAtPosition(2, 1).widget()
|
||||
bool_optional = widget._md_grid_layout.itemAtPosition(3, 1).widget()
|
||||
bool_required_default = widget._md_grid_layout.itemAtPosition(4, 1).widget()
|
||||
bool_required_nodefault = widget._md_grid_layout.itemAtPosition(5, 1).widget()
|
||||
int_default = widget._md_grid_layout.itemAtPosition(6, 1).widget()
|
||||
int_nodefault_optional = widget._md_grid_layout.itemAtPosition(7, 1).widget()
|
||||
float_nodefault = widget._md_grid_layout.itemAtPosition(8, 1).widget()
|
||||
decimal_dp_limits_nodefault = widget._md_grid_layout.itemAtPosition(9, 1).widget()
|
||||
unsupported_class = widget._md_grid_layout.itemAtPosition(10, 1).widget()
|
||||
|
||||
yield (
|
||||
widget,
|
||||
{
|
||||
"sample_name": sample_name,
|
||||
"str_optional": str_optional,
|
||||
"str_required": str_required,
|
||||
"bool_optional": bool_optional,
|
||||
"bool_required_default": bool_required_default,
|
||||
"bool_required_nodefault": bool_required_nodefault,
|
||||
"int_default": int_default,
|
||||
"int_nodefault_optional": int_nodefault_optional,
|
||||
"float_nodefault": float_nodefault,
|
||||
"decimal_dp_limits_nodefault": decimal_dp_limits_nodefault,
|
||||
"unsupported_class": unsupported_class,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def fill_commponents(components: dict[str, MetadataWidget]):
|
||||
components["sample_name"].setValue("test name")
|
||||
components["str_optional"].setValue(None)
|
||||
components["str_required"].setValue("something")
|
||||
components["bool_optional"].setValue(None)
|
||||
components["bool_required_nodefault"].setValue(False)
|
||||
components["int_default"].setValue(21)
|
||||
components["int_nodefault_optional"].setValue(-10)
|
||||
components["float_nodefault"].setValue(0.1)
|
||||
components["decimal_dp_limits_nodefault"].setValue(456.789)
|
||||
components["unsupported_class"].setValue(r'{"key": "value"}')
|
||||
|
||||
|
||||
def test_griditems_are_correct_class(
|
||||
metadata_widget: tuple[ScanMetadata, dict[str, MetadataWidget]]
|
||||
):
|
||||
_, components = metadata_widget
|
||||
assert isinstance(components["sample_name"], StrMetadataField)
|
||||
assert isinstance(components["str_optional"], StrMetadataField)
|
||||
assert isinstance(components["str_required"], StrMetadataField)
|
||||
assert isinstance(components["bool_optional"], BoolMetadataField)
|
||||
assert isinstance(components["bool_required_default"], BoolMetadataField)
|
||||
assert isinstance(components["bool_required_nodefault"], BoolMetadataField)
|
||||
assert isinstance(components["int_default"], IntMetadataField)
|
||||
assert isinstance(components["int_nodefault_optional"], IntMetadataField)
|
||||
assert isinstance(components["float_nodefault"], FloatDecimalMetadataField)
|
||||
assert isinstance(components["decimal_dp_limits_nodefault"], FloatDecimalMetadataField)
|
||||
assert isinstance(components["unsupported_class"], StrMetadataField)
|
||||
|
||||
|
||||
def test_grid_to_dict(metadata_widget: tuple[ScanMetadata, dict[str, MetadataWidget]]):
|
||||
widget, components = metadata_widget = metadata_widget
|
||||
fill_commponents(components)
|
||||
|
||||
assert widget._dict_from_grid() == TEST_DICT
|
||||
assert widget.get_full_model_dict() == TEST_DICT | {"extra_field": "extra_data"}
|
||||
|
||||
|
||||
def test_validation(metadata_widget: tuple[ScanMetadata, dict[str, MetadataWidget]]):
|
||||
widget, components = metadata_widget = metadata_widget
|
||||
assert widget._validity.compact_status.styleSheet().startswith(
|
||||
widget._validity.compact_status.default_led[:114]
|
||||
)
|
||||
|
||||
fill_commponents(components)
|
||||
widget.validate_form()
|
||||
assert widget._validity_message.text() == "No errors!"
|
||||
|
||||
components["bool_required_nodefault"]._main_widget.clear()
|
||||
widget.validate_form()
|
||||
assert "Input should be a valid boolean" in widget._validity_message.text()
|
||||
components["bool_required_nodefault"].setValue(True)
|
||||
|
||||
components["float_nodefault"]._main_widget.clear()
|
||||
widget.validate_form()
|
||||
assert "Input should be a valid number" in widget._validity_message.text()
|
||||
components["float_nodefault"].setValue(True)
|
||||
|
||||
|
||||
def test_numbers_clipped_to_limits(metadata_widget: tuple[ScanMetadata, dict[str, MetadataWidget]]):
|
||||
widget, components = metadata_widget = metadata_widget
|
||||
fill_commponents(components)
|
||||
|
||||
components["decimal_dp_limits_nodefault"].setValue(-56)
|
||||
widget.validate_form()
|
||||
assert components["decimal_dp_limits_nodefault"].getValue() == pytest.approx(2)
|
||||
assert widget._validity_message.text() == "No errors!"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def table():
|
||||
table = AdditionalMetadataTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
|
||||
yield table
|
||||
table._table_model.deleteLater()
|
||||
table._table_view.deleteLater()
|
||||
table.deleteLater()
|
||||
|
||||
|
||||
def test_additional_metadata_table_add_row(table: AdditionalMetadataTable):
|
||||
assert table._table_model.rowCount() == 3
|
||||
table._add_button.click()
|
||||
assert table._table_model.rowCount() == 4
|
||||
|
||||
|
||||
def test_additional_metadata_table_delete_row(table: AdditionalMetadataTable):
|
||||
assert table._table_model.rowCount() == 3
|
||||
table._table_view.selectRow(1)
|
||||
table.delete_selected_rows()
|
||||
assert table._table_model.rowCount() == 2
|
||||
assert list(table.dump_dict().keys()) == ["key1", "key3"]
|
||||
|
||||
|
||||
def test_additional_metadata_allows_changes(table: AdditionalMetadataTable):
|
||||
assert table._table_model.rowCount() == 3
|
||||
assert list(table.dump_dict().keys()) == ["key1", "key2", "key3"]
|
||||
table._table_model.setData(table._table_model.index(1, 0), "key4", Qt.ItemDataRole.EditRole)
|
||||
assert list(table.dump_dict().keys()) == ["key1", "key4", "key3"]
|
||||
|
||||
|
||||
def test_additional_metadata_doesnt_allow_dupes(table: AdditionalMetadataTable):
|
||||
assert table._table_model.rowCount() == 3
|
||||
assert list(table.dump_dict().keys()) == ["key1", "key2", "key3"]
|
||||
table._table_model.setData(table._table_model.index(1, 0), "key1", Qt.ItemDataRole.EditRole)
|
||||
assert list(table.dump_dict().keys()) == ["key1", "key2", "key3"]
|
Reference in New Issue
Block a user