mirror of
https://github.com/bec-project/bec_widgets.git
synced 2025-07-13 19:21:50 +02:00
feat: (#493) add dict to dynamic form types
This commit is contained in:
@ -26,6 +26,7 @@ from qtpy.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
|
||||||
from bec_widgets.widgets.editors.scan_metadata._util import (
|
from bec_widgets.widgets.editors.scan_metadata._util import (
|
||||||
clearable_required,
|
clearable_required,
|
||||||
field_default,
|
field_default,
|
||||||
@ -94,7 +95,7 @@ class ClearableBoolEntry(QWidget):
|
|||||||
self._false.setToolTip(tooltip)
|
self._false.setToolTip(tooltip)
|
||||||
|
|
||||||
|
|
||||||
DynamicFormItemType = str | int | float | Decimal | bool
|
DynamicFormItemType = str | int | float | Decimal | bool | dict
|
||||||
|
|
||||||
|
|
||||||
class DynamicFormItem(QWidget):
|
class DynamicFormItem(QWidget):
|
||||||
@ -205,12 +206,12 @@ class FloatDecimalMetadataField(DynamicFormItem):
|
|||||||
self._main_widget.textChanged.connect(self._value_changed)
|
self._main_widget.textChanged.connect(self._value_changed)
|
||||||
|
|
||||||
def _add_main_widget(self) -> None:
|
def _add_main_widget(self) -> None:
|
||||||
|
precision = field_precision(self._spec.info)
|
||||||
self._main_widget = QDoubleSpinBox()
|
self._main_widget = QDoubleSpinBox()
|
||||||
self._layout.addWidget(self._main_widget)
|
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.setMinimum(min_)
|
||||||
self._main_widget.setMaximum(max_)
|
self._main_widget.setMaximum(max_)
|
||||||
precision = field_precision(self._spec.info)
|
|
||||||
if precision:
|
if precision:
|
||||||
self._main_widget.setDecimals(precision)
|
self._main_widget.setDecimals(precision)
|
||||||
minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
|
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)
|
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]:
|
def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormItem]:
|
||||||
if annotation in [str, str | None]:
|
if annotation in [str, str | None]:
|
||||||
return StrMetadataField
|
return StrMetadataField
|
||||||
@ -263,6 +281,14 @@ def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormIte
|
|||||||
return FloatDecimalMetadataField
|
return FloatDecimalMetadataField
|
||||||
if annotation in [bool, bool | None]:
|
if annotation in [bool, bool | None]:
|
||||||
return BoolMetadataField
|
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:
|
else:
|
||||||
logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
|
logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
|
||||||
return StrMetadataField
|
return StrMetadataField
|
||||||
|
@ -57,6 +57,11 @@ class DictBackedTableModel(QAbstractTableModel):
|
|||||||
return True
|
return True
|
||||||
return False
|
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]):
|
def update_disallowed_keys(self, keys: list[str]):
|
||||||
"""Set the list of keys which may not be used.
|
"""Set the list of keys which may not be used.
|
||||||
|
|
||||||
@ -110,7 +115,7 @@ class DictBackedTableModel(QAbstractTableModel):
|
|||||||
|
|
||||||
class DictBackedTable(QWidget):
|
class DictBackedTable(QWidget):
|
||||||
delete_rows = Signal(list)
|
delete_rows = Signal(list)
|
||||||
data_updated = Signal()
|
data_changed = Signal(dict)
|
||||||
|
|
||||||
def __init__(self, initial_data: list[list[str]]):
|
def __init__(self, initial_data: list[list[str]]):
|
||||||
"""Widget which uses a DictBackedTableModel to display an editable table
|
"""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._add_button.clicked.connect(self._table_model.add_row)
|
||||||
self._remove_button.clicked.connect(self.delete_selected_rows)
|
self._remove_button.clicked.connect(self.delete_selected_rows)
|
||||||
self.delete_rows.connect(self._table_model.delete_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):
|
@SafeSlot()
|
||||||
"""Just to swallow the args"""
|
def clear(self):
|
||||||
self.data_updated.emit()
|
self._table_model.replaceData({})
|
||||||
|
|
||||||
|
def replace_data(self, data: dict):
|
||||||
|
self._table_model.replaceData(data)
|
||||||
|
|
||||||
def delete_selected_rows(self):
|
def delete_selected_rows(self):
|
||||||
"""Delete rows which are part of the selection model"""
|
"""Delete rows which are part of the selection model"""
|
||||||
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from math import inf, nextafter
|
from math import copysign, inf, nextafter
|
||||||
from typing import TYPE_CHECKING, TypeVar, get_args
|
from typing import TYPE_CHECKING, TypeVar, get_args
|
||||||
|
|
||||||
from annotated_types import Ge, Gt, Le, Lt
|
from annotated_types import Ge, Gt, Le, Lt
|
||||||
@ -23,16 +23,19 @@ _MAXFLOAT = sys.float_info.max
|
|||||||
T = TypeVar("T", int, float, Decimal)
|
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
|
_min = _MININT if type_ is int else _MINFLOAT
|
||||||
_max = _MAXINT if type_ is int else _MAXFLOAT
|
_max = _MAXINT if type_ is int else _MAXFLOAT
|
||||||
for md in info.metadata:
|
for md in info.metadata:
|
||||||
if isinstance(md, Ge):
|
if isinstance(md, Ge):
|
||||||
_min = type_(md.ge) # type: ignore
|
_min = type_(md.ge) # type: ignore
|
||||||
if isinstance(md, Gt):
|
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):
|
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):
|
if isinstance(md, Le):
|
||||||
_max = type_(md.le) # type: ignore
|
_max = type_(md.le) # type: ignore
|
||||||
return _min, _max # type: ignore
|
return _min, _max # type: ignore
|
||||||
|
@ -43,7 +43,7 @@ class ScanMetadata(PydanticModelForm):
|
|||||||
self._additional_metadata = DictBackedTable(initial_extras or [])
|
self._additional_metadata = DictBackedTable(initial_extras or [])
|
||||||
self._scan_name = scan_name or ""
|
self._scan_name = scan_name or ""
|
||||||
self._md_schema = get_metadata_schema_for_scan(self._scan_name)
|
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)
|
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.forms import PydanticModelForm
|
||||||
from bec_widgets.utils.forms_from_types.items import (
|
from bec_widgets.utils.forms_from_types.items import (
|
||||||
BoolMetadataField,
|
|
||||||
DynamicFormItem,
|
|
||||||
FloatDecimalMetadataField,
|
FloatDecimalMetadataField,
|
||||||
IntMetadataField,
|
IntMetadataField,
|
||||||
StrMetadataField,
|
StrMetadataField,
|
||||||
@ -34,7 +32,7 @@ class ExampleSchema(BaseModel):
|
|||||||
|
|
||||||
TEST_DICT = {
|
TEST_DICT = {
|
||||||
"sample_name": "test name",
|
"sample_name": "test name",
|
||||||
"str_optional": None,
|
"str_optional": "None",
|
||||||
"str_required": "something",
|
"str_required": "something",
|
||||||
"bool_optional": None,
|
"bool_optional": None,
|
||||||
"bool_required_default": True,
|
"bool_required_default": True,
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from typing import Set
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from bec_lib.metadata_schema import BasicScanMetadata
|
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 (
|
from bec_widgets.utils.forms_from_types.items import (
|
||||||
BoolMetadataField,
|
BoolMetadataField,
|
||||||
|
DictMetadataField,
|
||||||
DynamicFormItem,
|
DynamicFormItem,
|
||||||
FloatDecimalMetadataField,
|
FloatDecimalMetadataField,
|
||||||
IntMetadataField,
|
IntMetadataField,
|
||||||
@ -34,12 +36,13 @@ class ExampleSchema(BasicScanMetadata):
|
|||||||
int_nodefault_optional: int | None = Field(lt=-1, ge=-44)
|
int_nodefault_optional: int | None = Field(lt=-1, ge=-44)
|
||||||
float_nodefault: float
|
float_nodefault: float
|
||||||
decimal_dp_limits_nodefault: Decimal = Field(Decimal(1.23), decimal_places=2, gt=1, le=34.5)
|
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 = {
|
TEST_DICT = {
|
||||||
"sample_name": "test name",
|
"sample_name": "test name",
|
||||||
"str_optional": None,
|
"str_optional": "None",
|
||||||
"str_required": "something",
|
"str_required": "something",
|
||||||
"bool_optional": None,
|
"bool_optional": None,
|
||||||
"bool_required_default": True,
|
"bool_required_default": True,
|
||||||
@ -47,8 +50,9 @@ TEST_DICT = {
|
|||||||
"int_default": 21,
|
"int_default": 21,
|
||||||
"int_nodefault_optional": -10,
|
"int_nodefault_optional": -10,
|
||||||
"float_nodefault": pytest.approx(0.1),
|
"float_nodefault": pytest.approx(0.1),
|
||||||
"decimal_dp_limits_nodefault": pytest.approx(34),
|
"decimal_dp_limits_nodefault": pytest.approx(34.5),
|
||||||
"unsupported_class": '{"key": "value"}',
|
"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()
|
int_nodefault_optional = widget._form_grid.layout().itemAtPosition(7, 1).widget()
|
||||||
float_nodefault = widget._form_grid.layout().itemAtPosition(8, 1).widget()
|
float_nodefault = widget._form_grid.layout().itemAtPosition(8, 1).widget()
|
||||||
decimal_dp_limits_nodefault = widget._form_grid.layout().itemAtPosition(9, 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 (
|
yield (
|
||||||
widget,
|
widget,
|
||||||
@ -97,6 +102,7 @@ def metadata_widget(empty_metadata_widget: ScanMetadata):
|
|||||||
"int_nodefault_optional": int_nodefault_optional,
|
"int_nodefault_optional": int_nodefault_optional,
|
||||||
"float_nodefault": float_nodefault,
|
"float_nodefault": float_nodefault,
|
||||||
"decimal_dp_limits_nodefault": decimal_dp_limits_nodefault,
|
"decimal_dp_limits_nodefault": decimal_dp_limits_nodefault,
|
||||||
|
"dict_default": dict_default,
|
||||||
"unsupported_class": unsupported_class,
|
"unsupported_class": unsupported_class,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -112,7 +118,8 @@ def fill_commponents(components: dict[str, DynamicFormItem]):
|
|||||||
components["int_nodefault_optional"].setValue(-10)
|
components["int_nodefault_optional"].setValue(-10)
|
||||||
components["float_nodefault"].setValue(0.1)
|
components["float_nodefault"].setValue(0.1)
|
||||||
components["decimal_dp_limits_nodefault"].setValue(456.789)
|
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(
|
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["int_nodefault_optional"], IntMetadataField)
|
||||||
assert isinstance(components["float_nodefault"], FloatDecimalMetadataField)
|
assert isinstance(components["float_nodefault"], FloatDecimalMetadataField)
|
||||||
assert isinstance(components["decimal_dp_limits_nodefault"], FloatDecimalMetadataField)
|
assert isinstance(components["decimal_dp_limits_nodefault"], FloatDecimalMetadataField)
|
||||||
|
assert isinstance(components["dict_default"], DictMetadataField)
|
||||||
assert isinstance(components["unsupported_class"], StrMetadataField)
|
assert isinstance(components["unsupported_class"], StrMetadataField)
|
||||||
|
|
||||||
|
|
||||||
@ -168,8 +176,8 @@ def test_numbers_clipped_to_limits(
|
|||||||
fill_commponents(components)
|
fill_commponents(components)
|
||||||
|
|
||||||
components["decimal_dp_limits_nodefault"].setValue(-56)
|
components["decimal_dp_limits_nodefault"].setValue(-56)
|
||||||
|
assert components["decimal_dp_limits_nodefault"].getValue() == pytest.approx(1.01)
|
||||||
widget.validate_form()
|
widget.validate_form()
|
||||||
assert components["decimal_dp_limits_nodefault"].getValue() == pytest.approx(2)
|
|
||||||
assert widget._validity_message.text() == "No errors!"
|
assert widget._validity_message.text() == "No errors!"
|
||||||
|
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user