bec-widgets 1.20.0__py3-none-any.whl → 1.21.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
CHANGELOG.md CHANGED
@@ -1,6 +1,14 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v1.21.0 (2025-02-17)
5
+
6
+ ### Features
7
+
8
+ - Generated form for scan metadata
9
+ ([`1708bd4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1708bd405f86b1353828b01fbf5f98383a19ec2a))
10
+
11
+
4
12
  ## v1.20.0 (2025-02-06)
5
13
 
6
14
  ### Features
PKG-INFO CHANGED
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bec_widgets
3
- Version: 1.20.0
3
+ Version: 1.21.0
4
4
  Summary: BEC Widgets
5
5
  Project-URL: Bug Tracker, https://gitlab.psi.ch/bec/bec_widgets/issues
6
6
  Project-URL: Homepage, https://gitlab.psi.ch/bec/bec_widgets
@@ -0,0 +1,7 @@
1
+ from bec_widgets.widgets.editors.scan_metadata.additional_metadata_table import (
2
+ AdditionalMetadataTable,
3
+ AdditionalMetadataTableModel,
4
+ )
5
+ from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
6
+
7
+ __all__ = ["ScanMetadata", "AdditionalMetadataTable", "AdditionalMetadataTableModel"]
@@ -0,0 +1,275 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import abstractmethod
4
+ from decimal import Decimal
5
+ from typing import TYPE_CHECKING, Callable, get_args
6
+
7
+ from bec_lib.logger import bec_logger
8
+ from bec_qthemes import material_icon
9
+ from pydantic import BaseModel, Field
10
+ from qtpy.QtCore import Signal # type: ignore
11
+ from qtpy.QtWidgets import (
12
+ QApplication,
13
+ QButtonGroup,
14
+ QCheckBox,
15
+ QDoubleSpinBox,
16
+ QGridLayout,
17
+ QHBoxLayout,
18
+ QLabel,
19
+ QLayout,
20
+ QLineEdit,
21
+ QRadioButton,
22
+ QSpinBox,
23
+ QToolButton,
24
+ QWidget,
25
+ )
26
+
27
+ from bec_widgets.widgets.editors.scan_metadata._util import (
28
+ clearable_required,
29
+ field_default,
30
+ field_limits,
31
+ field_maxlen,
32
+ field_minlen,
33
+ field_precision,
34
+ )
35
+
36
+ if TYPE_CHECKING:
37
+ from pydantic.fields import FieldInfo
38
+
39
+ logger = bec_logger.logger
40
+
41
+
42
+ class ClearableBoolEntry(QWidget):
43
+ stateChanged = Signal()
44
+
45
+ def __init__(self, parent: QWidget | None = None) -> None:
46
+ super().__init__(parent)
47
+ self._layout = QHBoxLayout()
48
+ self._layout.setContentsMargins(0, 0, 0, 0)
49
+ self.setLayout(self._layout)
50
+ self._layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
51
+ self._entry = QButtonGroup()
52
+ self._true = QRadioButton("true", parent=self)
53
+ self._false = QRadioButton("false", parent=self)
54
+ for button in [self._true, self._false]:
55
+ self._layout.addWidget(button)
56
+ self._entry.addButton(button)
57
+ button.toggled.connect(self.stateChanged)
58
+
59
+ def clear(self):
60
+ self._entry.setExclusive(False)
61
+ self._true.setChecked(False)
62
+ self._false.setChecked(False)
63
+ self._entry.setExclusive(True)
64
+
65
+ def isChecked(self) -> bool | None:
66
+ if not self._true.isChecked() and not self._false.isChecked():
67
+ return None
68
+ return self._true.isChecked()
69
+
70
+ def setChecked(self, value: bool | None):
71
+ if value is None:
72
+ self.clear()
73
+ elif value:
74
+ self._true.setChecked(True)
75
+ self._false.setChecked(False)
76
+ else:
77
+ self._true.setChecked(False)
78
+ self._false.setChecked(True)
79
+
80
+ def setToolTip(self, tooltip: str):
81
+ self._true.setToolTip(tooltip)
82
+ self._false.setToolTip(tooltip)
83
+
84
+
85
+ class MetadataWidget(QWidget):
86
+
87
+ valueChanged = Signal()
88
+
89
+ def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
90
+ super().__init__(parent)
91
+ self._info = info
92
+ self._layout = QHBoxLayout()
93
+ self._layout.setContentsMargins(0, 0, 0, 0)
94
+ self._layout.setSizeConstraint(QLayout.SizeConstraint.SetMaximumSize)
95
+ self._default = field_default(self._info)
96
+ self._desc = self._info.description
97
+ self.setLayout(self._layout)
98
+ self._add_main_widget()
99
+ if clearable_required(info):
100
+ self._add_clear_button()
101
+
102
+ @abstractmethod
103
+ def getValue(self): ...
104
+
105
+ @abstractmethod
106
+ def setValue(self, value): ...
107
+
108
+ @abstractmethod
109
+ def _add_main_widget(self) -> None:
110
+ """Add the main data entry widget to self._main_widget and appply any
111
+ constraints from the field info"""
112
+
113
+ def _describe(self, pad=" "):
114
+ return pad + (self._desc if self._desc else "")
115
+
116
+ def _add_clear_button(self):
117
+ self._clear_button = QToolButton()
118
+ self._clear_button.setIcon(
119
+ material_icon(icon_name="close", size=(10, 10), convert_to_pixmap=False)
120
+ )
121
+ self._layout.addWidget(self._clear_button)
122
+ # the widget added in _add_main_widget must implement .clear() if value is not required
123
+ self._clear_button.setToolTip("Clear value or reset to default.")
124
+ self._clear_button.clicked.connect(self._main_widget.clear) # type: ignore
125
+
126
+ def _value_changed(self, *_, **__):
127
+ self.valueChanged.emit()
128
+
129
+
130
+ class StrMetadataField(MetadataWidget):
131
+ def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
132
+ super().__init__(info, parent)
133
+ self._main_widget.textChanged.connect(self._value_changed)
134
+
135
+ def _add_main_widget(self) -> None:
136
+ self._main_widget = QLineEdit()
137
+ self._layout.addWidget(self._main_widget)
138
+ min_length, max_length = field_minlen(self._info), field_maxlen(self._info)
139
+ if max_length:
140
+ self._main_widget.setMaxLength(max_length)
141
+ self._main_widget.setToolTip(
142
+ f"(length min: {min_length} max: {max_length}){self._describe()}"
143
+ )
144
+ if self._default:
145
+ self._main_widget.setText(self._default)
146
+ self._add_clear_button()
147
+
148
+ def getValue(self):
149
+ if self._main_widget.text() == "":
150
+ return self._default
151
+ return self._main_widget.text()
152
+
153
+ def setValue(self, value: str):
154
+ if value is None:
155
+ self._main_widget.setText("")
156
+ self._main_widget.setText(value)
157
+
158
+
159
+ class IntMetadataField(MetadataWidget):
160
+ def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
161
+ super().__init__(info, parent)
162
+ self._main_widget.textChanged.connect(self._value_changed)
163
+
164
+ def _add_main_widget(self) -> None:
165
+ self._main_widget = QSpinBox()
166
+ self._layout.addWidget(self._main_widget)
167
+ min_, max_ = field_limits(self._info, int)
168
+ self._main_widget.setMinimum(min_)
169
+ self._main_widget.setMaximum(max_)
170
+ self._main_widget.setToolTip(f"(range {min_} to {max_}){self._describe()}")
171
+ if self._default is not None:
172
+ self._main_widget.setValue(self._default)
173
+ self._add_clear_button()
174
+ else:
175
+ self._main_widget.clear()
176
+
177
+ def getValue(self):
178
+ if self._main_widget.text() == "":
179
+ return self._default
180
+ return self._main_widget.value()
181
+
182
+ def setValue(self, value: int):
183
+ if value is None:
184
+ self._main_widget.clear()
185
+ self._main_widget.setValue(value)
186
+
187
+
188
+ class FloatDecimalMetadataField(MetadataWidget):
189
+ def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
190
+ super().__init__(info, parent)
191
+ self._main_widget.textChanged.connect(self._value_changed)
192
+
193
+ def _add_main_widget(self) -> None:
194
+ self._main_widget = QDoubleSpinBox()
195
+ self._layout.addWidget(self._main_widget)
196
+ min_, max_ = field_limits(self._info, int)
197
+ self._main_widget.setMinimum(min_)
198
+ self._main_widget.setMaximum(max_)
199
+ precision = field_precision(self._info)
200
+ if precision:
201
+ self._main_widget.setDecimals(precision)
202
+ minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
203
+ maxstr = f"{float(max_):.3f}" if abs(max_) <= 1000 else f"{float(max_):.3e}"
204
+ self._main_widget.setToolTip(f"(range {minstr} to {maxstr}){self._describe()}")
205
+ if self._default is not None:
206
+ self._main_widget.setValue(self._default)
207
+ self._add_clear_button()
208
+ else:
209
+ self._main_widget.clear()
210
+
211
+ def getValue(self):
212
+ if self._main_widget.text() == "":
213
+ return self._default
214
+ return self._main_widget.value()
215
+
216
+ def setValue(self, value: float):
217
+ if value is None:
218
+ self._main_widget.clear()
219
+ self._main_widget.setValue(value)
220
+
221
+
222
+ class BoolMetadataField(MetadataWidget):
223
+ def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
224
+ super().__init__(info, parent)
225
+ self._main_widget.stateChanged.connect(self._value_changed)
226
+
227
+ def _add_main_widget(self) -> None:
228
+ if clearable_required(self._info):
229
+ self._main_widget = ClearableBoolEntry()
230
+ else:
231
+ self._main_widget = QCheckBox()
232
+ self._layout.addWidget(self._main_widget)
233
+ self._main_widget.setToolTip(self._describe(""))
234
+ self._main_widget.setChecked(self._default) # type: ignore # if there is no default then it will be ClearableBoolEntry and can be set with None
235
+
236
+ def getValue(self):
237
+ return self._main_widget.isChecked()
238
+
239
+ def setValue(self, value):
240
+ self._main_widget.setChecked(value)
241
+
242
+
243
+ def widget_from_type(annotation: type | None) -> Callable[[FieldInfo], MetadataWidget]:
244
+ if annotation in [str, str | None]:
245
+ return StrMetadataField
246
+ if annotation in [int, int | None]:
247
+ return IntMetadataField
248
+ if annotation in [float, float | None, Decimal, Decimal | None]:
249
+ return FloatDecimalMetadataField
250
+ if annotation in [bool, bool | None]:
251
+ return BoolMetadataField
252
+ else:
253
+ logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
254
+ return StrMetadataField
255
+
256
+
257
+ if __name__ == "__main__": # pragma: no cover
258
+
259
+ class TestModel(BaseModel):
260
+ value1: str | None = Field(None)
261
+ value2: bool | None = Field(None)
262
+ value3: bool = Field(True)
263
+ value4: int = Field(123)
264
+ value5: int | None = Field()
265
+
266
+ app = QApplication([])
267
+ w = QWidget()
268
+ layout = QGridLayout()
269
+ w.setLayout(layout)
270
+ for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
271
+ layout.addWidget(QLabel(field_name), i, 0)
272
+ layout.addWidget(widget_from_type(info.annotation)(info), i, 1)
273
+
274
+ w.show()
275
+ app.exec()
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from decimal import Decimal
5
+ from math import inf, nextafter
6
+ from typing import TYPE_CHECKING, TypeVar, get_args
7
+
8
+ from annotated_types import Ge, Gt, Le, Lt
9
+ from bec_lib.logger import bec_logger
10
+ from pydantic_core import PydanticUndefined
11
+
12
+ if TYPE_CHECKING:
13
+ from pydantic.fields import FieldInfo
14
+
15
+ logger = bec_logger.logger
16
+
17
+
18
+ _MININT = -2147483648
19
+ _MAXINT = 2147483647
20
+ _MINFLOAT = -sys.float_info.max
21
+ _MAXFLOAT = sys.float_info.max
22
+
23
+ T = TypeVar("T", int, float, Decimal)
24
+
25
+
26
+ def field_limits(info: FieldInfo, type_: type[T]) -> tuple[T, T]:
27
+ _min = _MININT if type_ is int else _MINFLOAT
28
+ _max = _MAXINT if type_ is int else _MAXFLOAT
29
+ for md in info.metadata:
30
+ if isinstance(md, Ge):
31
+ _min = type_(md.ge) # type: ignore
32
+ if isinstance(md, Gt):
33
+ _min = type_(md.gt) + 1 if type_ is int else nextafter(type_(md.gt), inf) # type: ignore
34
+ if isinstance(md, Lt):
35
+ _max = type_(md.lt) - 1 if type_ is int else nextafter(type_(md.lt), -inf) # type: ignore
36
+ if isinstance(md, Le):
37
+ _max = type_(md.le) # type: ignore
38
+ return _min, _max # type: ignore
39
+
40
+
41
+ def _get_anno(info: FieldInfo, annotation: str, default):
42
+ for md in info.metadata:
43
+ if hasattr(md, annotation):
44
+ return getattr(md, annotation)
45
+ return default
46
+
47
+
48
+ def field_precision(info: FieldInfo):
49
+ return _get_anno(info, "decimal_places", 307)
50
+
51
+
52
+ def field_maxlen(info: FieldInfo):
53
+ return _get_anno(info, "max_length", None)
54
+
55
+
56
+ def field_minlen(info: FieldInfo):
57
+ return _get_anno(info, "min_length", None)
58
+
59
+
60
+ def field_default(info: FieldInfo):
61
+ if info.default is PydanticUndefined:
62
+ return
63
+ return info.default
64
+
65
+
66
+ def clearable_required(info: FieldInfo):
67
+ return type(None) in get_args(info.annotation) or info.is_required()
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ignore
6
+ from qtpy.QtWidgets import (
7
+ QApplication,
8
+ QHBoxLayout,
9
+ QLabel,
10
+ QPushButton,
11
+ QTableView,
12
+ QVBoxLayout,
13
+ QWidget,
14
+ )
15
+
16
+ from bec_widgets.qt_utils.error_popups import SafeSlot
17
+
18
+
19
+ class AdditionalMetadataTableModel(QAbstractTableModel):
20
+ def __init__(self, data):
21
+ super().__init__()
22
+ self._data: list[list[str]] = data
23
+ self._disallowed_keys: list[str] = []
24
+
25
+ def headerData(
26
+ self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole()
27
+ ) -> Any:
28
+ if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
29
+ return "Key" if section == 0 else "Value"
30
+ return super().headerData(section, orientation, role)
31
+
32
+ def rowCount(self, index: QModelIndex = QModelIndex()):
33
+ return 0 if index.isValid() else len(self._data)
34
+
35
+ def columnCount(self, index: QModelIndex = QModelIndex()):
36
+ return 0 if index.isValid() else 2
37
+
38
+ def data(self, index, role=Qt.ItemDataRole):
39
+ if index.isValid():
40
+ if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole:
41
+ return str(self._data[index.row()][index.column()])
42
+
43
+ def setData(self, index, value, role):
44
+ if role == Qt.ItemDataRole.EditRole:
45
+ if value in self._disallowed_keys or value in self._other_keys(index.row()):
46
+ return False
47
+ self._data[index.row()][index.column()] = str(value)
48
+ return True
49
+ return False
50
+
51
+ def update_disallowed_keys(self, keys: list[str]):
52
+ self._disallowed_keys = keys
53
+ for i, item in enumerate(self._data):
54
+ if item[0] in self._disallowed_keys:
55
+ self._data[i][0] = ""
56
+ self.dataChanged.emit(self.index(i, 0), self.index(i, 0))
57
+
58
+ def _other_keys(self, row: int):
59
+ return [r[0] for r in self._data[:row] + self._data[row + 1 :]]
60
+
61
+ def flags(self, _):
62
+ return Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsEditable
63
+
64
+ def insertRows(self, row, number, index):
65
+ """We only support adding one at a time for now"""
66
+ if row != self.rowCount() or number != 1:
67
+ return False
68
+ self.beginInsertRows(QModelIndex(), 0, 0)
69
+ self._data.append(["", ""])
70
+ self.endInsertRows()
71
+ return True
72
+
73
+ def removeRows(self, row, number, index):
74
+ """This can only be consecutive, so instead of trying to be clever, only support removing one at a time"""
75
+ if number != 1:
76
+ return False
77
+ self.beginRemoveRows(QModelIndex(), row, row)
78
+ del self._data[row]
79
+ self.endRemoveRows()
80
+ return True
81
+
82
+ @SafeSlot()
83
+ def add_row(self):
84
+ self.insertRow(self.rowCount())
85
+
86
+ @SafeSlot(list)
87
+ def delete_rows(self, rows: list[int]):
88
+ # delete from the end so indices stay correct
89
+ for row in sorted(rows, reverse=True):
90
+ self.removeRows(row, 1, QModelIndex())
91
+
92
+ def dump_dict(self):
93
+ if self._data == [[]]:
94
+ return {}
95
+ return dict(self._data)
96
+
97
+
98
+ class AdditionalMetadataTable(QWidget):
99
+
100
+ delete_rows = Signal(list)
101
+
102
+ def __init__(self, initial_data: list[list[str]]):
103
+ super().__init__()
104
+
105
+ self._layout = QHBoxLayout()
106
+ self.setLayout(self._layout)
107
+ self._table_model = AdditionalMetadataTableModel(initial_data)
108
+ self._table_view = QTableView()
109
+ self._table_view.setModel(self._table_model)
110
+ self._table_view.horizontalHeader().setStretchLastSection(True)
111
+ self._layout.addWidget(self._table_view)
112
+
113
+ self._buttons = QVBoxLayout()
114
+ self._layout.addLayout(self._buttons)
115
+ self._add_button = QPushButton("+")
116
+ self._add_button.setToolTip("add a new row")
117
+ self._remove_button = QPushButton("-")
118
+ self._remove_button.setToolTip("delete rows containing any selected cells")
119
+ self._buttons.addWidget(self._add_button)
120
+ self._buttons.addWidget(self._remove_button)
121
+ self._add_button.clicked.connect(self._table_model.add_row)
122
+ self._remove_button.clicked.connect(self.delete_selected_rows)
123
+ self.delete_rows.connect(self._table_model.delete_rows)
124
+
125
+ def delete_selected_rows(self):
126
+ cells: list[QModelIndex] = self._table_view.selectionModel().selectedIndexes()
127
+ row_indices = list({r.row() for r in cells})
128
+ if row_indices:
129
+ self.delete_rows.emit(row_indices)
130
+
131
+ def dump_dict(self):
132
+ return self._table_model.dump_dict()
133
+
134
+ def update_disallowed_keys(self, keys: list[str]):
135
+ self._table_model.update_disallowed_keys(keys)
136
+
137
+
138
+ if __name__ == "__main__": # pragma: no cover
139
+ from bec_widgets.utils.colors import set_theme
140
+
141
+ app = QApplication([])
142
+ set_theme("dark")
143
+
144
+ window = AdditionalMetadataTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
145
+ window.show()
146
+ app.exec()
@@ -0,0 +1,196 @@
1
+ from __future__ import annotations
2
+
3
+ from decimal import Decimal
4
+ from typing import TYPE_CHECKING
5
+
6
+ from bec_lib.logger import bec_logger
7
+ from bec_lib.metadata_schema import get_metadata_schema_for_scan
8
+ from bec_qthemes import material_icon
9
+ from pydantic import Field, ValidationError
10
+ from qtpy.QtWidgets import (
11
+ QApplication,
12
+ QComboBox,
13
+ QGridLayout,
14
+ QLabel,
15
+ QLayout,
16
+ QVBoxLayout,
17
+ QWidget,
18
+ )
19
+
20
+ from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
21
+ from bec_widgets.qt_utils.error_popups import SafeSlot
22
+ from bec_widgets.utils.bec_widget import BECWidget
23
+ from bec_widgets.widgets.editors.scan_metadata._metadata_widgets import widget_from_type
24
+ from bec_widgets.widgets.editors.scan_metadata.additional_metadata_table import (
25
+ AdditionalMetadataTable,
26
+ )
27
+
28
+ if TYPE_CHECKING:
29
+ from pydantic.fields import FieldInfo
30
+
31
+ logger = bec_logger.logger
32
+
33
+
34
+ class ScanMetadata(BECWidget, QWidget):
35
+ """Dynamically generates a form for inclusion of metadata for a scan. Uses the
36
+ metadata schema registry supplied in the plugin repo to find pydantic models
37
+ associated with the scan type. Sets limits for numerical values if specified."""
38
+
39
+ def __init__(
40
+ self,
41
+ parent=None,
42
+ client=None,
43
+ scan_name: str | None = None,
44
+ initial_extras: list[list[str]] | None = None,
45
+ ):
46
+ super().__init__(client=client)
47
+ QWidget.__init__(self, parent=parent)
48
+
49
+ self.set_schema(scan_name)
50
+
51
+ self._layout = QVBoxLayout()
52
+ self._layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
53
+ self.setLayout(self._layout)
54
+ self._layout.addWidget(QLabel("<b>Required scan metadata:</b>"))
55
+ self._md_grid = QWidget()
56
+ self._layout.addWidget(self._md_grid)
57
+ self._grid_container = QVBoxLayout()
58
+ self._md_grid.setLayout(self._grid_container)
59
+ self._new_grid_layout()
60
+ self._grid_container.addLayout(self._md_grid_layout)
61
+ self._layout.addWidget(QLabel("<b>Additional metadata:</b>"))
62
+ self._additional_metadata = AdditionalMetadataTable(initial_extras or [])
63
+ self._layout.addWidget(self._additional_metadata)
64
+
65
+ self._validity = CompactPopupWidget()
66
+ self._validity.compact_view = True # type: ignore
67
+ self._validity.label = "Validity" # type: ignore
68
+ self._validity.compact_show_popup.setIcon(
69
+ material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
70
+ )
71
+ self._validity_message = QLabel("Not yet validated")
72
+ self._validity.addWidget(self._validity_message)
73
+ self._layout.addWidget(self._validity)
74
+
75
+ self.populate()
76
+
77
+ @SafeSlot(str)
78
+ def update_with_new_scan(self, scan_name: str):
79
+ self.set_schema(scan_name)
80
+ self.populate()
81
+ self.validate_form()
82
+
83
+ def validate_form(self, *_):
84
+ try:
85
+ self._md_schema.model_validate(self.get_full_model_dict())
86
+ self._validity.set_global_state("success")
87
+ self._validity_message.setText("No errors!")
88
+ except ValidationError as e:
89
+ self._validity.set_global_state("emergency")
90
+ self._validity_message.setText(str(e))
91
+
92
+ def get_full_model_dict(self):
93
+ """Get the entered metadata as a dict"""
94
+ return self._additional_metadata.dump_dict() | self._dict_from_grid()
95
+
96
+ def set_schema(self, scan_name: str | None = None):
97
+ self._scan_name = scan_name or ""
98
+ self._md_schema = get_metadata_schema_for_scan(self._scan_name)
99
+
100
+ def populate(self):
101
+ self._clear_grid()
102
+ self._populate()
103
+
104
+ def _populate(self):
105
+ self._additional_metadata.update_disallowed_keys(list(self._md_schema.model_fields.keys()))
106
+ for i, (field_name, info) in enumerate(self._md_schema.model_fields.items()):
107
+ self._add_griditem(field_name, info, i)
108
+
109
+ def _add_griditem(self, field_name: str, info: FieldInfo, row: int):
110
+ grid = self._md_grid_layout
111
+ label = QLabel(info.title or field_name)
112
+ label.setProperty("_model_field_name", field_name)
113
+ label.setToolTip(info.description or field_name)
114
+ grid.addWidget(label, row, 0)
115
+ widget = widget_from_type(info.annotation)(info)
116
+ widget.valueChanged.connect(self.validate_form)
117
+ grid.addWidget(widget, row, 1)
118
+
119
+ def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]:
120
+ grid = self._md_grid_layout
121
+ return {
122
+ grid.itemAtPosition(i, 0).widget().property("_model_field_name"): grid.itemAtPosition(i, 1).widget().getValue() # type: ignore # we only add 'MetadataWidget's here
123
+ for i in range(grid.rowCount())
124
+ }
125
+
126
+ def _clear_grid(self):
127
+ while self._md_grid_layout.count():
128
+ item = self._md_grid_layout.takeAt(0)
129
+ widget = item.widget()
130
+ if widget is not None:
131
+ widget.deleteLater()
132
+ self._md_grid_layout.deleteLater()
133
+ self._new_grid_layout()
134
+ self._grid_container.addLayout(self._md_grid_layout)
135
+ self._md_grid.adjustSize()
136
+ self.adjustSize()
137
+
138
+ def _new_grid_layout(self):
139
+ self._md_grid_layout = QGridLayout()
140
+ self._md_grid_layout.setContentsMargins(0, 0, 0, 0)
141
+ self._md_grid_layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
142
+
143
+
144
+ if __name__ == "__main__": # pragma: no cover
145
+ from unittest.mock import patch
146
+
147
+ from bec_lib.metadata_schema import BasicScanMetadata
148
+
149
+ from bec_widgets.utils.colors import set_theme
150
+
151
+ class ExampleSchema1(BasicScanMetadata):
152
+ abc: int = Field(gt=0, lt=2000, description="Heating temperature abc", title="A B C")
153
+ foo: str = Field(max_length=12, description="Sample database code", default="DEF123")
154
+ xyz: Decimal = Field(decimal_places=4)
155
+ baz: bool
156
+
157
+ class ExampleSchema2(BasicScanMetadata):
158
+ checkbox_up_top: bool
159
+ checkbox_again: bool = Field(
160
+ title="Checkbox Again", description="this one defaults to True", default=True
161
+ )
162
+ different_items: int | None = Field(
163
+ None, description="This is just one different item...", gt=-100, lt=0
164
+ )
165
+ length_limited_string: str = Field(max_length=32)
166
+ float_with_2dp: Decimal = Field(decimal_places=2)
167
+
168
+ class ExampleSchema3(BasicScanMetadata):
169
+ optional_with_regex: str | None = Field(None, pattern=r"^\d+-\d+$")
170
+
171
+ with patch(
172
+ "bec_lib.metadata_schema._get_metadata_schema_registry",
173
+ lambda: {"scan1": ExampleSchema1, "scan2": ExampleSchema2, "scan3": ExampleSchema3},
174
+ ):
175
+
176
+ app = QApplication([])
177
+ w = QWidget()
178
+ selection = QComboBox()
179
+ selection.addItems(["grid_scan", "scan1", "scan2", "scan3"])
180
+
181
+ layout = QVBoxLayout()
182
+ w.setLayout(layout)
183
+
184
+ scan_metadata = ScanMetadata(
185
+ scan_name="grid_scan",
186
+ initial_extras=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]],
187
+ )
188
+ selection.currentTextChanged.connect(scan_metadata.update_with_new_scan)
189
+
190
+ layout.addWidget(selection)
191
+ layout.addWidget(scan_metadata)
192
+
193
+ set_theme("dark")
194
+ window = w
195
+ window.show()
196
+ app.exec()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bec_widgets
3
- Version: 1.20.0
3
+ Version: 1.21.0
4
4
  Summary: BEC Widgets
5
5
  Project-URL: Bug Tracker, https://gitlab.psi.ch/bec/bec_widgets/issues
6
6
  Project-URL: Homepage, https://gitlab.psi.ch/bec/bec_widgets
@@ -2,11 +2,11 @@
2
2
  .gitlab-ci.yml,sha256=PuL-FmkTHm7qs467Mh9D8quWcEj4tgEA-UUGDieMuWk,8774
3
3
  .pylintrc,sha256=eeY8YwSI74oFfq6IYIbCqnx3Vk8ZncKaatv96n_Y8Rs,18544
4
4
  .readthedocs.yaml,sha256=aSOc277LqXcsTI6lgvm_JY80lMlr69GbPKgivua2cS0,603
5
- CHANGELOG.md,sha256=O7um_taM6ubL3SAPYiQsSnt-bk4rgPsgq6HA6l5xUTc,227701
5
+ CHANGELOG.md,sha256=nwcFNPwNl0Hm-pvcpA5AGUtZ6d5S2HTS7XGYS8W0jmA,227882
6
6
  LICENSE,sha256=YRKe85CBRyP7UpEAWwU8_qSIyuy5-l_9C-HKg5Qm8MQ,1511
7
- PKG-INFO,sha256=5TdKTwtmahsl1DVDbweKfIkvjjOVFR1xmUVyYW2c8Qk,1175
7
+ PKG-INFO,sha256=UCBSBJegtFWSYbJEwxDRB3c18aL2rojyKCuVFJV5zeg,1175
8
8
  README.md,sha256=KgdKusjlvEvFtdNZCeDMO91y77MWK2iDcYMDziksOr4,2553
9
- pyproject.toml,sha256=SQjOzLjNt3O6MZ6YQdYumjT5DSCdVDaOrxniG8WiTys,2542
9
+ pyproject.toml,sha256=lQdjFQkuXXHccxf9Xqtjt0kPEUCJ6tyFrCGPMuwQaMY,2542
10
10
  .git_hooks/pre-commit,sha256=n3RofIZHJl8zfJJIUomcMyYGFi_rwq4CC19z0snz3FI,286
11
11
  .gitlab/issue_templates/bug_report_template.md,sha256=gAuyEwl7XlnebBrkiJ9AqffSNOywmr8vygUFWKTuQeI,386
12
12
  .gitlab/issue_templates/documentation_update_template.md,sha256=FHLdb3TS_D9aL4CYZCjyXSulbaW5mrN2CmwTaeLPbNw,860
@@ -220,6 +220,11 @@ bec_widgets/widgets/editors/console/console_plugin.py,sha256=EvFTruYDVHiS4pHIwZn
220
220
  bec_widgets/widgets/editors/console/register_console.py,sha256=zoF-i3R9sRGzb85sdoxVunebYOfOD53fkCELTPtrFRc,471
221
221
  bec_widgets/widgets/editors/jupyter_console/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
222
222
  bec_widgets/widgets/editors/jupyter_console/jupyter_console.py,sha256=-e7HQOECeH5eDrJYh4BFIzRL78LDkooU4otabyN0aX4,2343
223
+ bec_widgets/widgets/editors/scan_metadata/__init__.py,sha256=IhDv6xQ7tpSbdaAQDOenrb0IU3wfSoElV9k6ajIM1IY,315
224
+ bec_widgets/widgets/editors/scan_metadata/_metadata_widgets.py,sha256=2WZf0Ej_R3ZQJ9QLvxCrVAoOgNPT5unh9OuhyYiUVyY,9294
225
+ bec_widgets/widgets/editors/scan_metadata/_util.py,sha256=8qn2clcJqi9nvPSvZzO9ornHxn_PGw4Z_O_1XpSmq8E,1861
226
+ bec_widgets/widgets/editors/scan_metadata/additional_metadata_table.py,sha256=XysmHU8B6WqLOLjQQBRG3sbN6_KaralvrIlrylkJ56E,5105
227
+ bec_widgets/widgets/editors/scan_metadata/scan_metadata.py,sha256=fo9CvYFUWbuypYUcdzTXlUJ1rIIIVCIFPX1ejbJGL8Q,7263
223
228
  bec_widgets/widgets/editors/text_box/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
224
229
  bec_widgets/widgets/editors/text_box/register_text_box.py,sha256=xRgVugvjLhX3iKb-vaAxflE6pWpal7pVFWDaUSUZLyE,467
225
230
  bec_widgets/widgets/editors/text_box/text_box.py,sha256=F_BdWKPwEjltgfAsPmGHipOs5sPtI4o0Y0EO68s3Og0,4311
@@ -350,8 +355,8 @@ bec_widgets/widgets/utility/visual/dark_mode_button/dark_mode_button.py,sha256=Z
350
355
  bec_widgets/widgets/utility/visual/dark_mode_button/dark_mode_button.pyproject,sha256=Lbi9zb6HNlIq14k6hlzR-oz6PIFShBuF7QxE6d87d64,34
351
356
  bec_widgets/widgets/utility/visual/dark_mode_button/dark_mode_button_plugin.py,sha256=CzChz2SSETYsR8-36meqWnsXCT-FIy_J_xeU5coWDY8,1350
352
357
  bec_widgets/widgets/utility/visual/dark_mode_button/register_dark_mode_button.py,sha256=rMpZ1CaoucwobgPj1FuKTnt07W82bV1GaSYdoqcdMb8,521
353
- bec_widgets-1.20.0.dist-info/METADATA,sha256=5TdKTwtmahsl1DVDbweKfIkvjjOVFR1xmUVyYW2c8Qk,1175
354
- bec_widgets-1.20.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
355
- bec_widgets-1.20.0.dist-info/entry_points.txt,sha256=dItMzmwA1wizJ1Itx15qnfJ0ZzKVYFLVJ1voxT7K7D4,214
356
- bec_widgets-1.20.0.dist-info/licenses/LICENSE,sha256=YRKe85CBRyP7UpEAWwU8_qSIyuy5-l_9C-HKg5Qm8MQ,1511
357
- bec_widgets-1.20.0.dist-info/RECORD,,
358
+ bec_widgets-1.21.0.dist-info/METADATA,sha256=UCBSBJegtFWSYbJEwxDRB3c18aL2rojyKCuVFJV5zeg,1175
359
+ bec_widgets-1.21.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
360
+ bec_widgets-1.21.0.dist-info/entry_points.txt,sha256=dItMzmwA1wizJ1Itx15qnfJ0ZzKVYFLVJ1voxT7K7D4,214
361
+ bec_widgets-1.21.0.dist-info/licenses/LICENSE,sha256=YRKe85CBRyP7UpEAWwU8_qSIyuy5-l_9C-HKg5Qm8MQ,1511
362
+ bec_widgets-1.21.0.dist-info/RECORD,,
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bec_widgets"
7
- version = "1.20.0"
7
+ version = "1.21.0"
8
8
  description = "BEC Widgets"
9
9
  requires-python = ">=3.10"
10
10
  classifiers = [