0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-14 03:31:50 +02:00

refactor: rearrange base of metadata forms for generic use

This commit is contained in:
David Perl
2025-04-22 20:50:33 +02:00
committed by perl_d
parent 23fee22ef8
commit d04770fe91
9 changed files with 332 additions and 213 deletions

View File

@ -0,0 +1,182 @@
from __future__ import annotations
from decimal import Decimal
from types import NoneType
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from pydantic import BaseModel, ValidationError
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import QGridLayout, QLabel, QLayout, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.forms_from_types.items import FormItemSpec, widget_from_type
logger = bec_logger.logger
class TypedForm(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "list_alt"
value_changed = Signal()
RPC = False
def __init__(
self,
items: list[tuple[str, type]] | None = None,
form_item_specs: list[FormItemSpec] | None = None,
parent=None,
client=None,
**kwargs,
):
"""Widget with a list of form items based on a list of types.
Args:
items (list[tuple[str, type]]): list of tuples of a name for the field and its type.
Should be a type supported by the logic in items.py
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
only one of items or form_item_specs should be
supplied.
"""
if (items is not None and form_item_specs is not None) or (
items is None and form_item_specs is None
):
raise ValueError("Must specify one and only one of items and form_item_specs")
super().__init__(parent=parent, client=client, **kwargs)
self._items = (
form_item_specs
if form_item_specs is not None
else [
FormItemSpec(name=name, item_type=item_type)
for name, item_type in items # type: ignore
]
)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._form_grid_container = QWidget(parent=self)
self._form_grid = QWidget(parent=self._form_grid_container)
self._layout.addWidget(self._form_grid_container)
self._form_grid_container.setLayout(QVBoxLayout())
self._form_grid.setLayout(self._new_grid_layout())
self.populate()
def populate(self):
self._clear_grid()
for r, item in enumerate(self._items):
self._add_griditem(item, r)
def _add_griditem(self, item: FormItemSpec, row: int):
grid = self._form_grid.layout()
label = QLabel(item.name)
label.setProperty("_model_field_name", item.name)
label.setToolTip(item.info.description or item.name)
grid.addWidget(label, row, 0)
widget = widget_from_type(item.item_type)(parent=self, spec=item)
widget.valueChanged.connect(self.value_changed)
grid.addWidget(widget, row, 1)
def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]:
grid: QGridLayout = self._form_grid.layout() # type: ignore
return {
grid.itemAtPosition(i, 0)
.widget()
.property("_model_field_name"): grid.itemAtPosition(i, 1)
.widget()
.getValue() # type: ignore # we only add 'DynamicFormItem's here
for i in range(grid.rowCount())
}
def _clear_grid(self):
if (old_layout := self._form_grid.layout()) is not None:
while old_layout.count():
item = old_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
old_layout.deleteLater()
self._form_grid.deleteLater()
self._form_grid = QWidget()
self._form_grid.setLayout(self._new_grid_layout())
self._form_grid_container.layout().addWidget(self._form_grid)
self._form_grid.adjustSize()
self._form_grid_container.adjustSize()
self.adjustSize()
def _new_grid_layout(self):
new_grid = QGridLayout()
new_grid.setContentsMargins(0, 0, 0, 0)
new_grid.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
return new_grid
class PydanticModelForm(TypedForm):
metadata_updated = Signal(dict)
metadata_cleared = Signal(NoneType)
def __init__(self, metadata_model: type[BaseModel], parent=None, client=None, **kwargs):
"""
A form generated from a pydantic model.
Args:
metadata_model (type[BaseModel]): the model class for which to generate a form.
"""
self._md_schema = metadata_model
super().__init__(form_item_specs=self._form_item_specs(), parent=parent, client=client)
self._validity = CompactPopupWidget()
self._validity.compact_view = True # type: ignore
self._validity.label = "Metadata 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.value_changed.connect(self.validate_form)
def set_schema(self, schema: type[BaseModel]):
self._md_schema = schema
self.populate()
def _form_item_specs(self):
return [
FormItemSpec(name=name, info=info, item_type=info.annotation)
for name, info in self._md_schema.model_fields.items()
]
def update_items_from_schema(self):
self._items = self._form_item_specs()
def populate(self):
self.update_items_from_schema()
super().populate()
def get_form_data(self):
"""Get the entered metadata as a dict."""
return self._dict_from_grid()
def validate_form(self, *_) -> bool:
"""validate the currently entered metadata against the pydantic schema.
If successful, returns on metadata_emitted and returns true.
Otherwise, emits on metadata_cleared and returns false."""
try:
metadata_dict = self.get_form_data()
self._md_schema.model_validate(metadata_dict)
self._validity.set_global_state("success")
self._validity_message.setText("No errors!")
self.metadata_updated.emit(metadata_dict)
return True
except ValidationError as e:
self._validity.set_global_state("emergency")
self._validity_message.setText(str(e))
self.metadata_cleared.emit(None)
return False

