qplotter 1.6.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 (130) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +67 -0
  4. qplot/_build_info.json +1 -0
  5. qplot/_metadata.py +75 -0
  6. qplot/_repair.py +14 -0
  7. qplot/_shutdown_supervisor.py +4049 -0
  8. qplot/_version.py +12 -0
  9. qplot/_windows_shutdown_job.py +1166 -0
  10. qplot/configuration/__init__.py +3 -0
  11. qplot/configuration/config.py +578 -0
  12. qplot/configuration/config_schema.json +357 -0
  13. qplot/configuration/fonts.py +3 -0
  14. qplot/configuration/scripts.py +255 -0
  15. qplot/configuration/themes/__init__.py +13 -0
  16. qplot/configuration/themes/_base.py +592 -0
  17. qplot/configuration/themes/blank.py +17 -0
  18. qplot/configuration/themes/dark.py +54 -0
  19. qplot/configuration/themes/light.py +54 -0
  20. qplot/datahandling/LoadFromDB.py +224 -0
  21. qplot/datahandling/__init__.py +47 -0
  22. qplot/datahandling/_trusted_live_helper.py +812 -0
  23. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  24. qplot/datahandling/database.py +2340 -0
  25. qplot/datahandling/dimensions.py +43 -0
  26. qplot/datahandling/file_identity.py +461 -0
  27. qplot/datahandling/parameter_data.py +149 -0
  28. qplot/datahandling/plot_progress.py +17 -0
  29. qplot/datahandling/qcodes_cache.py +277 -0
  30. qplot/datahandling/qcodes_compat.py +14 -0
  31. qplot/datahandling/readSQL.py +2442 -0
  32. qplot/datahandling/readonly.py +2566 -0
  33. qplot/datahandling/source_activity.py +21 -0
  34. qplot/datahandling/trusted_array_preview.py +148 -0
  35. qplot/datahandling/trusted_derived_cache.py +1605 -0
  36. qplot/datahandling/trusted_derived_rendering.py +876 -0
  37. qplot/datahandling/trusted_heatmap.py +246 -0
  38. qplot/datahandling/trusted_live.py +2808 -0
  39. qplot/datahandling/trusted_live_queries.py +5907 -0
  40. qplot/datahandling/trusted_live_service.py +1919 -0
  41. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  42. qplot/datahandling/trusted_plot.py +283 -0
  43. qplot/datahandling/trusted_presentation.py +1065 -0
  44. qplot/datahandling/trusted_snapshot.py +1132 -0
  45. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  46. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  47. qplot/diagnostics.py +241 -0
  48. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  49. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  50. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  51. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  52. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  53. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  54. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  55. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  56. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  57. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  58. qplot/testdata.py +1804 -0
  59. qplot/tools/__init__.py +13 -0
  60. qplot/tools/finite_means.py +123 -0
  61. qplot/tools/general.py +139 -0
  62. qplot/tools/heatmap_geometry.py +391 -0
  63. qplot/tools/operation_registry.py +179 -0
  64. qplot/tools/plot_tools.py +650 -0
  65. qplot/tools/sample_statistics.py +111 -0
  66. qplot/tools/worker.py +2838 -0
  67. qplot/windows/__init__.py +9 -0
  68. qplot/windows/_colorbar.py +422 -0
  69. qplot/windows/_commands.py +523 -0
  70. qplot/windows/_config_persistence.py +123 -0
  71. qplot/windows/_database_actions.py +4933 -0
  72. qplot/windows/_dataset_handle.py +221 -0
  73. qplot/windows/_display_precision.py +48 -0
  74. qplot/windows/_dragdrop.py +70 -0
  75. qplot/windows/_export_paths.py +1167 -0
  76. qplot/windows/_help.py +210 -0
  77. qplot/windows/_mixed_appearance.py +303 -0
  78. qplot/windows/_mixed_plots.py +461 -0
  79. qplot/windows/_native_averaging.py +323 -0
  80. qplot/windows/_native_transforms.py +551 -0
  81. qplot/windows/_plot1d_snap.py +612 -0
  82. qplot/windows/_plot1d_traces.py +2236 -0
  83. qplot/windows/_plot2d_colorbar.py +1127 -0
  84. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  85. qplot/windows/_plot2d_layers.py +2291 -0
  86. qplot/windows/_plot2d_sweeps.py +712 -0
  87. qplot/windows/_plotWin.py +2082 -0
  88. qplot/windows/_plot_actions.py +2703 -0
  89. qplot/windows/_plot_annotation.py +186 -0
  90. qplot/windows/_plot_appearance.py +283 -0
  91. qplot/windows/_plot_axis_scaling.py +1521 -0
  92. qplot/windows/_plot_data_viewbox.py +94 -0
  93. qplot/windows/_plot_drop_axes.py +224 -0
  94. qplot/windows/_plot_export.py +1445 -0
  95. qplot/windows/_plot_feedback.py +112 -0
  96. qplot/windows/_plot_marquee.py +659 -0
  97. qplot/windows/_plot_refresh.py +950 -0
  98. qplot/windows/_plot_source_promotion.py +178 -0
  99. qplot/windows/_plot_state.py +237 -0
  100. qplot/windows/_plot_status.py +115 -0
  101. qplot/windows/_plot_tiling.py +35 -0
  102. qplot/windows/_plot_transform_labels.py +60 -0
  103. qplot/windows/_preferences.py +724 -0
  104. qplot/windows/_refresh_interval.py +172 -0
  105. qplot/windows/_run_controls.py +789 -0
  106. qplot/windows/_shortcuts.py +47 -0
  107. qplot/windows/_subplots/__init__.py +8 -0
  108. qplot/windows/_subplots/subplot1d.py +532 -0
  109. qplot/windows/_subplots/subplot2d.py +919 -0
  110. qplot/windows/_svg_export.py +209 -0
  111. qplot/windows/_trusted_derived_qt.py +1759 -0
  112. qplot/windows/_widgets/__init__.py +23 -0
  113. qplot/windows/_widgets/_run_formatting.py +387 -0
  114. qplot/windows/_widgets/details_tables.py +1119 -0
  115. qplot/windows/_widgets/dropbox.py +290 -0
  116. qplot/windows/_widgets/operations.py +460 -0
  117. qplot/windows/_widgets/preview.py +2997 -0
  118. qplot/windows/_widgets/run_list_items.py +488 -0
  119. qplot/windows/_widgets/toolbar.py +111 -0
  120. qplot/windows/_widgets/treeWidgets.py +2572 -0
  121. qplot/windows/_window_controls.py +414 -0
  122. qplot/windows/main.py +1632 -0
  123. qplot/windows/plot1d.py +313 -0
  124. qplot/windows/plot2d.py +1935 -0
  125. qplotter-1.6.0.dist-info/METADATA +322 -0
  126. qplotter-1.6.0.dist-info/RECORD +130 -0
  127. qplotter-1.6.0.dist-info/WHEEL +5 -0
  128. qplotter-1.6.0.dist-info/entry_points.txt +4 -0
  129. qplotter-1.6.0.dist-info/licenses/LICENSE +21 -0
  130. qplotter-1.6.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1119 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, cast
