bec-widgets 2.10.3__py3-none-any.whl → 2.12.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.
Files changed (27) hide show
  1. CHANGELOG.md +84 -0
  2. PKG-INFO +1 -1
  3. bec_widgets/cli/client.py +5 -5
  4. bec_widgets/tests/utils.py +2 -2
  5. bec_widgets/utils/clickable_label.py +13 -0
  6. bec_widgets/utils/colors.py +7 -4
  7. bec_widgets/utils/expandable_frame.py +58 -7
  8. bec_widgets/utils/forms_from_types/forms.py +107 -28
  9. bec_widgets/utils/forms_from_types/items.py +88 -12
  10. bec_widgets/utils/forms_from_types/styles.py +21 -0
  11. bec_widgets/utils/generate_designer_plugin.py +10 -21
  12. bec_widgets/widgets/control/scan_control/scan_control.py +1 -1
  13. bec_widgets/widgets/editors/dict_backed_table.py +31 -11
  14. bec_widgets/widgets/editors/scan_metadata/_util.py +7 -4
  15. bec_widgets/widgets/editors/scan_metadata/scan_metadata.py +10 -4
  16. bec_widgets/widgets/plots/image/image.py +128 -856
  17. bec_widgets/widgets/plots/image/image_base.py +1062 -0
  18. bec_widgets/widgets/plots/image/image_item.py +7 -6
  19. bec_widgets/widgets/services/device_browser/device_browser.py +61 -29
  20. bec_widgets/widgets/services/device_browser/device_item/device_item.py +97 -19
  21. bec_widgets/widgets/services/device_browser/util.py +11 -0
  22. {bec_widgets-2.10.3.dist-info → bec_widgets-2.12.0.dist-info}/METADATA +1 -1
  23. {bec_widgets-2.10.3.dist-info → bec_widgets-2.12.0.dist-info}/RECORD +27 -23
  24. pyproject.toml +1 -1
  25. {bec_widgets-2.10.3.dist-info → bec_widgets-2.12.0.dist-info}/WHEEL +0 -0
  26. {bec_widgets-2.10.3.dist-info → bec_widgets-2.12.0.dist-info}/entry_points.txt +0 -0
  27. {bec_widgets-2.10.3.dist-info → bec_widgets-2.12.0.dist-info}/licenses/LICENSE +0 -0
@@ -2,12 +2,12 @@ from __future__ import annotations
2
2
 
3
3
  from abc import abstractmethod
4
4
  from decimal import Decimal
5
- from types import UnionType
6
- from typing import Callable, Protocol
5
+ from types import GenericAlias, UnionType
6
+ from typing import Literal
7
7
 
8
8
  from bec_lib.logger import bec_logger
9
9
  from bec_qthemes import material_icon
10
- from pydantic import BaseModel, ConfigDict, Field
10
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
11
11
  from pydantic.fields import FieldInfo
12
12
  from qtpy.QtCore import Signal # type: ignore
