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:
@ -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
|
||||
|
@ -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"""
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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!"
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user