View File

@ -2,11 +2,13 @@ from __future__ import annotations
from abc import abstractmethod
from decimal import Decimal
from typing import TYPE_CHECKING, Callable, get_args
from types import UnionType
from typing import Callable, Protocol
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from pydantic.fields import FieldInfo
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtWidgets import (
QApplication,
@ -33,12 +35,22 @@ from bec_widgets.widgets.editors.scan_metadata._util import (
field_precision,
)
if TYPE_CHECKING: # pragma: no cover
from pydantic.fields import FieldInfo
logger = bec_logger.logger
class FormItemSpec(BaseModel):
"""
The specification for an item in a dynamically generated form. Uses a pydantic FieldInfo
to store most annotation info, since one of the main purposes is to store data for
forms genrated from pydantic models, but can also be composed from other sources or by hand.
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
item_type: type | UnionType
name: str
info: FieldInfo = FieldInfo()
class ClearableBoolEntry(QWidget):
stateChanged = Signal()
@ -82,21 +94,20 @@ class ClearableBoolEntry(QWidget):
self._false.setToolTip(tooltip)
class MetadataWidget(QWidget):
class DynamicFormItem(QWidget):
valueChanged = Signal()
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent)
self._info = info
self._spec = spec
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._default = field_default(self._spec.info)
self._desc = self._spec.info.description
self.setLayout(self._layout)
self._add_main_widget()
if clearable_required(info):
if clearable_required(spec.info):
self._add_clear_button()
@abstractmethod
@ -127,15 +138,15 @@ class MetadataWidget(QWidget):
self.valueChanged.emit()
class StrMetadataField(MetadataWidget):
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
super().__init__(info, parent)
class StrMetadataField(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
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)
min_length, max_length = (field_minlen(self._spec.info), field_maxlen(self._spec.info))
if max_length:
self._main_widget.setMaxLength(max_length)
self._main_widget.setToolTip(
@ -156,15 +167,15 @@ class StrMetadataField(MetadataWidget):
self._main_widget.setText(value)
class IntMetadataField(MetadataWidget):
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
super().__init__(info, parent)
class IntMetadataField(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
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)
min_, max_ = field_limits(self._spec.info, int)
self._main_widget.setMinimum(min_)
self._main_widget.setMaximum(max_)
self._main_widget.setToolTip(f"(range {min_} to {max_}){self._describe()}")
@ -185,18 +196,18 @@ class IntMetadataField(MetadataWidget):
self._main_widget.setValue(value)
class FloatDecimalMetadataField(MetadataWidget):
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
super().__init__(info, parent)
class FloatDecimalMetadataField(DynamicFormItem):
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
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)
min_, max_ = field_limits(self._spec.info, int)
self._main_widget.setMinimum(min_)
self._main_widget.setMaximum(max_)
precision = field_precision(self._info)
precision = field_precision(self._spec.info)
if precision:
self._main_widget.setDecimals(precision)
minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
@ -219,13 +230,13 @@ class FloatDecimalMetadataField(MetadataWidget):
self._main_widget.setValue(value)
class BoolMetadataField(MetadataWidget):
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
super().__init__(info, parent)
class BoolMetadataField(DynamicFormItem):
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
super().__init__(parent=parent, spec=spec)
self._main_widget.stateChanged.connect(self._value_changed)
def _add_main_widget(self) -> None:
if clearable_required(self._info):
if clearable_required(self._spec.info):
self._main_widget = ClearableBoolEntry()
else:
self._main_widget = QCheckBox()
@ -240,7 +251,7 @@ class BoolMetadataField(MetadataWidget):
self._main_widget.setChecked(value)
def widget_from_type(annotation: type | None) -> Callable[[FieldInfo], MetadataWidget]:
def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormItem]:
if annotation in [str, str | None]:
return StrMetadataField
if annotation in [int, int | None]:

View File

@ -64,7 +64,6 @@ class ScanControl(BECWidget, QWidget):
default_scan: str | None = None,
**kwargs,
):
if config is None:
config = ScanControlConfig(
widget_class=self.__class__.__name__, allowed_scans=allowed_scans

View File

@ -16,12 +16,20 @@ from qtpy.QtWidgets import (
from bec_widgets.utils.error_popups import SafeSlot
class AdditionalMetadataTableModel(QAbstractTableModel):
class DictBackedTableModel(QAbstractTableModel):
def __init__(self, data):
"""A model to go with DictBackedTable, which represents key-value pairs
to be displayed in a TreeWidget.
Args:
data (list[list[str]]): list of key-value pairs to initialise with"""
super().__init__()
self._data: list[list[str]] = data
self._disallowed_keys: list[str] = []
# pylint: disable=missing-function-docstring
# see QAbstractTableModel documentation for these methods
def headerData(
self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole()
) -> Any:
@ -49,6 +57,10 @@ class AdditionalMetadataTableModel(QAbstractTableModel):
return False
def update_disallowed_keys(self, keys: list[str]):
"""Set the list of keys which may not be used.
Args:
keys (list[str]): list of keys which are forbidden."""
self._disallowed_keys = keys
for i, item in enumerate(self._data):
if item[0] in self._disallowed_keys:
@ -95,16 +107,21 @@ class AdditionalMetadataTableModel(QAbstractTableModel):
return dict(self._data)
class AdditionalMetadataTable(QWidget):
class DictBackedTable(QWidget):
delete_rows = Signal(list)
def __init__(self, initial_data: list[list[str]]):
"""Widget which uses a DictBackedTableModel to display an editable table
which can be extracted as a dict.
Args:
initial_data (list[list[str]]): list of key-value pairs to initialise with
"""
super().__init__()
self._layout = QHBoxLayout()
self.setLayout(self._layout)
self._table_model = AdditionalMetadataTableModel(initial_data)
self._table_model = DictBackedTableModel(initial_data)
self._table_view = QTreeView()
self._table_view.setModel(self._table_model)
self._table_view.setSizePolicy(
@ -126,15 +143,21 @@ class AdditionalMetadataTable(QWidget):
self.delete_rows.connect(self._table_model.delete_rows)
def delete_selected_rows(self):
"""Delete rows which are part of the selection model"""
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):
"""Get the current content of the table as a dict"""
return self._table_model.dump_dict()
def update_disallowed_keys(self, keys: list[str]):
"""Set the list of keys which may not be used.
Args:
keys (list[str]): list of keys which are forbidden."""
self._table_model.update_disallowed_keys(keys)
@ -144,6 +167,6 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication([])
set_theme("dark")
window = AdditionalMetadataTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
window = DictBackedTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
window.show()
app.exec()

View File

@ -1,7 +0,0 @@
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"]

View File

@ -1,52 +1,21 @@
from __future__ import annotations
from decimal import Decimal
from types import NoneType
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.QtCore import Signal # type: ignore
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QGridLayout,
QHBoxLayout,
QLabel,
QLayout,
QVBoxLayout,
QWidget,
)
from pydantic import Field
from qtpy.QtWidgets import QApplication, QComboBox, QHBoxLayout, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
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: # pragma: no cover
from pydantic.fields import FieldInfo
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
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."""
PLUGIN = True
ICON_NAME = "list_alt"
metadata_updated = Signal(dict)
metadata_cleared = Signal(NoneType)
RPC = False
class ScanMetadata(PydanticModelForm):
def __init__(
self,
parent=None,
@ -55,117 +24,35 @@ class ScanMetadata(BECWidget, QWidget):
initial_extras: list[list[str]] | None = None,
**kwargs,
):
super().__init__(parent=parent, client=client, **kwargs)
"""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.
self.set_schema(scan_name)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(self._layout)
self._required_md_box = ExpandableGroupFrame("Scan schema metadata")
self._layout.addWidget(self._required_md_box)
self._required_md_box_layout = QHBoxLayout()
self._required_md_box.set_layout(self._required_md_box_layout)
self._md_grid = QWidget()
self._required_md_box_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)
Args:
scan_name (str): The scan for which to generate a metadata form
Initial_extras (list[list[str]]): Initial data with which to populate the additional
metadata table - inner lists should be key-value pairs
"""
# self.populate() gets called in super().__init__
# so make sure self._additional_metadata exists
self._additional_md_box = ExpandableGroupFrame("Additional metadata", expanded=False)
self._layout.addWidget(self._additional_md_box)
self._additional_md_box_layout = QHBoxLayout()
self._additional_md_box.set_layout(self._additional_md_box_layout)
self._additional_metadata = AdditionalMetadataTable(initial_extras or [])
self._additional_md_box_layout.addWidget(self._additional_metadata)
self._validity = CompactPopupWidget()
self._validity.compact_view = True # type: ignore
self._validity.label = "Metadata 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, *_) -> bool:
"""validate the currently entered metadata against the pydantic schema.
If successful, returns on metadata_emitted and returns true.
Otherwise, emits on metadata_cleared and returns false."""
try:
metadata_dict = self.get_full_model_dict()
self._md_schema.model_validate(metadata_dict)
self._validity.set_global_state("success")
self._validity_message.setText("No errors!")
self.metadata_updated.emit(metadata_dict)
except ValidationError as e:
self._validity.set_global_state("emergency")
self._validity_message.setText(str(e))
self.metadata_cleared.emit(None)
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._additional_metadata = DictBackedTable(initial_extras or [])
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()
super().__init__(self._md_schema, parent, client, **kwargs)
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)
self._layout.addWidget(self._additional_md_box)
self._additional_md_box_layout.addWidget(self._additional_metadata)
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)
@SafeSlot(str)
def update_with_new_scan(self, scan_name: str):
self.set_schema_from_scan(scan_name)
self.validate_form()
@SafeProperty(bool)
def hide_optional_metadata(self): # type: ignore
@ -181,8 +68,25 @@ class ScanMetadata(BECWidget, QWidget):
"""
self._additional_md_box.setVisible(not hide)
def get_form_data(self):
"""Get the entered metadata as a dict"""
return self._additional_metadata.dump_dict() | self._dict_from_grid()
def populate(self):
self._additional_metadata.update_disallowed_keys(list(self._md_schema.model_fields.keys()))
super().populate()
def set_schema_from_scan(self, scan_name: str | None):
self._scan_name = scan_name or ""
self.set_schema(get_metadata_schema_for_scan(self._scan_name))
self.populate()
if __name__ == "__main__": # pragma: no cover
# pylint: disable=redefined-outer-name
# pylint: disable=protected-access
# pylint: disable=disallowed-name
from unittest.mock import patch
from bec_lib.metadata_schema import BasicScanMetadata
@ -213,7 +117,6 @@ if __name__ == "__main__": # pragma: no cover
"bec_lib.metadata_schema._get_metadata_schema_registry",
lambda: {"scan1": ExampleSchema1, "scan2": ExampleSchema2, "scan3": ExampleSchema3},
):
app = QApplication([])
w = QWidget()
selection = QComboBox()

View File

@ -5,12 +5,17 @@ import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_lib.messages import AvailableResourceMessage, ScanQueueHistoryMessage, ScanQueueMessage
from bec_widgets.utils.forms_from_types.items import StrMetadataField
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.scan_control import ScanControl
from bec_widgets.widgets.editors.scan_metadata._metadata_widgets import StrMetadataField
from .client_mocks import mocked_client
# pylint: disable=no-member
# pylint: disable=missing-function-docstring
# pylint: disable=redefined-outer-name
# pylint: disable=protected-access
available_scans_message = AvailableResourceMessage(
resource={
"line_scan": {
@ -539,9 +544,10 @@ def test_scan_metadata_is_connected(scan_control):
assert scan_control._metadata_form._scan_name == "line_scan"
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
assert scan_control._metadata_form._scan_name == "grid_scan"
sample_name = scan_control._metadata_form._md_grid_layout.itemAtPosition(0, 1).widget()
sample_name = scan_control._metadata_form._form_grid.layout().itemAtPosition(0, 1).widget()
assert isinstance(sample_name, StrMetadataField)
sample_name._main_widget.setText("Test Sample")
scan_control._metadata_form._additional_metadata._table_model._data = [
["test key 1", "test value 1"],
["test key 2", "test value 2"],

View File

@ -1,5 +1,4 @@
from decimal import Decimal
from typing import Annotated
import pytest
from bec_lib.metadata_schema import BasicScanMetadata
@ -7,17 +6,20 @@ from pydantic import Field
from pydantic.types import Json
from qtpy.QtCore import QItemSelectionModel, QPoint, Qt
from bec_widgets.widgets.editors.scan_metadata import ScanMetadata
from bec_widgets.widgets.editors.scan_metadata._metadata_widgets import (
from bec_widgets.utils.forms_from_types.items import (
BoolMetadataField,
DynamicFormItem,
FloatDecimalMetadataField,
IntMetadataField,
MetadataWidget,
StrMetadataField,
)
from bec_widgets.widgets.editors.scan_metadata.additional_metadata_table import (
AdditionalMetadataTable,
)
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
# pylint: disable=no-member
# pylint: disable=missing-function-docstring
# pylint: disable=redefined-outer-name
# pylint: disable=protected-access
class ExampleSchema(BasicScanMetadata):
@ -35,8 +37,6 @@ class ExampleSchema(BasicScanMetadata):
unsupported_class: Json = Field(default_factory=dict)
pytest.approx(0.1)
TEST_DICT = {
"sample_name": "test name",
"str_optional": None,
@ -72,17 +72,17 @@ def metadata_widget(empty_metadata_widget: ScanMetadata):
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()
sample_name = widget._form_grid.layout().itemAtPosition(0, 1).widget()
str_optional = widget._form_grid.layout().itemAtPosition(1, 1).widget()
str_required = widget._form_grid.layout().itemAtPosition(2, 1).widget()
bool_optional = widget._form_grid.layout().itemAtPosition(3, 1).widget()
bool_required_default = widget._form_grid.layout().itemAtPosition(4, 1).widget()
bool_required_nodefault = widget._form_grid.layout().itemAtPosition(5, 1).widget()
int_default = widget._form_grid.layout().itemAtPosition(6, 1).widget()
int_nodefault_optional = widget._form_grid.layout().itemAtPosition(7, 1).widget()
float_nodefault = widget._form_grid.layout().itemAtPosition(8, 1).widget()
decimal_dp_limits_nodefault = widget._form_grid.layout().itemAtPosition(9, 1).widget()
unsupported_class = widget._form_grid.layout().itemAtPosition(10, 1).widget()
yield (
widget,
@ -102,7 +102,7 @@ def metadata_widget(empty_metadata_widget: ScanMetadata):
)
def fill_commponents(components: dict[str, MetadataWidget]):
def fill_commponents(components: dict[str, DynamicFormItem]):
components["sample_name"].setValue("test name")
components["str_optional"].setValue(None)
components["str_required"].setValue("something")
@ -116,7 +116,7 @@ def fill_commponents(components: dict[str, MetadataWidget]):
def test_griditems_are_correct_class(
metadata_widget: tuple[ScanMetadata, dict[str, MetadataWidget]]
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]]
):
_, components = metadata_widget
assert isinstance(components["sample_name"], StrMetadataField)
@ -132,15 +132,15 @@ def test_griditems_are_correct_class(
assert isinstance(components["unsupported_class"], StrMetadataField)
def test_grid_to_dict(metadata_widget: tuple[ScanMetadata, dict[str, MetadataWidget]]):
def test_grid_to_dict(metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]]):
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"}
assert widget.get_form_data() == TEST_DICT | {"extra_field": "extra_data"}
def test_validation(metadata_widget: tuple[ScanMetadata, dict[str, MetadataWidget]]):
def test_validation(metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]]):
widget, components = metadata_widget = metadata_widget
assert widget._validity.compact_status.styleSheet().startswith(
widget._validity.compact_status.default_led[:114]
@ -161,7 +161,9 @@ def test_validation(metadata_widget: tuple[ScanMetadata, dict[str, MetadataWidge
components["float_nodefault"].setValue(True)
def test_numbers_clipped_to_limits(metadata_widget: tuple[ScanMetadata, dict[str, MetadataWidget]]):
def test_numbers_clipped_to_limits(
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]]
):
widget, components = metadata_widget = metadata_widget
fill_commponents(components)
@ -173,20 +175,20 @@ def test_numbers_clipped_to_limits(metadata_widget: tuple[ScanMetadata, dict[str
@pytest.fixture
def table():
table = AdditionalMetadataTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
table = DictBackedTable([["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):
def test_additional_metadata_table_add_row(table: DictBackedTable):
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):
def test_additional_metadata_table_delete_row(table: DictBackedTable):
assert table._table_model.rowCount() == 3
m = table._table_view.selectionModel()
item = table._table_view.indexAt(QPoint(0, 0)).siblingAtRow(1)
@ -196,14 +198,14 @@ def test_additional_metadata_table_delete_row(table: AdditionalMetadataTable):
assert list(table.dump_dict().keys()) == ["key1", "key3"]
def test_additional_metadata_allows_changes(table: AdditionalMetadataTable):
def test_additional_metadata_allows_changes(table: DictBackedTable):
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):
def test_additional_metadata_doesnt_allow_dupes(table: DictBackedTable):
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)