5
+
6
+ from PyQt6 import QtCore, QtGui
7
+ from PyQt6 import QtWidgets as qtw
8
+
9
+ from qplot.datahandling.trusted_presentation import (
10
+ TRUSTED_PRESENTATION_MAX_RENDERED_NODES,
11
+ TRUSTED_PRESENTATION_MAX_RENDERED_TEXT_BYTES,
12
+ TrustedPresentationView,
13
+ )
14
+ from qplot.datahandling.trusted_snapshot import (
15
+ TRUSTED_SNAPSHOT_LOAD_MORE_TEXT,
16
+ TRUSTED_SNAPSHOT_PAGE_MAX_CHILDREN,
17
+ TRUSTED_SNAPSHOT_PAGE_MAX_DATA_NODES_WITH_CONTINUATION,
18
+ TRUSTED_SNAPSHOT_PAGE_MAX_TEXT_BYTES,
19
+ TrustedSnapshotContainerHandle,
20
+ TrustedSnapshotContinuation,
21
+ TrustedSnapshotFullValueHandle,
22
+ TrustedSnapshotPage,
23
+ TrustedSnapshotView,
24
+ )
25
+
26
+ from .._commands import command_spec
27
+
28
+ COPY_SELECTION_SHORTCUTS = command_spec("copy.selection").resolved_shortcuts()
29
+ COPY_CELL_SHORTCUTS = command_spec("copy.cell").resolved_shortcuts()
30
+ FULL_VALUE_ID_ROLE = int(QtCore.Qt.ItemDataRole.UserRole) + 40
31
+ FULL_VALUE_PATH_ROLE = FULL_VALUE_ID_ROLE + 1
32
+ SNAPSHOT_SESSION_ROLE = FULL_VALUE_ID_ROLE + 2
33
+ SNAPSHOT_CONTAINER_ROLE = FULL_VALUE_ID_ROLE + 3
34
+ SNAPSHOT_PARENT_ROLE = FULL_VALUE_ID_ROLE + 4
35
+ SNAPSHOT_REQUEST_CONTINUATION_ROLE = FULL_VALUE_ID_ROLE + 5
36
+ SNAPSHOT_CONTINUATION_ROLE = FULL_VALUE_ID_ROLE + 6
37
+ SNAPSHOT_FULL_VALUE_ROLE = FULL_VALUE_ID_ROLE + 7
38
+ SNAPSHOT_PAGE_PENDING_ROLE = FULL_VALUE_ID_ROLE + 8
39
+ SNAPSHOT_CONTAINER_LOADED_ROLE = FULL_VALUE_ID_ROLE + 9
40
+ SNAPSHOT_LOAD_MORE_ROLE = FULL_VALUE_ID_ROLE + 10
41
+ DEFAULT_EXPANDED_SNAPSHOT_PATH = "/Snapshot/station"
42
+
43
+
44
+ def _snapshot_page_display_text_bytes(
45
+ rendered_text_bytes: int,
46
+ continuation: object | None,
47
+ ) -> int:
48
+ return rendered_text_bytes + (
49
+ len(TRUSTED_SNAPSHOT_LOAD_MORE_TEXT.encode("utf-8"))
50
+ if continuation is not None
51
+ else 0
52
+ )
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class SnapshotTreePageRequest:
57
+ """One bounded request emitted by the Snapshot tree.
58
+
59
+ Handles and continuations are opaque, immutable core values. In
60
+ particular, this envelope never owns the retained Snapshot source.
61
+ """
62
+
63
+ session_token: str
64
+ parent_handle: TrustedSnapshotContainerHandle
65
+ continuation: TrustedSnapshotContinuation | None
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class SnapshotTreeValueRequest:
70
+ """One exact Snapshot scalar request tied to the page that displayed it."""
71
+
72
+ session_token: str
73
+ handle: TrustedSnapshotFullValueHandle
74
+ path: str
75
+ parent_handle: TrustedSnapshotContainerHandle | None
76
+ continuation: TrustedSnapshotContinuation | None
77
+
78
+
79
+ def copy_action(label, shortcuts, slot, parent):
80
+ action = QtGui.QAction(label, parent)
81
+ action.setShortcuts(shortcuts)
82
+ action.setShortcutContext(QtCore.Qt.ShortcutContext.WidgetWithChildrenShortcut)
83
+ if hasattr(action, "setShortcutVisibleInContextMenu"):
84
+ action.setShortcutVisibleInContextMenu(True)
85
+ action.triggered.connect(slot)
86
+ return action
87
+
88
+
89
+ class WrappedValueDelegate(qtw.QStyledItemDelegate):
90
+ WRAP_FLAGS = (
91
+ QtCore.Qt.AlignmentFlag.AlignLeft
92
+ | QtCore.Qt.AlignmentFlag.AlignTop
93
+ | QtCore.Qt.TextFlag.TextWrapAnywhere
94
+ )
95
+
96
+ def paint(self, painter, option, index):
97
+ opt = qtw.QStyleOptionViewItem(option)
98
+ self.initStyleOption(opt, index)
99
+
100
+ widget = opt.widget
101
+ style = widget.style() if widget is not None else qtw.QApplication.style()
102
+ if style is None:
103
+ super().paint(painter, option, index)
104
+ return
105
+ text = opt.text
106
+ opt.text = ""
107
+
108
+ style.drawControl(
109
+ qtw.QStyle.ControlElement.CE_ItemViewItem, opt, painter, widget
110
+ )
111
+
112
+ text_rect = style.subElementRect(
113
+ qtw.QStyle.SubElement.SE_ItemViewItemText, opt, widget
114
+ )
115
+ text_rect.adjust(0, 2, 0, -2)
116
+ painter.save()
117
+ painter.setFont(opt.font)
118
+ role = (
119
+ QtGui.QPalette.ColorRole.HighlightedText
120
+ if opt.state & qtw.QStyle.StateFlag.State_Selected
121
+ else QtGui.QPalette.ColorRole.Text
122
+ )
123
+ painter.setPen(opt.palette.color(role))
124
+ painter.drawText(text_rect, self.WRAP_FLAGS, text)
125
+ painter.restore()
126
+
127
+ def sizeHint(self, option, index):
128
+ opt = qtw.QStyleOptionViewItem(option)
129
+ self.initStyleOption(opt, index)
130
+
131
+ width = opt.rect.width()
132
+ if width <= 0 and isinstance(opt.widget, qtw.QTreeView):
133
+ width = opt.widget.columnWidth(index.column())
134
+ width = max(24, width - 6)
135
+
136
+ metrics = QtGui.QFontMetrics(opt.font)
137
+ text_rect = metrics.boundingRect(
138
+ QtCore.QRect(0, 0, width, 100_000), self.WRAP_FLAGS, opt.text
139
+ )
140
+ base = super().sizeHint(option, index)
141
+ return QtCore.QSize(base.width(), max(base.height(), text_rect.height() + 6))
142
+
143
+
144
+ class TrustedFullValueDialog(qtw.QDialog):
145
+ """One reusable, read-only viewer for the current selected-run scalar."""
146
+
147
+ def __init__(self, parent=None):
148
+ super().__init__(parent)
149
+ self._exact_text: str | None = None
150
+ self.setObjectName("trustedFullValueDialog")
151
+ self.setWindowTitle("Complete metadata value")
152
+ self.resize(760, 520)
153
+
154
+ self.path_label = qtw.QLabel(self)
155
+ self.path_label.setTextInteractionFlags(
156
+ QtCore.Qt.TextInteractionFlag.TextSelectableByMouse
157
+ )
158
+ self.text_edit = qtw.QPlainTextEdit(self)
159
+ self.text_edit.setObjectName("trustedFullValueText")
160
+ self.text_edit.setReadOnly(True)
161
+ self.text_edit.setLineWrapMode(qtw.QPlainTextEdit.LineWrapMode.NoWrap)
162
+
163
+ self.select_all_button = qtw.QPushButton("Select All", self)
164
+ self.copy_button = qtw.QPushButton("Copy", self)
165
+ self.close_button = qtw.QPushButton("Close", self)
166
+ self.select_all_button.clicked.connect(self.text_edit.selectAll)
167
+ self.copy_button.clicked.connect(self.copy_value)
168
+ self.close_button.clicked.connect(self.close)
169
+
170
+ buttons = qtw.QHBoxLayout()
171
+ buttons.addStretch(1)
172
+ buttons.addWidget(self.select_all_button)
173
+ buttons.addWidget(self.copy_button)
174
+ buttons.addWidget(self.close_button)
175
+ layout = qtw.QVBoxLayout(self)
176
+ layout.addWidget(self.path_label)
177
+ layout.addWidget(self.text_edit, 1)
178
+ layout.addLayout(buttons)
179
+
180
+ def show_value(self, *, path: str, text: str, utf8_bytes: int) -> None:
181
+ self._exact_text = text
182
+ self.path_label.setText(f"{path} — {utf8_bytes} UTF-8 bytes")
183
+ self.text_edit.setPlainText(text)
184
+ cursor = self.text_edit.textCursor()
185
+ cursor.movePosition(QtGui.QTextCursor.MoveOperation.Start)
186
+ self.text_edit.setTextCursor(cursor)
187
+ self.show()
188
+ self.raise_()
189
+ self.activateWindow()
190
+
191
+ @QtCore.pyqtSlot()
192
+ def copy_value(self) -> None:
193
+ if self._exact_text is not None:
194
+ copy_to_clipboard(self._exact_text)
195
+
196
+ def discard_value(self) -> None:
197
+ self._exact_text = None
198
+ self.text_edit.clear()
199
+ self.path_label.clear()
200
+ self.close()
201
+
202
+
203
+ class infoTree(qtw.QTreeWidget):
204
+ fullValueRequested = QtCore.pyqtSignal(str, str)
205
+ snapshotPageRequested = QtCore.pyqtSignal(object)
206
+ snapshotFullValueRequested = QtCore.pyqtSignal(object)
207
+
208
+ def __init__(
209
+ self,
210
+ expand_all=True,
211
+ truncate_values=False,
212
+ *,
213
+ expand_top_level=False,
214
+ ):
215
+ super().__init__()
216
+ self.expand_all = expand_all
217
+ self.expand_top_level = expand_top_level
218
+ self.truncate_values = truncate_values
219
+ self._snapshot_session_token = ""
220
+ self._snapshot_root_handle: TrustedSnapshotContainerHandle | None = None
221
+ self._snapshot_pending_requests: list[SnapshotTreePageRequest] = []
222
+ self.setHeaderLabels(["Key", "Value"])
223
+ self.setColumnCount(2)
224
+ self.setWordWrap(True)
225
+ self.setTextElideMode(QtCore.Qt.TextElideMode.ElideNone)
226
+ self.setUniformRowHeights(False)
227
+ self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
228
+ self.setItemDelegateForColumn(1, WrappedValueDelegate(self))
229
+ header = self.header()
230
+ if header is not None:
231
+ header.setSectionResizeMode(0, qtw.QHeaderView.ResizeMode.ResizeToContents)
232
+ header.setSectionResizeMode(1, qtw.QHeaderView.ResizeMode.Stretch)
233
+ header.setStretchLastSection(True)
234
+ self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
235
+ self.customContextMenuRequested.connect(self.openCopyMenu)
236
+ self.itemActivated.connect(self._activate_full_value)
237
+ self.itemDoubleClicked.connect(self._activate_full_value)
238
+ self.itemExpanded.connect(self._request_expanded_snapshot_container)
239
+
240
+ self.copy_value_action = copy_action(
241
+ "Copy Value", COPY_CELL_SHORTCUTS, self.copyValue, self
242
+ )
243
+ self.copy_selection_action = copy_action(
244
+ "Copy Selection", COPY_SELECTION_SHORTCUTS, self.copySelection, self
245
+ )
246
+ self.view_full_value_action = QtGui.QAction("View Complete Value…", self)
247
+ self.view_full_value_action.triggered.connect(self._request_current_full_value)
248
+ self.addActions(
249
+ [
250
+ self.view_full_value_action,
251
+ self.copy_value_action,
252
+ self.copy_selection_action,
253
+ ]
254
+ )
255
+
256
+ @property
257
+ def snapshotSessionToken(self) -> str:
258
+ return self._snapshot_session_token
259
+
260
+ def expandDefaultSnapshotContainer(self) -> bool:
261
+ """Open the QCoDeS station node and request only its first lazy page."""
262
+
263
+ self._require_owner_thread()
264
+ if not self._snapshot_session_token:
265
+ return False
266
+ for index in range(self.topLevelItemCount()):
267
+ item = self.topLevelItem(index)
268
+ if item is None:
269
+ continue
270
+ if (
271
+ item.data(0, FULL_VALUE_PATH_ROLE) != DEFAULT_EXPANDED_SNAPSHOT_PATH
272
+ or item.data(0, SNAPSHOT_CONTAINER_ROLE) is None
273
+ ):
274
+ continue
275
+ if item.isExpanded():
276
+ self._request_expanded_snapshot_container(item)
277
+ else:
278
+ item.setExpanded(True)
279
+ return True
280
+ return False
281
+
282
+ def clear(self) -> None:
283
+ self._snapshot_session_token = ""
284
+ self._snapshot_root_handle = None
285
+ self._snapshot_pending_requests = []
286
+ super().clear()
287
+
288
+ def setInfo(self, info):
289
+ self.clear()
290
+
291
+ if not info:
292
+ self.addTopLevelItem(qtw.QTreeWidgetItem(["No data", ""]))
293
+ return
294
+ if not isinstance(info, dict):
295
+ item = qtw.QTreeWidgetItem(
296
+ ["Value", format_value(info, 180 if self.truncate_values else None)]
297
+ )
298
+ item.setToolTip(1, format_value(info))
299
+ self.addTopLevelItem(item)
300
+ return
301
+
302
+ items = dictToTree(info, truncate_values=self.truncate_values)
303
+ for item in items:
304
+ self.addTopLevelItem(item)
305
+ item.setExpanded(True)
306
+
307
+ if self.expand_all:
308
+ self.expandAll()
309
+ header = self.header()
310
+ if header is not None:
311
+ header.setSectionResizeMode(0, qtw.QHeaderView.ResizeMode.ResizeToContents)
312
+ header.setSectionResizeMode(1, qtw.QHeaderView.ResizeMode.Stretch)
313
+ cast(Any, self).doItemsLayout()
314
+
315
+ def setBoundedView(
316
+ self,
317
+ view: TrustedSnapshotView | TrustedPresentationView,
318
+ *,
319
+ expanded_paths: set[str] | frozenset[str] | None = None,
320
+ expand_all: bool | None = None,
321
+ ) -> None:
322
+ """Construct one pre-bounded tree without Python recursion."""
323
+ if isinstance(view, TrustedSnapshotView):
324
+ self._set_snapshot_view(view)
325
+ return
326
+ if not isinstance(view, TrustedPresentationView):
327
+ raise TypeError("view must be a trusted bounded tree view")
328
+
329
+ self.clear()
330
+ if not view.nodes:
331
+ self.addTopLevelItem(qtw.QTreeWidgetItem(["No data", ""]))
332
+ return
333
+
334
+ qt_items: list[qtw.QTreeWidgetItem] = []
335
+ rendered_bytes = 0
336
+ for node_index, node in enumerate(
337
+ view.nodes[:TRUSTED_PRESENTATION_MAX_RENDERED_NODES]
338
+ ):
339
+ node = cast(Any, node)
340
+ rendered_bytes += len(node.key.encode("utf-8")) + len(
341
+ node.value.encode("utf-8")
342
+ )
343
+ if rendered_bytes > TRUSTED_PRESENTATION_MAX_RENDERED_TEXT_BYTES:
344
+ break
345
+ item = qtw.QTreeWidgetItem([node.key, node.value])
346
+ item.setToolTip(1, getattr(node, "tooltip", node.value))
347
+ item.setToolTip(0, getattr(node, "tooltip", node.key))
348
+ path = str(getattr(node, "path", "") or "")
349
+ full_key_id = str(getattr(node, "full_key_id", "") or "")
350
+ full_value_id = str(getattr(node, "full_value_id", "") or "")
351
+ if path:
352
+ item.setData(0, FULL_VALUE_PATH_ROLE, path)
353
+ item.setData(1, FULL_VALUE_PATH_ROLE, path)
354
+ if full_key_id:
355
+ item.setData(0, FULL_VALUE_ID_ROLE, full_key_id)
356
+ if full_value_id:
357
+ item.setData(1, FULL_VALUE_ID_ROLE, full_value_id)
358
+ parent_index = node.parent_index
359
+ if (
360
+ parent_index is None
361
+ or parent_index < 0
362
+ or parent_index >= node_index
363
+ or parent_index >= len(qt_items)
364
+ ):
365
+ self.addTopLevelItem(item)
366
+ else:
367
+ qt_items[parent_index].addChild(item)
368
+ qt_items.append(item)
369
+
370
+ if not qt_items:
371
+ self.addTopLevelItem(
372
+ qtw.QTreeWidgetItem(
373
+ ["Snapshot unavailable", "The bounded view model is empty."]
374
+ )
375
+ )
376
+ return
377
+
378
+ should_expand_all = self.expand_all if expand_all is None else expand_all
379
+ if should_expand_all:
380
+ for item in qt_items:
381
+ item.setExpanded(True)
382
+ elif expanded_paths is not None:
383
+ for item in qt_items:
384
+ path = item.data(0, FULL_VALUE_PATH_ROLE)
385
+ if path:
386
+ item.setExpanded(str(path) in expanded_paths)
387
+ elif self.expand_top_level and item.parent() is None:
388
+ item.setExpanded(True)
389
+ elif self.expand_top_level:
390
+ for item in qt_items:
391
+ if item.parent() is None:
392
+ item.setExpanded(True)
393
+ header = self.header()
394
+ if header is not None:
395
+ header.setSectionResizeMode(0, qtw.QHeaderView.ResizeMode.ResizeToContents)
396
+ header.setSectionResizeMode(1, qtw.QHeaderView.ResizeMode.Stretch)
397
+ cast(Any, self).doItemsLayout()
398
+
399
+ def _set_snapshot_view(self, view: TrustedSnapshotView) -> None:
400
+ """Install only the Snapshot root page; retained source stays off Qt."""
401
+
402
+ self._require_owner_thread()
403
+ session_token = getattr(view, "session_token", "")
404
+ root_handle = getattr(view, "root_handle", None)
405
+ continuation = getattr(view, "continuation", None)
406
+ nodes = tuple(view.nodes)
407
+ self.clear()
408
+
409
+ if not isinstance(session_token, str) or not session_token:
410
+ self._set_legacy_snapshot_nodes(nodes)
411
+ return
412
+ page_node_limit = (
413
+ TRUSTED_SNAPSHOT_PAGE_MAX_DATA_NODES_WITH_CONTINUATION
414
+ if continuation is not None
415
+ else TRUSTED_SNAPSHOT_PAGE_MAX_CHILDREN
416
+ )
417
+ if len(nodes) > page_node_limit:
418
+ self.addTopLevelItem(
419
+ qtw.QTreeWidgetItem(
420
+ ["Snapshot unavailable", "The bounded view model is oversized."]
421
+ )
422
+ )
423
+ return
424
+
425
+ built = self._build_snapshot_items(
426
+ nodes,
427
+ session_token=session_token,
428
+ parent_handle=root_handle,
429
+ request_continuation=None,
430
+ parent_path="/Snapshot",
431
+ )
432
+ if built is None:
433
+ self.addTopLevelItem(
434
+ qtw.QTreeWidgetItem(
435
+ ["Snapshot unavailable", "The bounded view model is invalid."]
436
+ )
437
+ )
438
+ return
439
+
440
+ roots, _all_items, rendered_bytes = built
441
+ if (
442
+ _snapshot_page_display_text_bytes(rendered_bytes, continuation)
443
+ > TRUSTED_SNAPSHOT_PAGE_MAX_TEXT_BYTES
444
+ ):
445
+ self.addTopLevelItem(
446
+ qtw.QTreeWidgetItem(
447
+ ["Snapshot unavailable", "The bounded view model is oversized."]
448
+ )
449
+ )
450
+ return
451
+
452
+ self._snapshot_session_token = session_token
453
+ self._snapshot_root_handle = root_handle
454
+ blocker = QtCore.QSignalBlocker(self)
455
+ try:
456
+ if roots:
457
+ self.addTopLevelItems(roots)
458
+ if continuation is not None:
459
+ self.addTopLevelItem(
460
+ self._snapshot_continuation_item(
461
+ session_token,
462
+ cast(TrustedSnapshotContainerHandle, root_handle),
463
+ cast(TrustedSnapshotContinuation, continuation),
464
+ "/Snapshot",
465
+ )
466
+ )
467
+ if not roots and continuation is None:
468
+ self.addTopLevelItem(qtw.QTreeWidgetItem(["No data", ""]))
469
+ finally:
470
+ del blocker
471
+ self._finish_bounded_layout()
472
+
473
+ def _set_legacy_snapshot_nodes(self, nodes: tuple[object, ...]) -> None:
474
+ """Render old/terminal views while no lazy source handle is available."""
475
+
476
+ if not nodes:
477
+ self.addTopLevelItem(qtw.QTreeWidgetItem(["No data", ""]))
478
+ return
479
+ qt_items: list[qtw.QTreeWidgetItem] = []
480
+ rendered_bytes = 0
481
+ for node_index, raw_node in enumerate(
482
+ nodes[:TRUSTED_SNAPSHOT_PAGE_MAX_CHILDREN]
483
+ ):
484
+ node = cast(Any, raw_node)
485
+ if not isinstance(node.key, str) or not isinstance(node.value, str):
486
+ break
487
+ rendered_bytes += len(node.key.encode("utf-8")) + len(
488
+ node.value.encode("utf-8")
489
+ )
490
+ if rendered_bytes > TRUSTED_SNAPSHOT_PAGE_MAX_TEXT_BYTES:
491
+ break
492
+ item = qtw.QTreeWidgetItem([node.key, node.value])
493
+ tooltip = getattr(node, "tooltip", node.value)
494
+ if isinstance(tooltip, str):
495
+ item.setToolTip(0, tooltip)
496
+ item.setToolTip(1, tooltip)
497
+ parent_index = node.parent_index
498
+ if (
499
+ parent_index is None
500
+ or type(parent_index) is not int
501
+ or parent_index < 0
502
+ or parent_index >= node_index
503
+ or parent_index >= len(qt_items)
504
+ ):
505
+ self.addTopLevelItem(item)
506
+ else:
507
+ qt_items[parent_index].addChild(item)
508
+ qt_items.append(item)
509
+ if not qt_items:
510
+ self.addTopLevelItem(
511
+ qtw.QTreeWidgetItem(
512
+ ["Snapshot unavailable", "The bounded view model is empty."]
513
+ )
514
+ )
515
+ return
516
+ self._finish_bounded_layout()
517
+
518
+ def _build_snapshot_items(
519
+ self,
520
+ nodes: tuple[object, ...],
521
+ *,
522
+ session_token: str,
523
+ parent_handle: object,
524
+ request_continuation: object | None,
525
+ parent_path: str,
526
+ ) -> (
527
+ tuple[
528
+ list[qtw.QTreeWidgetItem],
529
+ list[qtw.QTreeWidgetItem],
530
+ int,
531
+ ]
532
+ | None
533
+ ):
534
+ """Build a detached, bounded page so rejection never partially mutates Qt."""
535
+
536
+ if len(nodes) > TRUSTED_SNAPSHOT_PAGE_MAX_CHILDREN:
537
+ return None
538
+ roots: list[qtw.QTreeWidgetItem] = []
539
+ qt_items: list[qtw.QTreeWidgetItem] = []
540
+ rendered_bytes = 0
541
+ for node_index, raw_node in enumerate(nodes):
542
+ node = cast(Any, raw_node)
543
+ key = getattr(node, "key", None)
544
+ value = getattr(node, "value", None)
545
+ parent_index = getattr(node, "parent_index", None)
546
+ if not isinstance(key, str) or not isinstance(value, str):
547
+ return None
548
+ if parent_index is not None and (
549
+ type(parent_index) is not int
550
+ or parent_index < 0
551
+ or parent_index >= node_index
552
+ or parent_index >= len(qt_items)
553
+ ):
554
+ return None
555
+ rendered_bytes += len(key.encode("utf-8")) + len(value.encode("utf-8"))
556
+ if rendered_bytes > TRUSTED_SNAPSHOT_PAGE_MAX_TEXT_BYTES:
557
+ return None
558
+
559
+ item = qtw.QTreeWidgetItem([key, value])
560
+ tooltip = getattr(node, "tooltip", value)
561
+ if not isinstance(tooltip, str):
562
+ return None
563
+ item.setToolTip(0, tooltip)
564
+ item.setToolTip(1, tooltip)
565
+ path_parent = (
566
+ parent_path
567
+ if parent_index is None
568
+ else str(
569
+ qt_items[parent_index].data(0, FULL_VALUE_PATH_ROLE) or parent_path
570
+ )
571
+ )
572
+ path = f"{path_parent.rstrip('/')}/{key}"
573
+ for column in (0, 1):
574
+ item.setData(column, SNAPSHOT_SESSION_ROLE, session_token)
575
+ item.setData(column, SNAPSHOT_PARENT_ROLE, parent_handle)
576
+ item.setData(
577
+ column,
578
+ SNAPSHOT_REQUEST_CONTINUATION_ROLE,
579
+ request_continuation,
580
+ )
581
+ item.setData(column, FULL_VALUE_PATH_ROLE, path)
582
+
583
+ container_handle = getattr(node, "container_handle", None)
584
+ if container_handle is not None:
585
+ item.setData(0, SNAPSHOT_CONTAINER_ROLE, container_handle)
586
+ item.setData(1, SNAPSHOT_CONTAINER_ROLE, container_handle)
587
+ item.setData(0, SNAPSHOT_CONTAINER_LOADED_ROLE, False)
588
+ item.setChildIndicatorPolicy(
589
+ qtw.QTreeWidgetItem.ChildIndicatorPolicy.ShowIndicator
590
+ )
591
+
592
+ full_key_handle = getattr(node, "full_key_handle", None)
593
+ full_value_handle = getattr(node, "full_value_handle", None)
594
+ if full_key_handle is not None:
595
+ item.setData(0, SNAPSHOT_FULL_VALUE_ROLE, full_key_handle)
596
+ if full_value_handle is not None:
597
+ item.setData(1, SNAPSHOT_FULL_VALUE_ROLE, full_value_handle)
598
+
599
+ if parent_index is None:
600
+ roots.append(item)
601
+ else:
602
+ qt_items[parent_index].addChild(item)
603
+ qt_items.append(item)
604
+ return roots, qt_items, rendered_bytes
605
+
606
+ @staticmethod
607
+ def _snapshot_continuation_item(
608
+ session_token: str,
609
+ parent_handle: TrustedSnapshotContainerHandle,
610
+ continuation: TrustedSnapshotContinuation,
611
+ parent_path: str,
612
+ ) -> qtw.QTreeWidgetItem:
613
+ item = qtw.QTreeWidgetItem([TRUSTED_SNAPSHOT_LOAD_MORE_TEXT, ""])
614
+ for column in (0, 1):
615
+ item.setData(column, SNAPSHOT_SESSION_ROLE, session_token)
616
+ item.setData(column, SNAPSHOT_PARENT_ROLE, parent_handle)
617
+ item.setData(column, SNAPSHOT_CONTINUATION_ROLE, continuation)
618
+ item.setData(column, SNAPSHOT_LOAD_MORE_ROLE, True)
619
+ item.setData(column, FULL_VALUE_PATH_ROLE, parent_path)
620
+ item.setToolTip(0, "Load the next bounded page of Snapshot entries.")
621
+ item.setToolTip(1, item.toolTip(0))
622
+ return item
623
+
624
+ @QtCore.pyqtSlot(qtw.QTreeWidgetItem)
625
+ def _request_expanded_snapshot_container(self, item) -> None:
626
+ if not self._snapshot_session_token:
627
+ return
628
+ container_handle = item.data(0, SNAPSHOT_CONTAINER_ROLE)
629
+ if container_handle is None or bool(
630
+ item.data(0, SNAPSHOT_CONTAINER_LOADED_ROLE)
631
+ ):
632
+ return
633
+ self._emit_snapshot_page_request(item, container_handle, None)
634
+
635
+ def _emit_snapshot_page_request(
636
+ self,
637
+ item: qtw.QTreeWidgetItem,
638
+ parent_handle: TrustedSnapshotContainerHandle,
639
+ continuation: TrustedSnapshotContinuation | None,
640
+ ) -> bool:
641
+ self._require_owner_thread()
642
+ session_token = item.data(0, SNAPSHOT_SESSION_ROLE)
643
+ if (
644
+ not isinstance(session_token, str)
645
+ or not session_token
646
+ or session_token != self._snapshot_session_token
647
+ or parent_handle is None
648
+ or item.treeWidget() is not self
649
+ or any(
650
+ request.parent_handle == parent_handle
651
+ for request in self._snapshot_pending_requests
652
+ )
653
+ ):
654
+ return False
655
+ if continuation is not None and (
656
+ not bool(item.data(0, SNAPSHOT_LOAD_MORE_ROLE))
657
+ or item.data(0, SNAPSHOT_CONTINUATION_ROLE) != continuation
658
+ ):
659
+ return False
660
+ request = SnapshotTreePageRequest(
661
+ session_token,
662
+ parent_handle,
663
+ continuation,
664
+ )
665
+ self._snapshot_pending_requests.append(request)
666
+ item.setData(0, SNAPSHOT_PAGE_PENDING_ROLE, request)
667
+ item.setData(1, SNAPSHOT_PAGE_PENDING_ROLE, request)
668
+ self.snapshotPageRequested.emit(request)
669
+ return True
670
+
671
+ def acceptSnapshotPage(self, page: TrustedSnapshotPage) -> bool:
672
+ """Atomically accept one exact pending Snapshot page on the owner thread."""
673
+
674
+ self._require_owner_thread()
675
+ if not isinstance(page, TrustedSnapshotPage):
676
+ return False
677
+ parent_handle = page.parent_handle
678
+ request_continuation = page.request_continuation
679
+ nodes = page.nodes
680
+ continuation = page.continuation
681
+ rendered_text_bytes = page.rendered_text_bytes
682
+ if (
683
+ not self._snapshot_session_token
684
+ or parent_handle is None
685
+ or not isinstance(nodes, tuple)
686
+ or type(rendered_text_bytes) is not int
687
+ or rendered_text_bytes < 0
688
+ or _snapshot_page_display_text_bytes(
689
+ rendered_text_bytes,
690
+ continuation,
691
+ )
692
+ > TRUSTED_SNAPSHOT_PAGE_MAX_TEXT_BYTES
693
+ ):
694
+ return False
695
+ page_node_limit = (
696
+ TRUSTED_SNAPSHOT_PAGE_MAX_DATA_NODES_WITH_CONTINUATION
697
+ if continuation is not None
698
+ else TRUSTED_SNAPSHOT_PAGE_MAX_CHILDREN
699
+ )
700
+ if len(nodes) > page_node_limit:
701
+ return False
702
+ request = next(
703
+ (
704
+ candidate
705
+ for candidate in self._snapshot_pending_requests
706
+ if candidate.session_token == self._snapshot_session_token
707
+ and candidate.parent_handle == parent_handle
708
+ and candidate.continuation == request_continuation
709
+ ),
710
+ None,
711
+ )
712
+ if request is None:
713
+ return False
714
+
715
+ parent_item = self._snapshot_item_for_container(parent_handle)
716
+ if parent_handle == self._snapshot_root_handle:
717
+ parent_item = None
718
+ elif parent_item is None:
719
+ return False
720
+ request_item = self._snapshot_request_item(request, parent_item)
721
+ if request_item is None:
722
+ return False
723
+ parent_path = (
724
+ "/Snapshot"
725
+ if parent_item is None
726
+ else str(parent_item.data(0, FULL_VALUE_PATH_ROLE) or "/Snapshot")
727
+ )
728
+ built = self._build_snapshot_items(
729
+ nodes,
730
+ session_token=self._snapshot_session_token,
731
+ parent_handle=parent_handle,
732
+ request_continuation=request_continuation,
733
+ parent_path=parent_path,
734
+ )
735
+ if built is None:
736
+ return False
737
+ roots, _all_items, built_bytes = built
738
+ if built_bytes != rendered_text_bytes:
739
+ return False
740
+ self._snapshot_pending_requests.remove(request)
741
+ blocker = QtCore.QSignalBlocker(self)
742
+ try:
743
+ if parent_item is not None:
744
+ parent_item.setData(0, SNAPSHOT_CONTAINER_LOADED_ROLE, True)
745
+ parent_item.setData(0, SNAPSHOT_PAGE_PENDING_ROLE, None)
746
+ parent_item.setData(1, SNAPSHOT_PAGE_PENDING_ROLE, None)
747
+ if request_continuation is not None:
748
+ self._remove_snapshot_item(request_item)
749
+ if roots:
750
+ if parent_item is None:
751
+ self.addTopLevelItems(roots)
752
+ else:
753
+ parent_item.addChildren(roots)
754
+ if continuation is not None:
755
+ more_item = self._snapshot_continuation_item(
756
+ self._snapshot_session_token,
757
+ parent_handle,
758
+ continuation,
759
+ parent_path,
760
+ )
761
+ if parent_item is None:
762
+ self.addTopLevelItem(more_item)
763
+ else:
764
+ parent_item.addChild(more_item)
765
+ if parent_item is not None and parent_item.childCount() == 0:
766
+ parent_item.setChildIndicatorPolicy(
767
+ qtw.QTreeWidgetItem.ChildIndicatorPolicy.DontShowIndicatorWhenChildless
768
+ )
769
+ finally:
770
+ del blocker
771
+ self._finish_bounded_layout()
772
+ return True
773
+
774
+ def rejectSnapshotPage(self, request: object) -> bool:
775
+ """Release one exact pending page without accepting stale page content."""
776
+
777
+ self._require_owner_thread()
778
+ if not isinstance(request, SnapshotTreePageRequest):
779
+ return False
780
+ matched = next(
781
+ (
782
+ candidate
783
+ for candidate in self._snapshot_pending_requests
784
+ if candidate == request
785
+ and candidate.session_token == self._snapshot_session_token
786
+ ),
787
+ None,
788
+ )
789
+ if matched is None:
790
+ return False
791
+ self._snapshot_pending_requests.remove(matched)
792
+ item = self._snapshot_request_item(matched, None)
793
+ if item is not None:
794
+ item.setData(0, SNAPSHOT_PAGE_PENDING_ROLE, None)
795
+ item.setData(1, SNAPSHOT_PAGE_PENDING_ROLE, None)
796
+ return True
797
+
798
+ def invalidateSnapshot(self) -> None:
799
+ """Drop the page session and every item/opaque role it owned."""
800
+
801
+ self._require_owner_thread()
802
+ self.clear()
803
+
804
+ def _snapshot_item_for_container(
805
+ self,
806
+ container_handle: object,
807
+ ) -> qtw.QTreeWidgetItem | None:
808
+ iterator = qtw.QTreeWidgetItemIterator(self)
809
+ while (item := iterator.value()) is not None:
810
+ if item.data(0, SNAPSHOT_CONTAINER_ROLE) == container_handle:
811
+ return item
812
+ iterator += 1
813
+ return None
814
+
815
+ def _snapshot_request_item(
816
+ self,
817
+ request: SnapshotTreePageRequest,
818
+ parent_item: qtw.QTreeWidgetItem | None,
819
+ ) -> qtw.QTreeWidgetItem | None:
820
+ iterator = qtw.QTreeWidgetItemIterator(self)
821
+ while (item := iterator.value()) is not None:
822
+ if item.data(0, SNAPSHOT_PAGE_PENDING_ROLE) == request:
823
+ if (
824
+ parent_item is None
825
+ or item is parent_item
826
+ or item.parent() is parent_item
827
+ ):
828
+ return item
829
+ iterator += 1
830
+ return None
831
+
832
+ def _remove_snapshot_item(self, item: qtw.QTreeWidgetItem) -> None:
833
+ parent = item.parent()
834
+ if parent is None:
835
+ index = self.indexOfTopLevelItem(item)
836
+ if index >= 0:
837
+ self.takeTopLevelItem(index)
838
+ else:
839
+ index = parent.indexOfChild(item)
840
+ if index >= 0:
841
+ parent.takeChild(index)
842
+
843
+ def _finish_bounded_layout(self) -> None:
844
+ header = self.header()
845
+ if header is not None:
846
+ header.setSectionResizeMode(0, qtw.QHeaderView.ResizeMode.ResizeToContents)
847
+ header.setSectionResizeMode(1, qtw.QHeaderView.ResizeMode.Stretch)
848
+ cast(Any, self).doItemsLayout()
849
+
850
+ def _require_owner_thread(self) -> None:
851
+ if QtCore.QThread.currentThread() != self.thread():
852
+ raise RuntimeError(
853
+ "Snapshot tree mutation must run on its Qt owner thread."
854
+ )
855
+
856
+ def expandedPaths(self) -> frozenset[str]:
857
+ expanded = set()
858
+ iterator = qtw.QTreeWidgetItemIterator(self)
859
+ while True:
860
+ item = iterator.value()
861
+ if item is None:
862
+ break
863
+ path = item.data(0, FULL_VALUE_PATH_ROLE)
864
+ if path and item.isExpanded():
865
+ expanded.add(str(path))
866
+ iterator += 1
867
+ return frozenset(expanded)
868
+
869
+ @QtCore.pyqtSlot(qtw.QTreeWidgetItem, int)
870
+ def _activate_full_value(self, item, column) -> None:
871
+ requested_column = 1 if int(column) == 1 else 0
872
+ if bool(item.data(0, SNAPSHOT_LOAD_MORE_ROLE)):
873
+ parent_handle = item.data(0, SNAPSHOT_PARENT_ROLE)
874
+ continuation = item.data(0, SNAPSHOT_CONTINUATION_ROLE)
875
+ if parent_handle is not None and continuation is not None:
876
+ self._emit_snapshot_page_request(
877
+ item,
878
+ parent_handle,
879
+ continuation,
880
+ )
881
+ return
882
+ if self._activate_snapshot_full_value(item, requested_column):
883
+ return
884
+ identifier = str(item.data(requested_column, FULL_VALUE_ID_ROLE) or "")
885
+ value_kind = "value" if requested_column == 1 else "key"
886
+ if not identifier:
887
+ requested_column = 1 - requested_column
888
+ identifier = str(item.data(requested_column, FULL_VALUE_ID_ROLE) or "")
889
+ value_kind = "value" if requested_column == 1 else "key"
890
+ if identifier:
891
+ path = str(
892
+ item.data(requested_column, FULL_VALUE_PATH_ROLE) or item.text(0)
893
+ )
894
+ self.fullValueRequested.emit(
895
+ identifier,
896
+ f"{path} ({value_kind})",
897
+ )
898
+
899
+ def _activate_snapshot_full_value(
900
+ self,
901
+ item: qtw.QTreeWidgetItem,
902
+ requested_column: int,
903
+ ) -> bool:
904
+ handle = item.data(requested_column, SNAPSHOT_FULL_VALUE_ROLE)
905
+ value_kind = "value" if requested_column == 1 else "key"
906
+ if handle is None:
907
+ requested_column = 1 - requested_column
908
+ handle = item.data(requested_column, SNAPSHOT_FULL_VALUE_ROLE)
909
+ value_kind = "value" if requested_column == 1 else "key"
910
+ if handle is None:
911
+ return False
912
+ session_token = item.data(requested_column, SNAPSHOT_SESSION_ROLE)
913
+ parent_handle = item.data(requested_column, SNAPSHOT_PARENT_ROLE)
914
+ if (
915
+ not isinstance(session_token, str)
916
+ or session_token != self._snapshot_session_token
917
+ or item.treeWidget() is not self
918
+ ):
919
+ return True
920
+ path = item.data(requested_column, FULL_VALUE_PATH_ROLE)
921
+ if not isinstance(path, str) or not path:
922
+ path = item.text(0)
923
+ self.snapshotFullValueRequested.emit(
924
+ SnapshotTreeValueRequest(
925
+ session_token,
926
+ handle,
927
+ f"{path} ({value_kind})",
928
+ parent_handle,
929
+ item.data(requested_column, SNAPSHOT_REQUEST_CONTINUATION_ROLE),
930
+ )
931
+ )
932
+ return True
933
+
934
+ @QtCore.pyqtSlot()
935
+ def _request_current_full_value(self) -> None:
936
+ item = self.currentItem()
937
+ if item is not None:
938
+ self._activate_full_value(item, self.currentColumn())
939
+
940
+ def resizeEvent(self, event):
941
+ super().resizeEvent(event)
942
+ cast(Any, self).doItemsLayout()
943
+
944
+ def openCopyMenu(self, pos):
945
+ item = self.itemAt(pos)
946
+ if item is None:
947
+ return
948
+
949
+ index = self.indexAt(pos)
950
+ column = index.column() if index.isValid() else 0
951
+ self.setCurrentItem(item, column)
952
+
953
+ menu = qtw.QMenu(self)
954
+ if any(
955
+ item.data(col, FULL_VALUE_ID_ROLE)
956
+ or item.data(col, SNAPSHOT_FULL_VALUE_ROLE) is not None
957
+ for col in (0, 1)
958
+ ):
959
+ menu.addAction(self.view_full_value_action)
960
+ menu.addSeparator()
961
+ menu.addAction(self.copy_value_action)
962
+
963
+ copy_row = QtGui.QAction("Copy Row", menu)
964
+ copy_row.triggered.connect(lambda: copy_to_clipboard(row_text(item)))
965
+ menu.addAction(copy_row)
966
+
967
+ if self.selectedItems():
968
+ menu.addAction(self.copy_selection_action)
969
+
970
+ viewport = self.viewport()
971
+ if viewport is not None:
972
+ menu.exec(viewport.mapToGlobal(pos))
973
+
974
+ def copyValue(self):
975
+ item = self.currentItem()
976
+ if item is not None:
977
+ copy_to_clipboard(item.text(1))
978
+
979
+ def copySelection(self):
980
+ items = self.selectedItems()
981
+ current_item = self.currentItem()
982
+ if not items and current_item is not None:
983
+ items = [current_item]
984
+ copy_to_clipboard("\n".join(row_text(item) for item in items))
985
+
986
+
987
+ class CopyableTableWidget(qtw.QTableWidget):
988
+ def __init__(self, *args, **kwargs):
989
+ super().__init__(*args, **kwargs)
990
+ self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
991
+ self.customContextMenuRequested.connect(self.openCopyMenu)
992
+
993
+ self.copy_cell_action = copy_action(
994
+ "Copy Cell", COPY_CELL_SHORTCUTS, self.copyCell, self
995
+ )
996
+ self.copy_selection_action = copy_action(
997
+ "Copy Selection", COPY_SELECTION_SHORTCUTS, self.copySelection, self
998
+ )
999
+ self.addActions([self.copy_cell_action, self.copy_selection_action])
1000
+
1001
+ def openCopyMenu(self, pos):
1002
+ item = self.itemAt(pos)
1003
+ if item is None:
1004
+ return
1005
+
1006
+ self.setCurrentItem(item)
1007
+
1008
+ menu = qtw.QMenu(self)
1009
+ menu.addAction(self.copy_cell_action)
1010
+ menu.addAction(self.copy_selection_action)
1011
+
1012
+ viewport = self.viewport()
1013
+ if viewport is not None:
1014
+ menu.exec(viewport.mapToGlobal(pos))
1015
+
1016
+ def copyCell(self):
1017
+ item = self.currentItem()
1018
+ if item is not None:
1019
+ copy_to_clipboard(item.text())
1020
+
1021
+ def copySelection(self):
1022
+ ranges = self.selectedRanges()
1023
+ if not ranges and self.currentItem() is not None:
1024
+ self.copyCell()
1025
+ return
1026
+
1027
+ sections = []
1028
+ for selected_range in ranges:
1029
+ rows = []
1030
+ for row in range(selected_range.topRow(), selected_range.bottomRow() + 1):
1031
+ values = []
1032
+ for col in range(
1033
+ selected_range.leftColumn(),
1034
+ selected_range.rightColumn() + 1,
1035
+ ):
1036
+ item = self.item(row, col)
1037
+ values.append(item.text() if item is not None else "")
1038
+ rows.append("\t".join(values))
1039
+ sections.append("\n".join(rows))
1040
+
1041
+ copy_to_clipboard("\n".join(section for section in sections if section))
1042
+
1043
+
1044
+ def dictToTree(d: dict, truncate_values=False):
1045
+ items = []
1046
+ for k, v in d.items():
1047
+ if not isinstance(v, dict):
1048
+ item = qtw.QTreeWidgetItem(
1049
+ [str(k), format_value(v, 180 if truncate_values else None)]
1050
+ )
1051
+ item.setToolTip(1, format_value(v))
1052
+ else:
1053
+ item = qtw.QTreeWidgetItem([k, ""])
1054
+ for child in dictToTree(v, truncate_values=truncate_values):
1055
+ item.addChild(child)
1056
+ items.append(item)
1057
+ return items
1058
+
1059
+
1060
+ def snapshot_parameters(snapshot):
1061
+ if not isinstance(snapshot, dict):
1062
+ return {}
1063
+
1064
+ out = {}
1065
+ parameter_dicts = []
1066
+
1067
+ params = snapshot.get("parameters")
1068
+ if isinstance(params, dict):
1069
+ parameter_dicts.append(params)
1070
+
1071
+ station = snapshot.get("station")
1072
+ if isinstance(station, dict):
1073
+ params = station.get("parameters")
1074
+ if isinstance(params, dict):
1075
+ parameter_dicts.append(params)
1076
+
1077
+ instruments = station.get("instruments")
1078
+ if isinstance(instruments, dict):
1079
+ for instrument in instruments.values():
1080
+ if isinstance(instrument, dict) and isinstance(
1081
+ instrument.get("parameters"), dict
1082
+ ):
1083
+ parameter_dicts.append(instrument["parameters"])
1084
+
1085
+ for params in parameter_dicts:
1086
+ for key, details in params.items():
1087
+ if not isinstance(details, dict):
1088
+ continue
1089
+ for name in (key, details.get("name"), details.get("full_name")):
1090
+ if name:
1091
+ out[str(name)] = details
1092
+
1093
+ return out
1094
+
1095
+
1096
+ def format_value(value, max_len=None):
1097
+ if value is None:
1098
+ text = ""
1099
+ elif isinstance(value, float):
1100
+ text = f"{value:.6g}"
1101
+ elif isinstance(value, (list, tuple)):
1102
+ text = ", ".join(format_value(item) for item in value)
1103
+ else:
1104
+ text = str(value)
1105
+
1106
+ text = text.replace("\n", " ")
1107
+ if max_len is not None and len(text) > max_len:
1108
+ return text[: max_len - 3] + "..."
1109
+ return text
1110
+
1111
+
1112
+ def row_text(item):
1113
+ return "\t".join(item.text(col) for col in range(item.columnCount()))
1114
+
1115
+
1116
+ def copy_to_clipboard(text):
1117
+ clipboard = qtw.QApplication.clipboard()
1118
+ if clipboard is not None:
1119
+ clipboard.setText(text)