0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-13 11:11:49 +02:00

feat: (#493) add dict to dynamic form types

This commit is contained in:
2025-05-13 17:09:06 +02:00
committed by David Perl
parent a25c1a8039
commit 92d1d6435d
6 changed files with 66 additions and 23 deletions

View File

@ -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

View File

@ -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"""

View File

@ -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

View File

@ -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)

View File

@ -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,

View File

@ -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!"