From a25c1a8039078c92789b717b3f8a553c75814c33 Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 13 May 2025 11:38:30 +0200 Subject: [PATCH] feat: (#493) add helpers to dynamic form widgets --- bec_widgets/utils/forms_from_types/forms.py | 103 ++++++++++++++---- bec_widgets/utils/forms_from_types/items.py | 11 +- .../editors/scan_metadata/scan_metadata.py | 2 +- tests/unit_tests/test_pydantic_model_form.py | 83 ++++++++++++++ 4 files changed, 175 insertions(+), 24 deletions(-) create mode 100644 tests/unit_tests/test_pydantic_model_form.py diff --git a/bec_widgets/utils/forms_from_types/forms.py b/bec_widgets/utils/forms_from_types/forms.py index 330a559d..8f9fd868 100644 --- a/bec_widgets/utils/forms_from_types/forms.py +++ b/bec_widgets/utils/forms_from_types/forms.py @@ -2,6 +2,7 @@ from __future__ import annotations from decimal import Decimal from types import NoneType +from typing import NamedTuple from bec_lib.logger import bec_logger from bec_qthemes import material_icon @@ -11,36 +12,50 @@ 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 +from bec_widgets.utils.error_popups import SafeProperty +from bec_widgets.utils.forms_from_types.items import ( + DynamicFormItem, + DynamicFormItemType, + FormItemSpec, + widget_from_type, +) logger = bec_logger.logger +class GridRow(NamedTuple): + i: int + label: QLabel + widget: DynamicFormItem + + class TypedForm(BECWidget, QWidget): PLUGIN = True ICON_NAME = "list_alt" value_changed = Signal() - RPC = False + RPC = True + USER_ACCESS = ["enabled", "enabled.setter"] def __init__( self, parent=None, items: list[tuple[str, type]] | None = None, form_item_specs: list[FormItemSpec] | None = None, + enabled: bool = True, 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. - + 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. + enabled (bool): whether fields are enabled for editing. """ if (items is not None and form_item_specs is not None) or ( items is None and form_item_specs is None @@ -59,6 +74,8 @@ class TypedForm(BECWidget, QWidget): self._layout.setContentsMargins(0, 0, 0, 0) self.setLayout(self._layout) + self._enabled: bool = enabled + self._form_grid_container = QWidget(parent=self) self._form_grid = QWidget(parent=self._form_grid_container) self._layout.addWidget(self._form_grid_container) @@ -82,15 +99,17 @@ class TypedForm(BECWidget, QWidget): widget.valueChanged.connect(self.value_changed) grid.addWidget(widget, row, 1) - def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]: + def enumerate_form_widgets(self): + """Return a generator over the rows of the form, with the row number, the label widget (to + which the field name is attached as a property), and the entry widget""" grid: QGridLayout = self._form_grid.layout() # type: ignore + for i in range(grid.rowCount()): + yield GridRow(i, grid.itemAtPosition(i, 0).widget(), grid.itemAtPosition(i, 1).widget()) + + def _dict_from_grid(self) -> dict[str, DynamicFormItemType]: 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()) + row.label.property("_model_field_name"): row.widget.getValue() + for row in self.enumerate_form_widgets() } def _clear_grid(self): @@ -107,6 +126,9 @@ class TypedForm(BECWidget, QWidget): self._form_grid.setLayout(self._new_grid_layout()) self._form_grid_container.layout().addWidget(self._form_grid) + self.update_size() + + def update_size(self): self._form_grid.adjustSize() self._form_grid_container.adjustSize() self.adjustSize() @@ -117,20 +139,50 @@ class TypedForm(BECWidget, QWidget): new_grid.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize) return new_grid + def _set_widgets_enabled(self, enabled: bool): + for row in self.enumerate_form_widgets(): + row.widget.setEnabled(enabled) + + @property + def widget_dict(self): + return { + row.label.property("_model_field_name"): row.widget + for row in self.enumerate_form_widgets() + } + + @SafeProperty(bool) + def enabled(self): + return self._enabled + + @enabled.setter + def enabled(self, value: bool): + self._enabled = value + self._set_widgets_enabled(value) + class PydanticModelForm(TypedForm): metadata_updated = Signal(dict) metadata_cleared = Signal(NoneType) - def __init__(self, parent=None, metadata_model: type[BaseModel] = None, client=None, **kwargs): + def __init__( + self, + parent=None, + data_model: type[BaseModel] | None = None, + enabled: bool = True, + client=None, + **kwargs, + ): """ A form generated from a pydantic model. Args: - metadata_model (type[BaseModel]): the model class for which to generate a form. + data_model (type[BaseModel]): the model class for which to generate a form. + enabled (bool): whether fields are enabled for editing. """ - self._md_schema = metadata_model - super().__init__(parent=parent, form_item_specs=self._form_item_specs(), client=client) + self._md_schema = data_model + super().__init__( + parent=parent, form_item_specs=self._form_item_specs(), enabled=enabled, client=client + ) self._validity = CompactPopupWidget() self._validity.compact_view = True # type: ignore @@ -147,6 +199,19 @@ class PydanticModelForm(TypedForm): self._md_schema = schema self.populate() + def set_data(self, data: BaseModel): + """Fill the data for the form. + + Args: + data (BaseModel): the data to enter into the form. Must be the same type as the + currently set schema, raises TypeError otherwise.""" + if not self._md_schema: + raise ValueError("Schema not set - can't set data") + if not isinstance(data, self._md_schema): + raise TypeError(f"Supplied data {data} not of type {self._md_schema}") + for form_item in self.enumerate_form_widgets(): + form_item.widget.setValue(getattr(data, form_item.label.property("_model_field_name"))) + def _form_item_specs(self): return [ FormItemSpec(name=name, info=info, item_type=info.annotation) diff --git a/bec_widgets/utils/forms_from_types/items.py b/bec_widgets/utils/forms_from_types/items.py index c052e59f..793ffbb6 100644 --- a/bec_widgets/utils/forms_from_types/items.py +++ b/bec_widgets/utils/forms_from_types/items.py @@ -94,6 +94,9 @@ class ClearableBoolEntry(QWidget): self._false.setToolTip(tooltip) +DynamicFormItemType = str | int | float | Decimal | bool + + class DynamicFormItem(QWidget): valueChanged = Signal() @@ -111,7 +114,7 @@ class DynamicFormItem(QWidget): self._add_clear_button() @abstractmethod - def getValue(self): ... + def getValue(self) -> DynamicFormItemType: ... @abstractmethod def setValue(self, value): ... @@ -204,7 +207,7 @@ class FloatDecimalMetadataField(DynamicFormItem): def _add_main_widget(self) -> None: self._main_widget = QDoubleSpinBox() self._layout.addWidget(self._main_widget) - min_, max_ = field_limits(self._spec.info, int) + min_, max_ = field_limits(self._spec.info, float) self._main_widget.setMinimum(min_) self._main_widget.setMaximum(max_) precision = field_precision(self._spec.info) @@ -224,10 +227,10 @@ class FloatDecimalMetadataField(DynamicFormItem): return self._default return self._main_widget.value() - def setValue(self, value: float): + def setValue(self, value: float | Decimal): if value is None: self._main_widget.clear() - self._main_widget.setValue(value) + self._main_widget.setValue(float(value)) class BoolMetadataField(DynamicFormItem): diff --git a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py index 918576d1..4742e370 100644 --- a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py +++ b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py @@ -45,7 +45,7 @@ class ScanMetadata(PydanticModelForm): self._md_schema = get_metadata_schema_for_scan(self._scan_name) self._additional_metadata.data_updated.connect(self.validate_form) - super().__init__(parent=parent, metadata_model=self._md_schema, client=client, **kwargs) + super().__init__(parent=parent, data_model=self._md_schema, client=client, **kwargs) self._layout.addWidget(self._additional_md_box) self._additional_md_box_layout.addWidget(self._additional_metadata) diff --git a/tests/unit_tests/test_pydantic_model_form.py b/tests/unit_tests/test_pydantic_model_form.py new file mode 100644 index 00000000..5cc6606d --- /dev/null +++ b/tests/unit_tests/test_pydantic_model_form.py @@ -0,0 +1,83 @@ +from decimal import Decimal + +import pytest +from pydantic import BaseModel, Field + +from bec_widgets.utils.forms_from_types.forms import PydanticModelForm +from bec_widgets.utils.forms_from_types.items import ( + BoolMetadataField, + DynamicFormItem, + FloatDecimalMetadataField, + IntMetadataField, + StrMetadataField, +) + +# pylint: disable=no-member +# pylint: disable=missing-function-docstring +# pylint: disable=redefined-outer-name +# pylint: disable=protected-access + + +class ExampleSchema(BaseModel): + 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_places=2, gt=1, le=34.5) + + +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": 123.456, + "decimal_dp_limits_nodefault": 34.5, +} + + +@pytest.fixture +def example_md(): + return ExampleSchema.model_validate(TEST_DICT) + + +@pytest.fixture +def model_widget(): + widget = PydanticModelForm(data_model=ExampleSchema) + widget.populate() + yield widget + widget._clear_grid() + widget.deleteLater() + + +def test_widget_dict(model_widget: PydanticModelForm): + assert isinstance(model_widget.widget_dict["str_optional"], StrMetadataField) + assert isinstance(model_widget.widget_dict["float_nodefault"], FloatDecimalMetadataField) + assert isinstance(model_widget.widget_dict["int_default"], IntMetadataField) + + +def test_widget_set_data(model_widget: PydanticModelForm): + data = ExampleSchema.model_validate(TEST_DICT) + model_widget.set_data(data) + for key in [ + "str_optional", + "str_required", + "bool_optional", + "bool_required_default", + "bool_required_nodefault", + "int_default", + "int_nodefault_optional", + "float_nodefault", + "decimal_dp_limits_nodefault", + ]: + assert model_widget.widget_dict[key].getValue() == TEST_DICT[key]