From 7b9e5f7234c450f64814b300a5576eced82c68f5 Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 13 May 2025 17:09:06 +0200 Subject: [PATCH] feat: (#493) add dict to dynamic form types --- bec_widgets/utils/forms_from_types/items.py | 32 +++++++++++++++++-- .../widgets/editors/dict_backed_table.py | 18 ++++++++--- .../widgets/editors/scan_metadata/_util.py | 11 ++++--- .../editors/scan_metadata/scan_metadata.py | 2 +- tests/unit_tests/test_pydantic_model_form.py | 4 +-- tests/unit_tests/test_scan_metadata.py | 22 +++++++++---- 6 files changed, 66 insertions(+), 23 deletions(-) diff --git a/bec_widgets/utils/forms_from_types/items.py b/bec_widgets/utils/forms_from_types/items.py index 793ffbb6..1a58f749 100644 --- a/bec_widgets/utils/forms_from_types/items.py +++ b/bec_widgets/utils/forms_from_types/items.py @@ -26,6 +26,7 @@ from qtpy.QtWidgets import ( QWidget, ) +from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable from bec_widgets.widgets.editors.scan_metadata._util import ( clearable_required, field_default, @@ -94,7 +95,7 @@ class ClearableBoolEntry(QWidget): self._false.setToolTip(tooltip) -DynamicFormItemType = str | int | float | Decimal | bool +DynamicFormItemType = str | int | float | Decimal | bool | dict class DynamicFormItem(QWidget): @@ -205,12 +206,12 @@ class FloatDecimalMetadataField(DynamicFormItem): self._main_widget.textChanged.connect(self._value_changed) def _add_main_widget(self) -> None: + precision = field_precision(self._spec.info) self._main_widget = QDoubleSpinBox() self._layout.addWidget(self._main_widget) - min_, max_ = field_limits(self._spec.info, float) + min_, max_ = field_limits(self._spec.info, float, precision) self._main_widget.setMinimum(min_) self._main_widget.setMaximum(max_) - 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}" @@ -254,6 +255,23 @@ class BoolMetadataField(DynamicFormItem): self._main_widget.setChecked(value) +class DictMetadataField(DynamicFormItem): + def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None: + super().__init__(parent=parent, spec=spec) + self._main_widget.data_changed.connect(self._value_changed) + + def _add_main_widget(self) -> None: + self._main_widget = DictBackedTable([]) + self._layout.addWidget(self._main_widget) + self._main_widget.setToolTip(self._describe("")) + + def getValue(self): + return self._main_widget.dump_dict() + + def setValue(self, value): + self._main_widget.replace_data(value) + + def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormItem]: if annotation in [str, str | None]: return StrMetadataField @@ -263,6 +281,14 @@ def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormIte return FloatDecimalMetadataField if annotation in [bool, bool | None]: return BoolMetadataField + if annotation in [dict, dict | None] or ( + isinstance(annotation, GenericAlias) and annotation.__origin__ is dict + ): + return DictMetadataField + if annotation in [list, list | None] or ( + isinstance(annotation, GenericAlias) and annotation.__origin__ is list + ): + return StrMetadataField else: logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.") return StrMetadataField diff --git a/bec_widgets/widgets/editors/dict_backed_table.py b/bec_widgets/widgets/editors/dict_backed_table.py index 99a0439f..05e42543 100644 --- a/bec_widgets/widgets/editors/dict_backed_table.py +++ b/bec_widgets/widgets/editors/dict_backed_table.py @@ -57,6 +57,11 @@ class DictBackedTableModel(QAbstractTableModel): return True return False + def replaceData(self, data: dict): + self.resetInternalData() + self._data = [[k, v] for k, v in data.items()] + self.dataChanged.emit(self.index(0, 0), self.index(len(self._data), 0)) + def update_disallowed_keys(self, keys: list[str]): """Set the list of keys which may not be used. @@ -110,7 +115,7 @@ class DictBackedTableModel(QAbstractTableModel): class DictBackedTable(QWidget): delete_rows = Signal(list) - data_updated = Signal() + data_changed = Signal(dict) def __init__(self, initial_data: list[list[str]]): """Widget which uses a DictBackedTableModel to display an editable table @@ -143,11 +148,14 @@ class DictBackedTable(QWidget): 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) - self._table_model.dataChanged.connect(self._emit_data_updated) + self._table_model.dataChanged.connect(lambda *_: self.data_changed.emit(self.dump_dict())) - def _emit_data_updated(self, *args, **kwargs): - """Just to swallow the args""" - self.data_updated.emit() + @SafeSlot() + def clear(self): + self._table_model.replaceData({}) + + def replace_data(self, data: dict): + self._table_model.replaceData(data) def delete_selected_rows(self): """Delete rows which are part of the selection model""" diff --git a/bec_widgets/widgets/editors/scan_metadata/_util.py b/bec_widgets/widgets/editors/scan_metadata/_util.py index dfe373de..3e4fa092 100644 --- a/bec_widgets/widgets/editors/scan_metadata/_util.py +++ b/bec_widgets/widgets/editors/scan_metadata/_util.py @@ -2,7 +2,7 @@ from __future__ import annotations import sys from decimal import Decimal -from math import inf, nextafter +from math import copysign, inf, nextafter from typing import TYPE_CHECKING, TypeVar, get_args from annotated_types import Ge, Gt, Le, Lt @@ -23,16 +23,19 @@ _MAXFLOAT = sys.float_info.max T = TypeVar("T", int, float, Decimal) -def field_limits(info: FieldInfo, type_: type[T]) -> tuple[T, T]: +def field_limits(info: FieldInfo, type_: type[T], prec: int | None = None) -> tuple[T, T]: + def _nextafter(x, y): + return nextafter(x, y) if prec is None else x + (10 ** (-prec)) * (copysign(1, y)) + _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 + _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 + _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 diff --git a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py index 4742e370..5df8003e 100644 --- a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py +++ b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py @@ -43,7 +43,7 @@ class ScanMetadata(PydanticModelForm): self._additional_metadata = DictBackedTable(initial_extras or []) self._scan_name = scan_name or "" self._md_schema = get_metadata_schema_for_scan(self._scan_name) - self._additional_metadata.data_updated.connect(self.validate_form) + self._additional_metadata.data_changed.connect(self.validate_form) super().__init__(parent=parent, data_model=self._md_schema, client=client, **kwargs) diff --git a/tests/unit_tests/test_pydantic_model_form.py b/tests/unit_tests/test_pydantic_model_form.py index 5cc6606d..0841b868 100644 --- a/tests/unit_tests/test_pydantic_model_form.py +++ b/tests/unit_tests/test_pydantic_model_form.py @@ -5,8 +5,6 @@ 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, @@ -34,7 +32,7 @@ class ExampleSchema(BaseModel): TEST_DICT = { "sample_name": "test name", - "str_optional": None, + "str_optional": "None", "str_required": "something", "bool_optional": None, "bool_required_default": True, diff --git a/tests/unit_tests/test_scan_metadata.py b/tests/unit_tests/test_scan_metadata.py index 0a93f2bd..a563b76a 100644 --- a/tests/unit_tests/test_scan_metadata.py +++ b/tests/unit_tests/test_scan_metadata.py @@ -1,4 +1,5 @@ from decimal import Decimal +from typing import Set import pytest from bec_lib.metadata_schema import BasicScanMetadata @@ -8,6 +9,7 @@ from qtpy.QtCore import QItemSelectionModel, QPoint, Qt from bec_widgets.utils.forms_from_types.items import ( BoolMetadataField, + DictMetadataField, DynamicFormItem, FloatDecimalMetadataField, IntMetadataField, @@ -34,12 +36,13 @@ class ExampleSchema(BasicScanMetadata): 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) + dict_default: dict = Field(default_factory=dict) + unsupported_class: Json = Field(default=set()) TEST_DICT = { "sample_name": "test name", - "str_optional": None, + "str_optional": "None", "str_required": "something", "bool_optional": None, "bool_required_default": True, @@ -47,8 +50,9 @@ TEST_DICT = { "int_default": 21, "int_nodefault_optional": -10, "float_nodefault": pytest.approx(0.1), - "decimal_dp_limits_nodefault": pytest.approx(34), - "unsupported_class": '{"key": "value"}', + "decimal_dp_limits_nodefault": pytest.approx(34.5), + "dict_default": {"test_dict": "values"}, + "unsupported_class": '["set", "item"]', } @@ -82,7 +86,8 @@ def metadata_widget(empty_metadata_widget: ScanMetadata): 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() + dict_default = widget._form_grid.layout().itemAtPosition(10, 1).widget() + unsupported_class = widget._form_grid.layout().itemAtPosition(11, 1).widget() yield ( widget, @@ -97,6 +102,7 @@ def metadata_widget(empty_metadata_widget: ScanMetadata): "int_nodefault_optional": int_nodefault_optional, "float_nodefault": float_nodefault, "decimal_dp_limits_nodefault": decimal_dp_limits_nodefault, + "dict_default": dict_default, "unsupported_class": unsupported_class, }, ) @@ -112,7 +118,8 @@ def fill_commponents(components: dict[str, DynamicFormItem]): 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"}') + components["dict_default"].setValue({"test_dict": "values"}) + components["unsupported_class"].setValue(r'["set", "item"]') def test_griditems_are_correct_class( @@ -129,6 +136,7 @@ def test_griditems_are_correct_class( assert isinstance(components["int_nodefault_optional"], IntMetadataField) assert isinstance(components["float_nodefault"], FloatDecimalMetadataField) assert isinstance(components["decimal_dp_limits_nodefault"], FloatDecimalMetadataField) + assert isinstance(components["dict_default"], DictMetadataField) assert isinstance(components["unsupported_class"], StrMetadataField) @@ -168,8 +176,8 @@ def test_numbers_clipped_to_limits( fill_commponents(components) components["decimal_dp_limits_nodefault"].setValue(-56) + assert components["decimal_dp_limits_nodefault"].getValue() == pytest.approx(1.01) widget.validate_form() - assert components["decimal_dp_limits_nodefault"].getValue() == pytest.approx(2) assert widget._validity_message.text() == "No errors!"