13
13
  from qtpy.QtWidgets import (
@@ -21,11 +21,13 @@ from qtpy.QtWidgets import (
21
21
  QLayout,
22
22
  QLineEdit,
23
23
  QRadioButton,
24
+ QSizePolicy,
24
25
  QSpinBox,
25
26
  QToolButton,
26
27
  QWidget,
27
28
  )
28
29
 
30
+ from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
29
31
  from bec_widgets.widgets.editors.scan_metadata._util import (
30
32
  clearable_required,
31
33
  field_default,
@@ -46,9 +48,36 @@ class FormItemSpec(BaseModel):
46
48
  """
47
49
 
48
50
  model_config = ConfigDict(arbitrary_types_allowed=True)
49
- item_type: type | UnionType
51
+
52
+ item_type: type | UnionType | GenericAlias
50
53
  name: str
51
54
  info: FieldInfo = FieldInfo()
55
+ pretty_display: bool = Field(
56
+ default=False,
57
+ description="Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.",
58
+ )
59
+
60
+ @field_validator("item_type", mode="before")
61
+ @classmethod
62
+ def _validate_type(cls, v):
63
+ allowed_primitives = [str, int, float, bool]
64
+ if isinstance(v, (type, UnionType)):
65
+ return v
66
+ if isinstance(v, GenericAlias):
67
+ if v.__origin__ in [list, dict] and all(
68
+ arg in allowed_primitives for arg in v.__args__
69
+ ):
70
+ return v
71
+ raise ValueError(
72
+ f"Generics of type {v} are not supported - only lists and dicts of primitive types {allowed_primitives}"
73
+ )
74
+ if type(v) is type(Literal[""]): # _LiteralGenericAlias is not exported from typing
75
+ arg_types = set(type(arg) for arg in v.__args__)
76
+ if len(arg_types) != 1:
77
+ raise ValueError("Mixtures of literal types are not supported!")
78
+ if (t := arg_types.pop()) in allowed_primitives:
79
+ return t
80
+ raise ValueError(f"Literals of type {t} are not supported")
52
81
 
53
82
 
54
83
  class ClearableBoolEntry(QWidget):
@@ -94,10 +123,20 @@ class ClearableBoolEntry(QWidget):
94
123
  self._false.setToolTip(tooltip)
95
124
 
96
125
 
126
+ DynamicFormItemType = str | int | float | Decimal | bool | dict
127
+
128
+
97
129
  class DynamicFormItem(QWidget):
98
130
  valueChanged = Signal()
99
131
 
100
132
  def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
133
+ """
134
+ Initializes the form item widget.
135
+
136
+ Args:
137
+ parent (QWidget | None, optional): The parent widget. Defaults to None.
138
+ spec (FormItemSpec): The specification for the form item.
139
+ """
101
140
  super().__init__(parent)
102
141
  self._spec = spec
103
142
  self._layout = QHBoxLayout()
@@ -107,11 +146,16 @@ class DynamicFormItem(QWidget):
107
146
  self._desc = self._spec.info.description
108
147
  self.setLayout(self._layout)
109
148
  self._add_main_widget()
110
- if clearable_required(spec.info):
111
- self._add_clear_button()
149
+ self._main_widget: QWidget
150
+ self._main_widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
151
+ if not spec.pretty_display:
152
+ if clearable_required(spec.info):
153
+ self._add_clear_button()
154
+ else:
155
+ self._set_pretty_display()
112
156
 
113
157
  @abstractmethod
114
- def getValue(self): ...
158
+ def getValue(self) -> DynamicFormItemType: ...
115
159
 
116
160
  @abstractmethod
117
161
  def setValue(self, value): ...
@@ -121,6 +165,9 @@ class DynamicFormItem(QWidget):
121
165
  """Add the main data entry widget to self._main_widget and appply any
122
166
  constraints from the field info"""
123
167
 
168
+ def _set_pretty_display(self):
169
+ self.setEnabled(False)
170
+
124
171
  def _describe(self, pad=" "):
125
172
  return pad + (self._desc if self._desc else "")
126
173
 
@@ -164,7 +211,7 @@ class StrMetadataField(DynamicFormItem):
164
211
  def setValue(self, value: str):
165
212
  if value is None:
166
213
  self._main_widget.setText("")
167
- self._main_widget.setText(value)
214
+ self._main_widget.setText(str(value))
168
215
 
169
216
 
170
217
  class IntMetadataField(DynamicFormItem):
@@ -202,12 +249,12 @@ class FloatDecimalMetadataField(DynamicFormItem):
202
249
  self._main_widget.textChanged.connect(self._value_changed)
203
250
 
204
251
  def _add_main_widget(self) -> None:
252
+ precision = field_precision(self._spec.info)
205
253
  self._main_widget = QDoubleSpinBox()
206
254
  self._layout.addWidget(self._main_widget)
207
- min_, max_ = field_limits(self._spec.info, int)
255
+ min_, max_ = field_limits(self._spec.info, float, precision)
208
256
  self._main_widget.setMinimum(min_)
209
257
  self._main_widget.setMaximum(max_)
210
- precision = field_precision(self._spec.info)
211
258
  if precision:
212
259
  self._main_widget.setDecimals(precision)
213
260
  minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
@@ -224,10 +271,10 @@ class FloatDecimalMetadataField(DynamicFormItem):
224
271
  return self._default
225
272
  return self._main_widget.value()
226
273
 
227
- def setValue(self, value: float):
274
+ def setValue(self, value: float | Decimal):
228
275
  if value is None:
229
276
  self._main_widget.clear()
230
- self._main_widget.setValue(value)
277
+ self._main_widget.setValue(float(value))
231
278
 
232
279
 
233
280
  class BoolMetadataField(DynamicFormItem):
@@ -251,6 +298,27 @@ class BoolMetadataField(DynamicFormItem):
251
298
  self._main_widget.setChecked(value)
252
299
 
253
300
 
301
+ class DictMetadataField(DynamicFormItem):
302
+ def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
303
+ super().__init__(parent=parent, spec=spec)
304
+ self._main_widget.data_changed.connect(self._value_changed)
305
+
306
+ def _set_pretty_display(self):
307
+ self._main_widget.set_button_visibility(False)
308
+ super()._set_pretty_display()
309
+
310
+ def _add_main_widget(self) -> None:
311
+ self._main_widget = DictBackedTable(self, [])
312
+ self._layout.addWidget(self._main_widget)
313
+ self._main_widget.setToolTip(self._describe(""))
314
+
315
+ def getValue(self):
316
+ return self._main_widget.dump_dict()
317
+
318
+ def setValue(self, value):
319
+ self._main_widget.replace_data(value)
320
+
321
+
254
322
  def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormItem]:
255
323
  if annotation in [str, str | None]:
256
324
  return StrMetadataField
@@ -260,6 +328,14 @@ def widget_from_type(annotation: type | UnionType | None) -> type[DynamicFormIte
260
328
  return FloatDecimalMetadataField
261
329
  if annotation in [bool, bool | None]:
262
330
  return BoolMetadataField
331
+ if annotation in [dict, dict | None] or (
332
+ isinstance(annotation, GenericAlias) and annotation.__origin__ is dict
333
+ ):
334
+ return DictMetadataField
335
+ if annotation in [list, list | None] or (
336
+ isinstance(annotation, GenericAlias) and annotation.__origin__ is list
337
+ ):
338
+ return StrMetadataField
263
339
  else:
264
340
  logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
265
341
  return StrMetadataField
@@ -0,0 +1,21 @@
1
+ import bec_qthemes
2
+
3
+
4
+ def pretty_display_theme(theme: str = "dark"):
5
+ palette = bec_qthemes.load_palette(theme)
6
+ foreground = palette.text().color().name()
7
+ background = palette.base().color().name()
8
+ border = palette.shadow().color().name()
9
+ accent = palette.accent().color().name()
10
+ return f"""
11
+ QWidget {{color: {foreground}; background-color: {background}}}
12
+ QLabel {{ font-weight: bold; }}
13
+ QLineEdit,QLabel,QTreeView {{ border-style: solid; border-width: 2px; border-color: {border} }}
14
+ QRadioButton {{ color: {foreground}; }}
15
+ QRadioButton::indicator::checked {{ color: {accent}; }}
16
+ QCheckBox {{ color: {accent}; }}
17
+ """
18
+
19
+
20
+ if __name__ == "__main__":
21
+ print(pretty_display_theme())
@@ -8,6 +8,9 @@ from qtpy.QtCore import QObject
8
8
  from bec_widgets.utils.name_utils import pascal_to_snake
9
9
 
10
10
  EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"]
11
+ _PARENT_ARG_REGEX = r".__init__\(\s*(?:parent\)|parent=parent,?|parent,?)"
12
+ _SELF_PARENT_ARG_REGEX = r".__init__\(\s*self,\s*(?:parent\)|parent=parent,?|parent,?)"
13
+ SUPER_INIT_REGEX = re.compile(r"super\(\)" + _PARENT_ARG_REGEX, re.MULTILINE)
11
14
 
12
15
 
13
16
  class PluginFilenames(NamedTuple):
@@ -90,34 +93,20 @@ class DesignerPluginGenerator:
90
93
 
91
94
  # Check if the widget class calls the super constructor with parent argument
92
95
  init_source = inspect.getsource(self.widget.__init__)
93
- cls_init_found = (
94
- bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent=parent") > 0)
95
- or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent)") > 0)
96
- or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent,") > 0)
97
- )
98
- super_init_found = (
99
- bool(
100
- init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent=parent") > 0
101
- )
102
- or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent,") > 0)
103
- or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent)") > 0)
96
+ class_re = re.compile(base_cls[0].__name__ + _SELF_PARENT_ARG_REGEX, re.MULTILINE)
97
+ cls_init_found = class_re.search(init_source) is not None
98
+ super_self_re = re.compile(
99
+ rf"super\({base_cls[0].__name__}, self\)" + _PARENT_ARG_REGEX, re.MULTILINE
104
100
  )
101
+ super_init_found = super_self_re.search(init_source) is not None
105
102
  if issubclass(self.widget.__bases__[0], QObject) and not super_init_found:
106
- super_init_found = (
107
- bool(init_source.find("super().__init__(parent=parent") > 0)
108
- or bool(init_source.find("super().__init__(parent,") > 0)
109
- or bool(init_source.find("super().__init__(parent)") > 0)
110
- )
103
+ super_init_found = SUPER_INIT_REGEX.search(init_source) is not None
111
104
 
112
105
  # for the new style classes, we only have one super call. We can therefore check if the
113
106
  # number of __init__ calls is 2 (the class itself and the super class)
114
107
  num_inits = re.findall(r"__init__", init_source)
115
108
  if len(num_inits) == 2 and not super_init_found:
116
- super_init_found = bool(
117
- init_source.find("super().__init__(parent=parent") > 0
118
- or init_source.find("super().__init__(parent,") > 0
119
- or init_source.find("super().__init__(parent)") > 0
120
- )
109
+ super_init_found = SUPER_INIT_REGEX.search(init_source) is not None
121
110
 
122
111
  if not cls_init_found and not super_init_found:
123
112
  raise ValueError(
@@ -89,6 +89,7 @@ class ScanControl(BECWidget, QWidget):
89
89
  self.config.allowed_scans = allowed_scans
90
90
 
91
91
  self._scan_metadata: dict | None = None
92
+ self._metadata_form = ScanMetadata(parent=self)
92
93
 
93
94
  # Create and set main layout
94
95
  self._init_UI()
@@ -165,7 +166,6 @@ class ScanControl(BECWidget, QWidget):
165
166
  self.layout.addStretch()
166
167
 
167
168
  def _add_metadata_form(self):
168
- self._metadata_form = ScanMetadata(parent=self)
169
169
  self.layout.addWidget(self._metadata_form)
170
170
  self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
171
171
  self.scan_selected.connect(self._metadata_form.update_with_new_scan)
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Any
4
4
 
5
+ from qtpy import QtWidgets
5
6
  from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ignore
6
7
  from qtpy.QtWidgets import (
7
8
  QApplication,
@@ -45,7 +46,11 @@ class DictBackedTableModel(QAbstractTableModel):
45
46
 
46
47
  def data(self, index, role=Qt.ItemDataRole):
47
48
  if index.isValid():
48
- if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole:
49
+ if role in [
50
+ Qt.ItemDataRole.DisplayRole,
51
+ Qt.ItemDataRole.EditRole,
52
+ Qt.ItemDataRole.ToolTipRole,
53
+ ]:
49
54
  return str(self._data[index.row()][index.column()])
50
55
 
51
56
  def setData(self, index, value, role):
@@ -57,6 +62,11 @@ class DictBackedTableModel(QAbstractTableModel):
57
62
  return True
58
63
  return False
59
64
 
65
+ def replaceData(self, data: dict):
66
+ self.resetInternalData()
67
+ self._data = [[k, v] for k, v in data.items()]
68
+ self.dataChanged.emit(self.index(0, 0), self.index(len(self._data), 0))
69
+
60
70
  def update_disallowed_keys(self, keys: list[str]):
61
71
  """Set the list of keys which may not be used.
62
72
 
@@ -110,16 +120,16 @@ class DictBackedTableModel(QAbstractTableModel):
110
120
 
111
121
  class DictBackedTable(QWidget):
112
122
  delete_rows = Signal(list)
113
- data_updated = Signal()
123
+ data_changed = Signal(dict)
114
124
 
115
- def __init__(self, initial_data: list[list[str]]):
125
+ def __init__(self, parent: QWidget | None = None, initial_data: list[list[str]] = []):
116
126
  """Widget which uses a DictBackedTableModel to display an editable table
117
127
  which can be extracted as a dict.
118
128
 
119
129
  Args:
120
130
  initial_data (list[list[str]]): list of key-value pairs to initialise with
121
131
  """
122
- super().__init__()
132
+ super().__init__(parent)
123
133
 
124
134
  self._layout = QHBoxLayout()
125
135
  self.setLayout(self._layout)
@@ -127,13 +137,17 @@ class DictBackedTable(QWidget):
127
137
  self._table_view = QTreeView()
128
138
  self._table_view.setModel(self._table_model)
129
139
  self._table_view.setSizePolicy(
130
- QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
140
+ QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
131
141
  )
132
142
  self._table_view.setAlternatingRowColors(True)
143
+ self._table_view.header().setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
144
+ self._table_view.header().setSectionResizeMode(5, QtWidgets.QHeaderView.Stretch)
133
145
  self._layout.addWidget(self._table_view)
134
146
 
147
+ self._button_holder = QWidget()
135
148
  self._buttons = QVBoxLayout()
136
- self._layout.addLayout(self._buttons)
149
+ self._button_holder.setLayout(self._buttons)
150
+ self._layout.addWidget(self._button_holder)
137
151
  self._add_button = QPushButton("+")
138
152
  self._add_button.setToolTip("add a new row")
139
153
  self._remove_button = QPushButton("-")
@@ -143,11 +157,17 @@ class DictBackedTable(QWidget):
143
157
  self._add_button.clicked.connect(self._table_model.add_row)
144
158
  self._remove_button.clicked.connect(self.delete_selected_rows)
145
159
  self.delete_rows.connect(self._table_model.delete_rows)
146
- self._table_model.dataChanged.connect(self._emit_data_updated)
160
+ self._table_model.dataChanged.connect(lambda *_: self.data_changed.emit(self.dump_dict()))
161
+
162
+ def set_button_visibility(self, value: bool):
163
+ self._button_holder.setVisible(value)
164
+
165
+ @SafeSlot()
166
+ def clear(self):
167
+ self._table_model.replaceData({})
147
168
 
148
- def _emit_data_updated(self, *args, **kwargs):
149
- """Just to swallow the args"""
150
- self.data_updated.emit()
169
+ def replace_data(self, data: dict):
170
+ self._table_model.replaceData(data)
151
171
 
152
172
  def delete_selected_rows(self):
153
173
  """Delete rows which are part of the selection model"""
@@ -174,6 +194,6 @@ if __name__ == "__main__": # pragma: no cover
174
194
  app = QApplication([])
175
195
  set_theme("dark")
176
196
 
177
- window = DictBackedTable([["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
197
+ window = DictBackedTable(None, [["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
178
198
  window.show()
179
199
  app.exec()
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import sys
4
4
  from decimal import Decimal
5
- from math import inf, nextafter
5
+ from math import copysign, inf, nextafter
6
6
  from typing import TYPE_CHECKING, TypeVar, get_args
7
7
 
8
8
  from annotated_types import Ge, Gt, Le, Lt
@@ -23,16 +23,19 @@ _MAXFLOAT = sys.float_info.max
23
23
  T = TypeVar("T", int, float, Decimal)
24
24
 
25
25
 
26
- def field_limits(info: FieldInfo, type_: type[T]) -> tuple[T, T]:
26
+ def field_limits(info: FieldInfo, type_: type[T], prec: int | None = None) -> tuple[T, T]:
27
+ def _nextafter(x, y):
28
+ return nextafter(x, y) if prec is None else x + (10 ** (-prec)) * (copysign(1, y))
29
+
27
30
  _min = _MININT if type_ is int else _MINFLOAT
28
31
  _max = _MAXINT if type_ is int else _MAXFLOAT
29
32
  for md in info.metadata:
30
33
  if isinstance(md, Ge):
31
34
  _min = type_(md.ge) # type: ignore
32
35
  if isinstance(md, Gt):
33
- _min = type_(md.gt) + 1 if type_ is int else nextafter(type_(md.gt), inf) # type: ignore
36
+ _min = type_(md.gt) + 1 if type_ is int else _nextafter(type_(md.gt), inf) # type: ignore
34
37
  if isinstance(md, Lt):
35
- _max = type_(md.lt) - 1 if type_ is int else nextafter(type_(md.lt), -inf) # type: ignore
38
+ _max = type_(md.lt) - 1 if type_ is int else _nextafter(type_(md.lt), -inf) # type: ignore
36
39
  if isinstance(md, Le):
37
40
  _max = type_(md.le) # type: ignore
38
41
  return _min, _max # type: ignore
@@ -16,6 +16,9 @@ logger = bec_logger.logger
16
16
 
17
17
 
18
18
  class ScanMetadata(PydanticModelForm):
19
+
20
+ RPC = False
21
+
19
22
  def __init__(
20
23
  self,
21
24
  parent=None,
@@ -36,16 +39,18 @@ class ScanMetadata(PydanticModelForm):
36
39
 
37
40
  # self.populate() gets called in super().__init__
38
41
  # so make sure self._additional_metadata exists
39
- self._additional_md_box = ExpandableGroupFrame("Additional metadata", expanded=False)
42
+ self._additional_md_box = ExpandableGroupFrame(
43
+ parent, "Additional metadata", expanded=False
44
+ )
40
45
  self._additional_md_box_layout = QHBoxLayout()
41
46
  self._additional_md_box.set_layout(self._additional_md_box_layout)
42
47
 
43
- self._additional_metadata = DictBackedTable(initial_extras or [])
48
+ self._additional_metadata = DictBackedTable(parent, initial_extras or [])
44
49
  self._scan_name = scan_name or ""
45
50
  self._md_schema = get_metadata_schema_for_scan(self._scan_name)
46
- self._additional_metadata.data_updated.connect(self.validate_form)
51
+ self._additional_metadata.data_changed.connect(self.validate_form)
47
52
 
48
- super().__init__(parent=parent, metadata_model=self._md_schema, client=client, **kwargs)
53
+ super().__init__(parent=parent, data_model=self._md_schema, client=client, **kwargs)
49
54
 
50
55
  self._layout.addWidget(self._additional_md_box)
51
56
  self._additional_md_box_layout.addWidget(self._additional_metadata)
@@ -127,6 +132,7 @@ if __name__ == "__main__": # pragma: no cover
127
132
  w.setLayout(layout)
128
133
 
129
134
  scan_metadata = ScanMetadata(
135
+ parent=w,
130
136
  scan_name="grid_scan",
131
137
  initial_extras=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]],
132
138
  )