From d04770fe913474ec9d4e06b056c85e720d1470c4 Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 22 Apr 2025 20:50:33 +0200 Subject: [PATCH] refactor: rearrange base of metadata forms for generic use --- .../utils/forms_from_types/__init__.py | 0 bec_widgets/utils/forms_from_types/forms.py | 182 ++++++++++++++++++ .../forms_from_types/items.py} | 71 ++++--- .../control/scan_control/scan_control.py | 1 - ...metadata_table.py => dict_backed_table.py} | 33 +++- .../widgets/editors/scan_metadata/__init__.py | 7 - .../editors/scan_metadata/scan_metadata.py | 177 ++++------------- tests/unit_tests/test_scan_control.py | 10 +- tests/unit_tests/test_scan_metadata.py | 64 +++--- 9 files changed, 332 insertions(+), 213 deletions(-) create mode 100644 bec_widgets/utils/forms_from_types/__init__.py create mode 100644 bec_widgets/utils/forms_from_types/forms.py rename bec_widgets/{widgets/editors/scan_metadata/_metadata_widgets.py => utils/forms_from_types/items.py} (79%) rename bec_widgets/widgets/editors/{scan_metadata/additional_metadata_table.py => dict_backed_table.py} (80%) diff --git a/bec_widgets/utils/forms_from_types/__init__.py b/bec_widgets/utils/forms_from_types/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/utils/forms_from_types/forms.py b/bec_widgets/utils/forms_from_types/forms.py new file mode 100644 index 00000000..421f0079 --- /dev/null +++ b/bec_widgets/utils/forms_from_types/forms.py @@ -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 diff --git a/bec_widgets/widgets/editors/scan_metadata/_metadata_widgets.py b/bec_widgets/utils/forms_from_types/items.py similarity index 79% rename from bec_widgets/widgets/editors/scan_metadata/_metadata_widgets.py rename to bec_widgets/utils/forms_from_types/items.py index 8b48a639..c052e59f 100644 --- a/bec_widgets/widgets/editors/scan_metadata/_metadata_widgets.py +++ b/bec_widgets/utils/forms_from_types/items.py @@ -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]: diff --git a/bec_widgets/widgets/control/scan_control/scan_control.py b/bec_widgets/widgets/control/scan_control/scan_control.py index a2a82b70..2869c43f 100644 --- a/bec_widgets/widgets/control/scan_control/scan_control.py +++ b/bec_widgets/widgets/control/scan_control/scan_control.py @@ -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 diff --git a/bec_widgets/widgets/editors/scan_metadata/additional_metadata_table.py b/bec_widgets/widgets/editors/dict_backed_table.py similarity index 80% rename from bec_widgets/widgets/editors/scan_metadata/additional_metadata_table.py rename to bec_widgets/widgets/editors/dict_backed_table.py index a334f0fa..78a76b66 100644 --- a/bec_widgets/widgets/editors/scan_metadata/additional_metadata_table.py +++ b/bec_widgets/widgets/editors/dict_backed_table.py @@ -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() diff --git a/bec_widgets/widgets/editors/scan_metadata/__init__.py b/bec_widgets/widgets/editors/scan_metadata/__init__.py index 3cf0e0af..e69de29b 100644 --- a/bec_widgets/widgets/editors/scan_metadata/__init__.py +++ b/bec_widgets/widgets/editors/scan_metadata/__init__.py @@ -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"] diff --git a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py index 0dc8207d..8e4529af 100644 --- a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py +++ b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py @@ -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() diff --git a/tests/unit_tests/test_scan_control.py b/tests/unit_tests/test_scan_control.py index cbf1c195..ab9842c1 100644 --- a/tests/unit_tests/test_scan_control.py +++ b/tests/unit_tests/test_scan_control.py @@ -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"], diff --git a/tests/unit_tests/test_scan_metadata.py b/tests/unit_tests/test_scan_metadata.py index 1d0ab148..0a89c5f9 100644 --- a/tests/unit_tests/test_scan_metadata.py +++ b/tests/unit_tests/test_scan_metadata.py @@ -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)