qplotter 1.6.0b3__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.
- qplot/__init__.py +46 -0
- qplot/__main__.py +232 -0
- qplot/_auxiliary_paths.py +63 -0
- qplot/_metadata.py +12 -0
- qplot/_repair.py +14 -0
- qplot/_shutdown_supervisor.py +4049 -0
- qplot/_version.py +12 -0
- qplot/_windows_shutdown_job.py +1166 -0
- qplot/configuration/__init__.py +3 -0
- qplot/configuration/config.py +573 -0
- qplot/configuration/config_schema.json +319 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +579 -0
- qplot/configuration/themes/blank.py +17 -0
- qplot/configuration/themes/dark.py +54 -0
- qplot/configuration/themes/light.py +54 -0
- qplot/datahandling/LoadFromDB.py +224 -0
- qplot/datahandling/__init__.py +47 -0
- qplot/datahandling/_trusted_live_helper.py +812 -0
- qplot/datahandling/_trusted_live_protocol.py +1745 -0
- qplot/datahandling/database.py +2330 -0
- qplot/datahandling/dimensions.py +43 -0
- qplot/datahandling/file_identity.py +461 -0
- qplot/datahandling/parameter_data.py +149 -0
- qplot/datahandling/plot_progress.py +17 -0
- qplot/datahandling/qcodes_cache.py +277 -0
- qplot/datahandling/qcodes_compat.py +14 -0
- qplot/datahandling/readSQL.py +2442 -0
- qplot/datahandling/readonly.py +2561 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +127 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +868 -0
- qplot/datahandling/trusted_heatmap.py +241 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1894 -0
- qplot/datahandling/trusted_live_supervisor.py +2161 -0
- qplot/datahandling/trusted_plot.py +283 -0
- qplot/datahandling/trusted_presentation.py +1065 -0
- qplot/datahandling/trusted_snapshot.py +1132 -0
- qplot/datahandling/trusted_work_coordinator.py +2157 -0
- qplot/datahandling/trusted_work_scheduler.py +1204 -0
- qplot/diagnostics.py +241 -0
- qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
- qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
- qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
- qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
- qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
- qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
- qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
- qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
- qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
- qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
- qplot/testdata.py +1733 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +114 -0
- qplot/tools/general.py +134 -0
- qplot/tools/heatmap_geometry.py +381 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +625 -0
- qplot/tools/sample_statistics.py +56 -0
- qplot/tools/worker.py +2787 -0
- qplot/windows/__init__.py +9 -0
- qplot/windows/_colorbar.py +422 -0
- qplot/windows/_commands.py +523 -0
- qplot/windows/_config_persistence.py +123 -0
- qplot/windows/_database_actions.py +4904 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_dragdrop.py +71 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +169 -0
- qplot/windows/_native_averaging.py +173 -0
- qplot/windows/_native_transforms.py +389 -0
- qplot/windows/_plot1d_snap.py +608 -0
- qplot/windows/_plot1d_traces.py +2183 -0
- qplot/windows/_plot2d_colorbar.py +1126 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2197 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +1993 -0
- qplot/windows/_plot_actions.py +2695 -0
- qplot/windows/_plot_appearance.py +276 -0
- qplot/windows/_plot_axis_scaling.py +1485 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +110 -0
- qplot/windows/_plot_marquee.py +656 -0
- qplot/windows/_plot_refresh.py +942 -0
- qplot/windows/_plot_state.py +239 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +635 -0
- qplot/windows/_refresh_interval.py +70 -0
- qplot/windows/_run_controls.py +828 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +531 -0
- qplot/windows/_subplots/subplot2d.py +918 -0
- qplot/windows/_svg_export.py +209 -0
- qplot/windows/_trusted_derived_qt.py +1759 -0
- qplot/windows/_widgets/__init__.py +23 -0
- qplot/windows/_widgets/_run_formatting.py +387 -0
- qplot/windows/_widgets/details_tables.py +1119 -0
- qplot/windows/_widgets/dropbox.py +290 -0
- qplot/windows/_widgets/operations.py +460 -0
- qplot/windows/_widgets/preview.py +2997 -0
- qplot/windows/_widgets/run_list_items.py +488 -0
- qplot/windows/_widgets/toolbar.py +111 -0
- qplot/windows/_widgets/treeWidgets.py +2572 -0
- qplot/windows/_window_controls.py +413 -0
- qplot/windows/main.py +1635 -0
- qplot/windows/plot1d.py +311 -0
- qplot/windows/plot2d.py +1901 -0
- qplotter-1.6.0b3.dist-info/METADATA +318 -0
- qplotter-1.6.0b3.dist-info/RECORD +119 -0
- qplotter-1.6.0b3.dist-info/WHEEL +5 -0
- qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
- qplotter-1.6.0b3.dist-info/top_level.txt +1 -0
qplot/windows/main.py
ADDED
|
@@ -0,0 +1,1635 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import threading
|
|
4
|
+
from time import monotonic, perf_counter
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
from PyQt6 import (
|
|
8
|
+
QtCore,
|
|
9
|
+
QtGui,
|
|
10
|
+
)
|
|
11
|
+
from PyQt6 import (
|
|
12
|
+
QtWidgets as qtw,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from qplot import config
|
|
16
|
+
from qplot.datahandling.database import (
|
|
17
|
+
DatabaseDetailWorker as DatabaseDetailWorker,
|
|
18
|
+
)
|
|
19
|
+
from qplot.datahandling.database import (
|
|
20
|
+
DatabaseExpensiveDetailWorker as DatabaseExpensiveDetailWorker,
|
|
21
|
+
)
|
|
22
|
+
from qplot.datahandling.database import (
|
|
23
|
+
DatabaseLoadWorker as DatabaseLoadWorker,
|
|
24
|
+
)
|
|
25
|
+
from qplot.datahandling.database import (
|
|
26
|
+
DatabaseRefreshWorker as DatabaseRefreshWorker,
|
|
27
|
+
)
|
|
28
|
+
from qplot.datahandling.database import (
|
|
29
|
+
DatabaseSelectedRunWorker as DatabaseSelectedRunWorker,
|
|
30
|
+
)
|
|
31
|
+
from qplot.datahandling.database import (
|
|
32
|
+
database_info_report as database_info_report,
|
|
33
|
+
)
|
|
34
|
+
from qplot.datahandling.database import (
|
|
35
|
+
database_path_from_mime_data as database_path_from_mime_data,
|
|
36
|
+
)
|
|
37
|
+
from qplot.diagnostics import get_logger, log_bounded_shutdown, log_user_error
|
|
38
|
+
|
|
39
|
+
from ._commands import create_action
|
|
40
|
+
from ._config_persistence import (
|
|
41
|
+
persist_config_action,
|
|
42
|
+
persist_config_value,
|
|
43
|
+
set_widget_value_without_signals,
|
|
44
|
+
)
|
|
45
|
+
from ._database_actions import DatabaseActionsMixin, TestDatabaseGenerationWorker
|
|
46
|
+
from ._dataset_handle import DatasetHandle, DatasetKey
|
|
47
|
+
from ._help import add_help_menu
|
|
48
|
+
from ._plot_actions import PlotActionsMixin
|
|
49
|
+
from ._preferences import (
|
|
50
|
+
PreferencesDialog,
|
|
51
|
+
create_preferences_action,
|
|
52
|
+
)
|
|
53
|
+
from ._run_controls import AUTO_PLOT_KEY, RunControlsMixin
|
|
54
|
+
from ._trusted_derived_qt import TrustedDerivedQtBridge
|
|
55
|
+
from ._window_controls import (
|
|
56
|
+
CONFIRM_CLOSE_ALL_KEY,
|
|
57
|
+
CONFIRM_QUIT_KEY,
|
|
58
|
+
add_application_quit_action,
|
|
59
|
+
add_restore_defaults_option,
|
|
60
|
+
add_standard_window_controls,
|
|
61
|
+
ask_confirmation_with_dont_ask_again,
|
|
62
|
+
close_all_warning_enabled,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
MAIN_WINDOW_READABLE_WIDTH = 780
|
|
66
|
+
_APPLICATION_SHUTDOWN_TIMEOUT_SECONDS = 15.0
|
|
67
|
+
_APPLICATION_SHUTDOWN_DIAGNOSTIC_GRACE_SECONDS = 0.25
|
|
68
|
+
_APPLICATION_FORCED_SHUTDOWN_EXIT_CODE = 70
|
|
69
|
+
_RETIRED_SERVICE_REAPER_INTERVAL_MS = 100
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _flush_shutdown_diagnostics():
|
|
73
|
+
"""Flush qPlot's persistent diagnostics and process text streams."""
|
|
74
|
+
|
|
75
|
+
for handler in tuple(get_logger().handlers):
|
|
76
|
+
try:
|
|
77
|
+
handler.flush()
|
|
78
|
+
stream = getattr(handler, "stream", None)
|
|
79
|
+
if stream is not None:
|
|
80
|
+
os.fsync(stream.fileno())
|
|
81
|
+
except BaseException:
|
|
82
|
+
pass
|
|
83
|
+
for stream in (sys.stderr, sys.stdout):
|
|
84
|
+
try:
|
|
85
|
+
stream.flush()
|
|
86
|
+
except BaseException:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _persist_shutdown_diagnostics(*, started_at, total_timeout, diagnostics):
|
|
91
|
+
"""Persist an exact bounded-shutdown snapshot before a hard process exit."""
|
|
92
|
+
|
|
93
|
+
elapsed = max(0.0, monotonic() - started_at)
|
|
94
|
+
if not diagnostics:
|
|
95
|
+
diagnostics = ("background work remained active without diagnostics",)
|
|
96
|
+
details = f"elapsed={elapsed:.3f}s\n" + "\n".join(diagnostics)
|
|
97
|
+
try:
|
|
98
|
+
log_bounded_shutdown(
|
|
99
|
+
"Bounded Application Shutdown: "
|
|
100
|
+
f"the {total_timeout:g}-second monotonic shutdown deadline was "
|
|
101
|
+
"exhausted; qPlot will terminate at the process boundary if "
|
|
102
|
+
"Qt-owned work still cannot be destroyed.",
|
|
103
|
+
details,
|
|
104
|
+
logger_name=__name__,
|
|
105
|
+
)
|
|
106
|
+
finally:
|
|
107
|
+
_flush_shutdown_diagnostics()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _ProcessShutdownFailSafe:
|
|
111
|
+
"""Pair a direct-parent supervisor with a deadline-only local backstop."""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
supervisor_client=None,
|
|
116
|
+
*,
|
|
117
|
+
startup_diagnostic=None,
|
|
118
|
+
force_exit=os._exit,
|
|
119
|
+
):
|
|
120
|
+
self._supervisor_client = supervisor_client
|
|
121
|
+
self._startup_diagnostic = (
|
|
122
|
+
None if startup_diagnostic is None else str(startup_diagnostic)
|
|
123
|
+
)
|
|
124
|
+
self._force_exit = force_exit
|
|
125
|
+
self._lock = threading.Lock()
|
|
126
|
+
self._persistence_io_lock = threading.Lock()
|
|
127
|
+
self._cancelled = threading.Event()
|
|
128
|
+
self._fallback_diagnostic_started = threading.Event()
|
|
129
|
+
self._fallback_diagnostic_completed = threading.Event()
|
|
130
|
+
self._armed = False
|
|
131
|
+
self._started_at = 0.0
|
|
132
|
+
self._diagnostic_deadline = 0.0
|
|
133
|
+
self._hard_deadline = 0.0
|
|
134
|
+
self._total_timeout = 0.0
|
|
135
|
+
self._diagnostics: tuple[str, ...] = ()
|
|
136
|
+
self._supervisor_diagnostics: tuple[str, ...] = ()
|
|
137
|
+
self._diagnostic_version = 0
|
|
138
|
+
self._persisted_version = -1
|
|
139
|
+
self._supervisor_acknowledged = False
|
|
140
|
+
self._fallback_active = False
|
|
141
|
+
self._persistence_active = False
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def armed(self):
|
|
145
|
+
with self._lock:
|
|
146
|
+
return self._armed
|
|
147
|
+
|
|
148
|
+
def arm(self, *, started_at, diagnostic_deadline, hard_deadline):
|
|
149
|
+
"""Arm once after shutdown confirmation; later calls never extend it."""
|
|
150
|
+
|
|
151
|
+
with self._lock:
|
|
152
|
+
if self._armed:
|
|
153
|
+
return None
|
|
154
|
+
self._armed = True
|
|
155
|
+
self._started_at = started_at
|
|
156
|
+
self._diagnostic_deadline = diagnostic_deadline
|
|
157
|
+
self._hard_deadline = hard_deadline
|
|
158
|
+
self._total_timeout = max(0.0, hard_deadline - started_at)
|
|
159
|
+
self._diagnostics = ()
|
|
160
|
+
self._supervisor_diagnostics = ()
|
|
161
|
+
self._diagnostic_version = 0
|
|
162
|
+
self._persisted_version = -1
|
|
163
|
+
self._supervisor_acknowledged = False
|
|
164
|
+
self._fallback_active = False
|
|
165
|
+
self._persistence_active = False
|
|
166
|
+
self._cancelled.clear()
|
|
167
|
+
self._fallback_diagnostic_started.clear()
|
|
168
|
+
self._fallback_diagnostic_completed.clear()
|
|
169
|
+
|
|
170
|
+
if self._startup_diagnostic is not None:
|
|
171
|
+
self._append_supervisor_diagnostic(self._startup_diagnostic)
|
|
172
|
+
client = self._supervisor_client
|
|
173
|
+
if client is None:
|
|
174
|
+
self._activate_fallback()
|
|
175
|
+
diagnostic = self._startup_diagnostic
|
|
176
|
+
if diagnostic is None:
|
|
177
|
+
diagnostic = (
|
|
178
|
+
"process shutdown supervisor is unavailable; "
|
|
179
|
+
"only the in-process deadline fallback is armed"
|
|
180
|
+
)
|
|
181
|
+
self._append_supervisor_diagnostic(diagnostic)
|
|
182
|
+
return diagnostic
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
arm_diagnostic = client.arm(hard_deadline)
|
|
186
|
+
except BaseException as error:
|
|
187
|
+
arm_diagnostic = (
|
|
188
|
+
"process shutdown supervisor ARM raised "
|
|
189
|
+
f"{type(error).__name__}: {error}"
|
|
190
|
+
)
|
|
191
|
+
# The direct parent has either installed its immutable deadline or
|
|
192
|
+
# returned an exact failure by this point. Install the independent
|
|
193
|
+
# GUI-local backstop before doing any diagnostic persistence.
|
|
194
|
+
self._activate_fallback()
|
|
195
|
+
if arm_diagnostic:
|
|
196
|
+
exact = str(arm_diagnostic)
|
|
197
|
+
self._append_supervisor_diagnostic(exact)
|
|
198
|
+
return exact
|
|
199
|
+
with self._lock:
|
|
200
|
+
self._supervisor_acknowledged = True
|
|
201
|
+
return self._latest_supervisor_diagnostic()
|
|
202
|
+
|
|
203
|
+
def update_diagnostics(self, diagnostics):
|
|
204
|
+
"""Publish an in-memory liveness snapshot without doing diagnostic I/O."""
|
|
205
|
+
|
|
206
|
+
exact = tuple(str(item) for item in diagnostics)
|
|
207
|
+
with self._lock:
|
|
208
|
+
if exact == self._diagnostics:
|
|
209
|
+
return
|
|
210
|
+
self._diagnostics = exact
|
|
211
|
+
self._diagnostic_version += 1
|
|
212
|
+
|
|
213
|
+
def persist_now(self):
|
|
214
|
+
"""Persist the newest exact snapshot, once per diagnostic version."""
|
|
215
|
+
|
|
216
|
+
with self._persistence_io_lock:
|
|
217
|
+
with self._lock:
|
|
218
|
+
if not self._armed:
|
|
219
|
+
return
|
|
220
|
+
version = self._diagnostic_version
|
|
221
|
+
if version <= self._persisted_version:
|
|
222
|
+
return
|
|
223
|
+
started_at = self._started_at
|
|
224
|
+
total_timeout = self._total_timeout
|
|
225
|
+
diagnostics = self._effective_diagnostics_locked()
|
|
226
|
+
_persist_shutdown_diagnostics(
|
|
227
|
+
started_at=started_at,
|
|
228
|
+
total_timeout=total_timeout,
|
|
229
|
+
diagnostics=diagnostics,
|
|
230
|
+
)
|
|
231
|
+
with self._lock:
|
|
232
|
+
self._persisted_version = max(self._persisted_version, version)
|
|
233
|
+
|
|
234
|
+
def persist_async(self):
|
|
235
|
+
"""Persist on a daemon thread so diagnostic I/O cannot delay exit."""
|
|
236
|
+
|
|
237
|
+
with self._lock:
|
|
238
|
+
if not self._armed or self._persistence_active:
|
|
239
|
+
return
|
|
240
|
+
self._persistence_active = True
|
|
241
|
+
try:
|
|
242
|
+
threading.Thread(
|
|
243
|
+
target=self._persist_async_worker,
|
|
244
|
+
name="qplot-shutdown-diagnostic-persistence",
|
|
245
|
+
daemon=True,
|
|
246
|
+
).start()
|
|
247
|
+
except BaseException as error:
|
|
248
|
+
with self._lock:
|
|
249
|
+
self._persistence_active = False
|
|
250
|
+
self._append_supervisor_diagnostic(
|
|
251
|
+
"process shutdown diagnostic thread setup raised "
|
|
252
|
+
f"{type(error).__name__}: {error}"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def _persist_async_worker(self):
|
|
256
|
+
try:
|
|
257
|
+
while True:
|
|
258
|
+
self.persist_now()
|
|
259
|
+
with self._lock:
|
|
260
|
+
if (
|
|
261
|
+
not self._armed
|
|
262
|
+
or self._persisted_version >= self._diagnostic_version
|
|
263
|
+
):
|
|
264
|
+
self._persistence_active = False
|
|
265
|
+
return
|
|
266
|
+
finally:
|
|
267
|
+
with self._lock:
|
|
268
|
+
self._persistence_active = False
|
|
269
|
+
|
|
270
|
+
def disarm(self):
|
|
271
|
+
"""Cancel only the GUI-local fallback after quiescent Qt teardown."""
|
|
272
|
+
|
|
273
|
+
# There is deliberately no supervisor DISARM message. Once ARM was
|
|
274
|
+
# accepted, the direct parent remains armed until it reaps this process.
|
|
275
|
+
with self._lock:
|
|
276
|
+
self._armed = False
|
|
277
|
+
self._cancelled.set()
|
|
278
|
+
|
|
279
|
+
def wait_for_forced_exit(self):
|
|
280
|
+
"""Reach the process boundary before any possibly blocking destructor."""
|
|
281
|
+
|
|
282
|
+
with self._lock:
|
|
283
|
+
hard_deadline = self._hard_deadline
|
|
284
|
+
armed = self._armed
|
|
285
|
+
if not armed:
|
|
286
|
+
return
|
|
287
|
+
if self._cancelled.wait(max(0.0, hard_deadline - monotonic())):
|
|
288
|
+
return
|
|
289
|
+
self._force_exit(_APPLICATION_FORCED_SHUTDOWN_EXIT_CODE)
|
|
290
|
+
|
|
291
|
+
def watchdog_operational(self):
|
|
292
|
+
"""Return whether the direct parent acknowledged immutable ARM."""
|
|
293
|
+
|
|
294
|
+
with self._lock:
|
|
295
|
+
return self._armed and self._supervisor_acknowledged
|
|
296
|
+
|
|
297
|
+
def _fallback_diagnostic_watchdog(self):
|
|
298
|
+
self._fallback_diagnostic_started.set()
|
|
299
|
+
if self._cancelled.wait(max(0.0, self._diagnostic_deadline - monotonic())):
|
|
300
|
+
return
|
|
301
|
+
# This watchdog already runs away from the GUI thread. Persist here
|
|
302
|
+
# instead of scheduling a second daemon so the hard-deadline watchdog
|
|
303
|
+
# cannot overtake diagnostic publication merely because that extra
|
|
304
|
+
# thread has not yet been scheduled.
|
|
305
|
+
try:
|
|
306
|
+
self.persist_now()
|
|
307
|
+
finally:
|
|
308
|
+
self._fallback_diagnostic_completed.set()
|
|
309
|
+
|
|
310
|
+
def _fallback_termination_watchdog(self):
|
|
311
|
+
# Prefer a completed diagnostic snapshot before termination without
|
|
312
|
+
# ever allowing slow or blocked diagnostic I/O to extend the immutable
|
|
313
|
+
# hard deadline. This also prevents the two fallback threads racing at
|
|
314
|
+
# the deadline after ordinary, fast persistence has already started.
|
|
315
|
+
self._fallback_diagnostic_completed.wait(
|
|
316
|
+
max(0.0, self._hard_deadline - monotonic())
|
|
317
|
+
)
|
|
318
|
+
if self._cancelled.wait(max(0.0, self._hard_deadline - monotonic())):
|
|
319
|
+
return
|
|
320
|
+
self._force_exit(_APPLICATION_FORCED_SHUTDOWN_EXIT_CODE)
|
|
321
|
+
|
|
322
|
+
def _activate_fallback(self):
|
|
323
|
+
with self._lock:
|
|
324
|
+
if self._fallback_active or not self._armed:
|
|
325
|
+
return
|
|
326
|
+
self._fallback_active = True
|
|
327
|
+
for name, target in (
|
|
328
|
+
(
|
|
329
|
+
"qplot-shutdown-diagnostic-fallback",
|
|
330
|
+
self._fallback_diagnostic_watchdog,
|
|
331
|
+
),
|
|
332
|
+
(
|
|
333
|
+
"qplot-shutdown-process-fallback",
|
|
334
|
+
self._fallback_termination_watchdog,
|
|
335
|
+
),
|
|
336
|
+
):
|
|
337
|
+
try:
|
|
338
|
+
threading.Thread(target=target, name=name, daemon=True).start()
|
|
339
|
+
if target == self._fallback_diagnostic_watchdog:
|
|
340
|
+
readiness_deadline = min(
|
|
341
|
+
self._diagnostic_deadline,
|
|
342
|
+
self._hard_deadline,
|
|
343
|
+
)
|
|
344
|
+
self._fallback_diagnostic_started.wait(
|
|
345
|
+
max(0.0, readiness_deadline - monotonic())
|
|
346
|
+
)
|
|
347
|
+
except BaseException as error:
|
|
348
|
+
self._append_supervisor_diagnostic(
|
|
349
|
+
"process shutdown local fallback setup raised "
|
|
350
|
+
f"{type(error).__name__}: {error}"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def _append_supervisor_diagnostic(self, diagnostic):
|
|
354
|
+
exact = str(diagnostic)
|
|
355
|
+
with self._lock:
|
|
356
|
+
if exact in self._supervisor_diagnostics:
|
|
357
|
+
return
|
|
358
|
+
self._supervisor_diagnostics = (*self._supervisor_diagnostics, exact)
|
|
359
|
+
self._diagnostic_version += 1
|
|
360
|
+
|
|
361
|
+
def _latest_supervisor_diagnostic(self):
|
|
362
|
+
with self._lock:
|
|
363
|
+
if not self._supervisor_diagnostics:
|
|
364
|
+
return None
|
|
365
|
+
return self._supervisor_diagnostics[-1]
|
|
366
|
+
|
|
367
|
+
def _effective_diagnostics_locked(self):
|
|
368
|
+
return tuple(dict.fromkeys((*self._supervisor_diagnostics, *self._diagnostics)))
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class DatabasePathLineEdit(qtw.QLineEdit):
|
|
372
|
+
"""
|
|
373
|
+
Read-only database path field that accepts dropped QCoDeS database files.
|
|
374
|
+
|
|
375
|
+
"""
|
|
376
|
+
databaseDropped = QtCore.pyqtSignal(str)
|
|
377
|
+
|
|
378
|
+
def __init__(self, *args, **kwargs):
|
|
379
|
+
super().__init__(*args, **kwargs)
|
|
380
|
+
|
|
381
|
+
self._database_path = ""
|
|
382
|
+
self.setAcceptDrops(True)
|
|
383
|
+
|
|
384
|
+
def setText(self, text):
|
|
385
|
+
self._database_path = str(text or "")
|
|
386
|
+
|
|
387
|
+
if not self._database_path:
|
|
388
|
+
super().setText("")
|
|
389
|
+
self.setToolTip("Current database path. Drop a QCoDeS .db file here to load it.")
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
super().setText(os.path.basename(self._database_path) or self._database_path)
|
|
393
|
+
self.setCursorPosition(0)
|
|
394
|
+
self.setToolTip(
|
|
395
|
+
"Current database:\n"
|
|
396
|
+
f"{self._database_path}\n\n"
|
|
397
|
+
"Drop a QCoDeS .db file here to load it."
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
def text(self):
|
|
401
|
+
return self._database_path
|
|
402
|
+
|
|
403
|
+
def dragEnterEvent(self, event):
|
|
404
|
+
if database_path_from_mime_data(event.mimeData()) is not None:
|
|
405
|
+
event.acceptProposedAction()
|
|
406
|
+
else:
|
|
407
|
+
event.ignore()
|
|
408
|
+
|
|
409
|
+
def dragMoveEvent(self, event):
|
|
410
|
+
self.dragEnterEvent(event)
|
|
411
|
+
|
|
412
|
+
def dropEvent(self, event):
|
|
413
|
+
path = database_path_from_mime_data(event.mimeData())
|
|
414
|
+
if path is None:
|
|
415
|
+
event.ignore()
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
event.acceptProposedAction()
|
|
419
|
+
self.databaseDropped.emit(os.path.abspath(path))
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class MainWindow(
|
|
423
|
+
DatabaseActionsMixin,
|
|
424
|
+
PlotActionsMixin,
|
|
425
|
+
RunControlsMixin,
|
|
426
|
+
qtw.QMainWindow,
|
|
427
|
+
):
|
|
428
|
+
"""
|
|
429
|
+
The Main application which connects/initialises QCoDeS database, displays
|
|
430
|
+
available options plots to open, and opens windows.
|
|
431
|
+
|
|
432
|
+
This window can be opened by calling qplot.run()
|
|
433
|
+
|
|
434
|
+
Holds a shallow copy of all other open windows to prevent deletion by
|
|
435
|
+
python's garbarge collector
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
def __init__(self, startup_database_path=None):
|
|
439
|
+
startup_start = perf_counter()
|
|
440
|
+
super().__init__()
|
|
441
|
+
|
|
442
|
+
#vars
|
|
443
|
+
self.config = config() # Connect to config.json in :/users/<user>/.qplot/
|
|
444
|
+
self.windows = [] # prevent auto delete of windows
|
|
445
|
+
self.ds = None
|
|
446
|
+
self._selected_dataset_key: DatasetKey | None = None
|
|
447
|
+
self.preview_size = self._configured_preview_size()
|
|
448
|
+
self.dataset_holder: dict[DatasetKey, DatasetHandle] = {}
|
|
449
|
+
self.monitor = QtCore.QTimer()
|
|
450
|
+
self.threadPool = QtCore.QThreadPool()
|
|
451
|
+
self.threadPool.setMaxThreadCount(self.config.get("runtime_settings.max_threads"))
|
|
452
|
+
self._plot_workers: set[object] = set()
|
|
453
|
+
self.threadPool._qplot_workers = self._plot_workers # type: ignore[attr-defined]
|
|
454
|
+
self.databaseLoadThreadPool = QtCore.QThreadPool(self)
|
|
455
|
+
self.databaseLoadThreadPool.setMaxThreadCount(1)
|
|
456
|
+
self.databaseDetailThreadPool = QtCore.QThreadPool(self)
|
|
457
|
+
self.databaseDetailThreadPool.setMaxThreadCount(1)
|
|
458
|
+
self.databaseExpensiveDetailThreadPool = QtCore.QThreadPool(self)
|
|
459
|
+
self.databaseExpensiveDetailThreadPool.setMaxThreadCount(1)
|
|
460
|
+
self.databaseRefreshThreadPool = QtCore.QThreadPool(self)
|
|
461
|
+
self.databaseRefreshThreadPool.setMaxThreadCount(1)
|
|
462
|
+
self.databaseSelectedRunThreadPool = QtCore.QThreadPool(self)
|
|
463
|
+
self.databaseSelectedRunThreadPool.setMaxThreadCount(1)
|
|
464
|
+
self.testDatabaseGenerationThreadPool = QtCore.QThreadPool(self)
|
|
465
|
+
self.testDatabaseGenerationThreadPool.setMaxThreadCount(1)
|
|
466
|
+
self._database_load_generation = 0
|
|
467
|
+
self._database_load_active = False
|
|
468
|
+
self._database_load_state = None
|
|
469
|
+
self._database_load_worker = None
|
|
470
|
+
self._database_info_generation = 0
|
|
471
|
+
self._database_info_worker = None
|
|
472
|
+
self._database_info_instance = None
|
|
473
|
+
self._database_info_active = False
|
|
474
|
+
self._loaded_database_identity = None
|
|
475
|
+
self._loaded_database_instance = None # type: ignore[assignment]
|
|
476
|
+
self._database_detail_generation = 0
|
|
477
|
+
self._database_detail_active = False
|
|
478
|
+
self._database_detail_worker = None
|
|
479
|
+
self._database_detail_instance = None
|
|
480
|
+
self._database_expensive_detail_generation = 0
|
|
481
|
+
self._database_expensive_detail_active = False
|
|
482
|
+
self._database_expensive_detail_worker = None
|
|
483
|
+
self._database_expensive_detail_instance = None
|
|
484
|
+
self._database_refresh_generation = 0
|
|
485
|
+
self._database_refresh_active = False
|
|
486
|
+
self._database_refresh_pending = False
|
|
487
|
+
self._database_refresh_worker: DatabaseRefreshWorker | None = None
|
|
488
|
+
self._database_refresh_identity = None
|
|
489
|
+
self._database_refresh_instance = None
|
|
490
|
+
self._database_refresh_staged_new_runs = {}
|
|
491
|
+
self._database_refresh_publication_active = False
|
|
492
|
+
self._database_selected_run_generation = 0
|
|
493
|
+
self._database_selected_run_worker: DatabaseSelectedRunWorker | None = None
|
|
494
|
+
self._database_selected_run_instance = None
|
|
495
|
+
self._database_selected_run_mode = None
|
|
496
|
+
self._restoring_selected_run_publication = False
|
|
497
|
+
self._selected_run_restore_pending = False
|
|
498
|
+
self._selected_run_guid = None
|
|
499
|
+
self._selected_run_detail_cache = {}
|
|
500
|
+
self._selected_run_partial_detail_keys = set()
|
|
501
|
+
self._snapshot_setpoint_summary_cache = {}
|
|
502
|
+
self._trusted_read_service = None
|
|
503
|
+
self._pending_trusted_read_services = {}
|
|
504
|
+
self._retired_trusted_read_services = set()
|
|
505
|
+
self._retired_service_reap_diagnostics: dict[int, str] = {}
|
|
506
|
+
self._database_access_mode = None
|
|
507
|
+
self._database_fallback_reason = None
|
|
508
|
+
self._test_database_generation_active = False
|
|
509
|
+
self._test_database_generation_worker: TestDatabaseGenerationWorker | None = None
|
|
510
|
+
self._test_database_replacement_state = None
|
|
511
|
+
self._database_view_released_for_generation = False
|
|
512
|
+
self._shutdown_started = False
|
|
513
|
+
self._shutdown_ready = False
|
|
514
|
+
self._shutdown_started_at = None
|
|
515
|
+
self._shutdown_deadline = None
|
|
516
|
+
self._shutdown_hard_deadline = None
|
|
517
|
+
self._shutdown_cleanup_escalated = False
|
|
518
|
+
self._shutdown_escalation_diagnostics: tuple[str, ...] = ()
|
|
519
|
+
self._shutdown_liveness_diagnostics: tuple[str, ...] = ()
|
|
520
|
+
self._shutdown_last_diagnostics: tuple[str, ...] = ()
|
|
521
|
+
self._shutdown_diagnostics: tuple[str, ...] = ()
|
|
522
|
+
self._shutdown_deadline_exhausted = False
|
|
523
|
+
# The process entry point installs and owns the fail-safe. Keeping an
|
|
524
|
+
# ordinary embedded/test MainWindow inert avoids an unexpected hard
|
|
525
|
+
# exit in a host process that owns its own Qt lifecycle.
|
|
526
|
+
self._shutdown_process_fail_safe = None
|
|
527
|
+
self._shutdown_timer = QtCore.QTimer(self)
|
|
528
|
+
self._shutdown_timer.setInterval(25)
|
|
529
|
+
self._shutdown_timer.timeout.connect(self._finish_deferred_shutdown)
|
|
530
|
+
self._retired_service_reaper_timer = QtCore.QTimer(self)
|
|
531
|
+
self._retired_service_reaper_timer.setInterval(
|
|
532
|
+
_RETIRED_SERVICE_REAPER_INTERVAL_MS
|
|
533
|
+
)
|
|
534
|
+
self._retired_service_reaper_timer.timeout.connect(
|
|
535
|
+
self._reap_retired_trusted_read_services
|
|
536
|
+
)
|
|
537
|
+
self._next_plot_x = 0
|
|
538
|
+
self._next_plot_y = 0
|
|
539
|
+
self.localLastFile = None
|
|
540
|
+
self.startup_database_path = startup_database_path
|
|
541
|
+
|
|
542
|
+
# Set GUI color and style from user choice in qplot.configuration.themes
|
|
543
|
+
self.setStyleSheet(self.config.theme.main)
|
|
544
|
+
|
|
545
|
+
#widgets
|
|
546
|
+
self.l = qtw.QVBoxLayout()
|
|
547
|
+
self.l.setContentsMargins(8, 8, 8, 4)
|
|
548
|
+
self.l.setSpacing(6)
|
|
549
|
+
|
|
550
|
+
#Core initialisation functions
|
|
551
|
+
self.initRefresh()
|
|
552
|
+
self.initMenu()
|
|
553
|
+
self.initFile()
|
|
554
|
+
self.initRunDisplay()
|
|
555
|
+
self._trusted_derived_bridge = TrustedDerivedQtBridge(self)
|
|
556
|
+
self.initShortcuts()
|
|
557
|
+
self.startupDatabaseTimer = QtCore.QTimer(self)
|
|
558
|
+
self.startupDatabaseTimer.setSingleShot(True)
|
|
559
|
+
self.startupDatabaseTimer.timeout.connect(self.load_startup_database)
|
|
560
|
+
|
|
561
|
+
#Final Setup
|
|
562
|
+
w = qtw.QFrame()
|
|
563
|
+
w.setLayout(self.l)
|
|
564
|
+
self.setCentralWidget(w)
|
|
565
|
+
|
|
566
|
+
# Fetch window size from config.json, but keep the run list readable.
|
|
567
|
+
configured_width, configured_height = self.config.get("GUI.main_frame_size")
|
|
568
|
+
self.resize(
|
|
569
|
+
max(configured_width, MAIN_WINDOW_READABLE_WIDTH),
|
|
570
|
+
configured_height,
|
|
571
|
+
)
|
|
572
|
+
self.setWindowTitle("qPlot")
|
|
573
|
+
startup_elapsed = perf_counter() - startup_start
|
|
574
|
+
self.show_status(f"Ready - QPlot opened in {startup_elapsed:.2f} s")
|
|
575
|
+
|
|
576
|
+
# Get user's window dimensions to control new window position
|
|
577
|
+
primary_screen = cast(QtGui.QScreen, qtw.QApplication.primaryScreen())
|
|
578
|
+
self.screenrect = primary_screen.availableGeometry()
|
|
579
|
+
self._next_plot_x = self.screenrect.left()
|
|
580
|
+
self._next_plot_y = self.screenrect.top()
|
|
581
|
+
|
|
582
|
+
# Try to bring window to top
|
|
583
|
+
self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowType.WindowStaysOnTopHint)
|
|
584
|
+
self.show()
|
|
585
|
+
self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowType.WindowStaysOnTopHint)
|
|
586
|
+
self.show()
|
|
587
|
+
startup_warning = getattr(self.config, "startup_warning", None)
|
|
588
|
+
if startup_warning:
|
|
589
|
+
self.show_status(startup_warning, 10_000)
|
|
590
|
+
self.startupDatabaseTimer.start(0)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def initMenu(self):
|
|
594
|
+
"""
|
|
595
|
+
Produces the menu bar and all menu's contained at the top of the window
|
|
596
|
+
|
|
597
|
+
"""
|
|
598
|
+
menu = cast(qtw.QMenuBar, self.menuBar())
|
|
599
|
+
# First dropdown menu
|
|
600
|
+
fileMenu = cast(qtw.QMenu, menu.addMenu("&File")) # Not sure why these all have &, but they do
|
|
601
|
+
|
|
602
|
+
# Load database file
|
|
603
|
+
loadAction = create_action("database.load", self)
|
|
604
|
+
loadAction.triggered.connect(self.getfile)
|
|
605
|
+
fileMenu.addAction(loadAction)
|
|
606
|
+
|
|
607
|
+
self.recentDatabaseMenu = cast(qtw.QMenu, fileMenu.addMenu("Load &Recent Database"))
|
|
608
|
+
self.refresh_recent_database_menu()
|
|
609
|
+
|
|
610
|
+
open_folder_action = create_action(
|
|
611
|
+
"database.open_folder",
|
|
612
|
+
self,
|
|
613
|
+
)
|
|
614
|
+
open_folder_action.triggered.connect(self.open_database_location)
|
|
615
|
+
fileMenu.addAction(open_folder_action)
|
|
616
|
+
|
|
617
|
+
self.closeDatabaseAction = create_action("database.close", self)
|
|
618
|
+
self.closeDatabaseAction.triggered.connect(self.close_current_database)
|
|
619
|
+
fileMenu.addAction(self.closeDatabaseAction)
|
|
620
|
+
|
|
621
|
+
# Force update check on database
|
|
622
|
+
refreshAction = create_action("window.refresh", self)
|
|
623
|
+
refreshAction.triggered.connect(self.refreshMain)
|
|
624
|
+
fileMenu.addAction(refreshAction)
|
|
625
|
+
|
|
626
|
+
fileMenu.addSeparator()
|
|
627
|
+
|
|
628
|
+
test_data_menu = cast(qtw.QMenu, fileMenu.addMenu("Generate &Test Data"))
|
|
629
|
+
create_csv_action = create_action("testdata.create_csv", self)
|
|
630
|
+
create_csv_action.triggered.connect(self.create_test_database_csv)
|
|
631
|
+
test_data_menu.addAction(create_csv_action)
|
|
632
|
+
export_collection_action = create_action(
|
|
633
|
+
"testdata.export_collection",
|
|
634
|
+
self,
|
|
635
|
+
)
|
|
636
|
+
export_collection_action.triggered.connect(
|
|
637
|
+
self.export_test_database_csv_collection
|
|
638
|
+
)
|
|
639
|
+
test_data_menu.addAction(export_collection_action)
|
|
640
|
+
self.generateTestDatabaseAction = create_action(
|
|
641
|
+
"testdata.generate_database",
|
|
642
|
+
self,
|
|
643
|
+
)
|
|
644
|
+
self.generateTestDatabaseAction.triggered.connect(
|
|
645
|
+
self.generate_test_database_from_csv
|
|
646
|
+
)
|
|
647
|
+
test_data_menu.addAction(self.generateTestDatabaseAction)
|
|
648
|
+
|
|
649
|
+
fileMenu.addSeparator()
|
|
650
|
+
|
|
651
|
+
self.closeAllPlotsAction = create_action(
|
|
652
|
+
"plots.close_all",
|
|
653
|
+
self,
|
|
654
|
+
status_tip="Close all open plot windows",
|
|
655
|
+
)
|
|
656
|
+
self.closeAllPlotsAction.triggered.connect(self.closeAll)
|
|
657
|
+
fileMenu.addAction(self.closeAllPlotsAction)
|
|
658
|
+
|
|
659
|
+
closeAction = create_action(
|
|
660
|
+
"window.close",
|
|
661
|
+
self,
|
|
662
|
+
status_tip="Close the main qPlot window",
|
|
663
|
+
)
|
|
664
|
+
closeAction.triggered.connect(self.close)
|
|
665
|
+
fileMenu.addAction(closeAction)
|
|
666
|
+
|
|
667
|
+
add_application_quit_action(self, fileMenu, self.quit_application)
|
|
668
|
+
|
|
669
|
+
add_standard_window_controls(self)
|
|
670
|
+
|
|
671
|
+
# Second dropdown menu
|
|
672
|
+
prefMenu = cast(qtw.QMenu, menu.addMenu("&Options"))
|
|
673
|
+
|
|
674
|
+
prefMenu.addAction(
|
|
675
|
+
create_preferences_action(self, self.show_preferences_dialog)
|
|
676
|
+
)
|
|
677
|
+
prefMenu.addSeparator()
|
|
678
|
+
add_restore_defaults_option(self, prefMenu)
|
|
679
|
+
add_help_menu(self)
|
|
680
|
+
|
|
681
|
+
def initFile(self):
|
|
682
|
+
"""
|
|
683
|
+
Display text box for current selected database
|
|
684
|
+
|
|
685
|
+
"""
|
|
686
|
+
self.targetLayout = qtw.QHBoxLayout()
|
|
687
|
+
style = cast(qtw.QStyle, self.style())
|
|
688
|
+
self.targetLayout.setContentsMargins(8, 2, 8, 2)
|
|
689
|
+
self.targetLayout.setSpacing(6)
|
|
690
|
+
|
|
691
|
+
database_label = qtw.QLabel("Database:")
|
|
692
|
+
database_label.setToolTip("Current QCoDeS database")
|
|
693
|
+
self.targetLayout.addWidget(database_label)
|
|
694
|
+
|
|
695
|
+
self.fileTextbox = DatabasePathLineEdit()
|
|
696
|
+
self.fileTextbox.setObjectName("databasePathField")
|
|
697
|
+
self.fileTextbox.setReadOnly(True)
|
|
698
|
+
self.fileTextbox.setPlaceholderText("Drop a QCoDeS .db file here or use File -> Load")
|
|
699
|
+
self.fileTextbox.setToolTip(
|
|
700
|
+
"Current database path. Drop a QCoDeS .db file here to load it."
|
|
701
|
+
)
|
|
702
|
+
self.fileTextbox.databaseDropped.connect(self.load_database_path)
|
|
703
|
+
self.targetLayout.addWidget(self.fileTextbox, 1)
|
|
704
|
+
|
|
705
|
+
self.copyDatabasePathButton = qtw.QToolButton()
|
|
706
|
+
self.copyDatabasePathButton.setObjectName("databaseIconButton")
|
|
707
|
+
self.copyDatabasePathButton.setIcon(
|
|
708
|
+
style.standardIcon(qtw.QStyle.StandardPixmap.SP_FileDialogDetailedView)
|
|
709
|
+
)
|
|
710
|
+
self.copyDatabasePathButton.setToolTip("Copy the full database path")
|
|
711
|
+
self.copyDatabasePathButton.setAccessibleName("Copy database path")
|
|
712
|
+
self.copyDatabasePathButton.setFixedSize(28, 26)
|
|
713
|
+
self.copyDatabasePathButton.clicked.connect(self.copy_database_path)
|
|
714
|
+
self.targetLayout.addWidget(self.copyDatabasePathButton)
|
|
715
|
+
|
|
716
|
+
self.databaseInfoButton = qtw.QToolButton()
|
|
717
|
+
self.databaseInfoButton.setObjectName("databaseIconButton")
|
|
718
|
+
self.databaseInfoButton.setIcon(
|
|
719
|
+
style.standardIcon(qtw.QStyle.StandardPixmap.SP_MessageBoxInformation)
|
|
720
|
+
)
|
|
721
|
+
self.databaseInfoButton.setToolTip("Show database information")
|
|
722
|
+
self.databaseInfoButton.setAccessibleName("Show database information")
|
|
723
|
+
self.databaseInfoButton.setFixedSize(28, 26)
|
|
724
|
+
self.databaseInfoButton.clicked.connect(self.show_database_info)
|
|
725
|
+
self.targetLayout.addWidget(self.databaseInfoButton)
|
|
726
|
+
|
|
727
|
+
self.loadDatabaseButton = qtw.QToolButton()
|
|
728
|
+
self.loadDatabaseButton.setObjectName("databaseIconButton")
|
|
729
|
+
self.loadDatabaseButton.setIcon(
|
|
730
|
+
style.standardIcon(qtw.QStyle.StandardPixmap.SP_DialogOpenButton)
|
|
731
|
+
)
|
|
732
|
+
self.loadDatabaseButton.setToolTip("Load a QCoDeS .db database (Ctrl+L)")
|
|
733
|
+
self.loadDatabaseButton.setAccessibleName("Load database")
|
|
734
|
+
self.loadDatabaseButton.setFixedSize(28, 26)
|
|
735
|
+
self.loadDatabaseButton.clicked.connect(self.getfile)
|
|
736
|
+
self.targetLayout.addWidget(self.loadDatabaseButton)
|
|
737
|
+
|
|
738
|
+
self.openDatabaseFolderButton = qtw.QToolButton()
|
|
739
|
+
self.openDatabaseFolderButton.setObjectName("databaseIconButton")
|
|
740
|
+
self.openDatabaseFolderButton.setIcon(
|
|
741
|
+
style.standardIcon(qtw.QStyle.StandardPixmap.SP_DirOpenIcon)
|
|
742
|
+
)
|
|
743
|
+
self.openDatabaseFolderButton.setToolTip(
|
|
744
|
+
"Open the folder containing the current database (Ctrl+Shift+D)"
|
|
745
|
+
)
|
|
746
|
+
self.openDatabaseFolderButton.setAccessibleName("Open database folder")
|
|
747
|
+
self.openDatabaseFolderButton.setFixedSize(28, 26)
|
|
748
|
+
self.openDatabaseFolderButton.clicked.connect(self.open_database_location)
|
|
749
|
+
self.targetLayout.addWidget(self.openDatabaseFolderButton)
|
|
750
|
+
|
|
751
|
+
self.targetLayout.addStretch()
|
|
752
|
+
self.targetLayout.addSpacing(18)
|
|
753
|
+
self.targetLayout.addWidget(self.closeAllPlotsButton)
|
|
754
|
+
|
|
755
|
+
self.databaseLoadFrame = qtw.QFrame()
|
|
756
|
+
self.databaseLoadFrame.setObjectName("databaseLoadFrame")
|
|
757
|
+
database_load_layout = qtw.QHBoxLayout(self.databaseLoadFrame)
|
|
758
|
+
database_load_layout.setContentsMargins(8, 0, 8, 2)
|
|
759
|
+
database_load_layout.setSpacing(6)
|
|
760
|
+
|
|
761
|
+
self.databaseLoadProgress = qtw.QProgressBar()
|
|
762
|
+
self.databaseLoadProgress.setObjectName("databaseLoadProgress")
|
|
763
|
+
self.databaseLoadProgress.setRange(0, 0)
|
|
764
|
+
self.databaseLoadProgress.setTextVisible(False)
|
|
765
|
+
self.databaseLoadProgress.setFixedWidth(120)
|
|
766
|
+
self.databaseLoadProgress.setMaximumHeight(16)
|
|
767
|
+
self.databaseLoadProgress.setAccessibleName("Database load progress")
|
|
768
|
+
database_load_layout.addWidget(self.databaseLoadProgress)
|
|
769
|
+
|
|
770
|
+
self.databaseLoadLabel = qtw.QLabel("")
|
|
771
|
+
self.databaseLoadLabel.setObjectName("databaseLoadLabel")
|
|
772
|
+
self.databaseLoadLabel.setSizePolicy(
|
|
773
|
+
qtw.QSizePolicy.Policy.Expanding,
|
|
774
|
+
qtw.QSizePolicy.Policy.Preferred,
|
|
775
|
+
)
|
|
776
|
+
database_load_layout.addWidget(self.databaseLoadLabel, 1)
|
|
777
|
+
|
|
778
|
+
self.databaseLoadCancelButton = qtw.QToolButton()
|
|
779
|
+
self.databaseLoadCancelButton.setObjectName("databaseIconButton")
|
|
780
|
+
self.databaseLoadCancelButton.setIcon(
|
|
781
|
+
style.standardIcon(qtw.QStyle.StandardPixmap.SP_DialogCancelButton)
|
|
782
|
+
)
|
|
783
|
+
self.databaseLoadCancelButton.setText("Cancel")
|
|
784
|
+
self.databaseLoadCancelButton.setToolButtonStyle(
|
|
785
|
+
QtCore.Qt.ToolButtonStyle.ToolButtonTextBesideIcon
|
|
786
|
+
)
|
|
787
|
+
self.databaseLoadCancelButton.setToolTip("Cancel the current database load")
|
|
788
|
+
self.databaseLoadCancelButton.setAccessibleName("Cancel database load")
|
|
789
|
+
self.databaseLoadCancelButton.setFixedSize(78, 24)
|
|
790
|
+
self.databaseLoadCancelButton.clicked.connect(self.cancel_database_load)
|
|
791
|
+
database_load_layout.addWidget(self.databaseLoadCancelButton)
|
|
792
|
+
self.databaseLoadFrame.setVisible(False)
|
|
793
|
+
|
|
794
|
+
###############################################################################
|
|
795
|
+
#Open/Close events
|
|
796
|
+
|
|
797
|
+
@QtCore.pyqtSlot()
|
|
798
|
+
def close_current_database(self):
|
|
799
|
+
"""
|
|
800
|
+
Closes plot windows before releasing the current database.
|
|
801
|
+
|
|
802
|
+
"""
|
|
803
|
+
if not self.fileTextbox.text():
|
|
804
|
+
self.show_status("No database is loaded.", 3000)
|
|
805
|
+
return
|
|
806
|
+
|
|
807
|
+
if not self.close_plot_windows(confirm=True, status=False):
|
|
808
|
+
self.show_status("Database close cancelled.", 3000)
|
|
809
|
+
return
|
|
810
|
+
|
|
811
|
+
self.close_database()
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
@QtCore.pyqtSlot()
|
|
815
|
+
def quit_application(self):
|
|
816
|
+
"""
|
|
817
|
+
Routes the Quit command through the main window's shutdown handler.
|
|
818
|
+
|
|
819
|
+
"""
|
|
820
|
+
self.close()
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
@QtCore.pyqtSlot(bool)
|
|
824
|
+
def closeEvent(self, event):
|
|
825
|
+
"""
|
|
826
|
+
Event handler for closing Main Window.
|
|
827
|
+
|
|
828
|
+
Also handles some closing admin
|
|
829
|
+
|
|
830
|
+
"""
|
|
831
|
+
if getattr(self, "_shutdown_ready", False):
|
|
832
|
+
event.accept()
|
|
833
|
+
return
|
|
834
|
+
if getattr(self, "_shutdown_started", False):
|
|
835
|
+
event.ignore()
|
|
836
|
+
return
|
|
837
|
+
|
|
838
|
+
# Confirm exit
|
|
839
|
+
if self.config.get(CONFIRM_QUIT_KEY):
|
|
840
|
+
reply = ask_confirmation_with_dont_ask_again(
|
|
841
|
+
self,
|
|
842
|
+
"Confirm Exit",
|
|
843
|
+
"Are you sure you want to exit?",
|
|
844
|
+
CONFIRM_QUIT_KEY,
|
|
845
|
+
)
|
|
846
|
+
if reply != qtw.QMessageBox.StandardButton.Yes:
|
|
847
|
+
event.ignore()
|
|
848
|
+
return
|
|
849
|
+
|
|
850
|
+
# One monotonic total bound covers cancellation, cleanup escalation,
|
|
851
|
+
# diagnostic persistence, and hard process termination. The final
|
|
852
|
+
# grace is inside that bound, never appended to it.
|
|
853
|
+
shutdown_started_at = monotonic()
|
|
854
|
+
total_timeout = max(0.0, _APPLICATION_SHUTDOWN_TIMEOUT_SECONDS)
|
|
855
|
+
diagnostic_grace = min(
|
|
856
|
+
_APPLICATION_SHUTDOWN_DIAGNOSTIC_GRACE_SECONDS,
|
|
857
|
+
total_timeout * 0.25,
|
|
858
|
+
)
|
|
859
|
+
hard_deadline = shutdown_started_at + total_timeout
|
|
860
|
+
self._shutdown_started_at = shutdown_started_at
|
|
861
|
+
self._shutdown_deadline = hard_deadline - diagnostic_grace
|
|
862
|
+
self._shutdown_hard_deadline = hard_deadline
|
|
863
|
+
self._shutdown_cleanup_escalated = False
|
|
864
|
+
self._shutdown_escalation_diagnostics = ()
|
|
865
|
+
self._shutdown_liveness_diagnostics = ()
|
|
866
|
+
self._shutdown_last_diagnostics = ()
|
|
867
|
+
self._shutdown_diagnostics = ()
|
|
868
|
+
self._shutdown_deadline_exhausted = False
|
|
869
|
+
process_fail_safe = getattr(self, "_shutdown_process_fail_safe", None)
|
|
870
|
+
if process_fail_safe is not None:
|
|
871
|
+
launch_diagnostic = process_fail_safe.arm(
|
|
872
|
+
started_at=shutdown_started_at,
|
|
873
|
+
diagnostic_deadline=self._shutdown_deadline,
|
|
874
|
+
hard_deadline=hard_deadline,
|
|
875
|
+
)
|
|
876
|
+
if launch_diagnostic:
|
|
877
|
+
self._shutdown_escalation_diagnostics = (launch_diagnostic,)
|
|
878
|
+
MainWindow._publish_shutdown_diagnostics(self)
|
|
879
|
+
|
|
880
|
+
# Mark the refresh lifecycle as shut down before cancelling workers.
|
|
881
|
+
# This also invalidates a QTimer timeout that was already queued.
|
|
882
|
+
self._automatic_refresh_shutdown = True
|
|
883
|
+
stop_refresh_timer = getattr(self, "_stop_automatic_refresh_timer", None)
|
|
884
|
+
if callable(stop_refresh_timer):
|
|
885
|
+
stop_refresh_timer()
|
|
886
|
+
|
|
887
|
+
derived_bridge = getattr(self, "_trusted_derived_bridge", None)
|
|
888
|
+
if derived_bridge is not None:
|
|
889
|
+
derived_bridge.shutdown()
|
|
890
|
+
preview = getattr(getattr(self, "infoBox", None), "preview", None)
|
|
891
|
+
if preview is not None:
|
|
892
|
+
preview.shutdown()
|
|
893
|
+
self.startupDatabaseTimer.stop()
|
|
894
|
+
worker = getattr(self, "_database_load_worker", None)
|
|
895
|
+
if worker is not None:
|
|
896
|
+
worker.cancel()
|
|
897
|
+
detail_worker = getattr(self, "_database_detail_worker", None)
|
|
898
|
+
if detail_worker is not None:
|
|
899
|
+
detail_worker.cancel()
|
|
900
|
+
expensive_detail_worker = getattr(
|
|
901
|
+
self,
|
|
902
|
+
"_database_expensive_detail_worker",
|
|
903
|
+
None,
|
|
904
|
+
)
|
|
905
|
+
if expensive_detail_worker is not None:
|
|
906
|
+
expensive_detail_worker.cancel()
|
|
907
|
+
refresh_worker = getattr(self, "_database_refresh_worker", None)
|
|
908
|
+
if refresh_worker is not None:
|
|
909
|
+
refresh_worker.cancel()
|
|
910
|
+
selected_run_worker = getattr(self, "_database_selected_run_worker", None)
|
|
911
|
+
if selected_run_worker is not None:
|
|
912
|
+
selected_run_worker.cancel()
|
|
913
|
+
DatabaseActionsMixin._cancel_database_info(self)
|
|
914
|
+
generation_worker = getattr(self, "_test_database_generation_worker", None)
|
|
915
|
+
if generation_worker is not None:
|
|
916
|
+
generation_worker.cancel()
|
|
917
|
+
MainWindow._cancel_plot_work(self)
|
|
918
|
+
self._database_load_generation += 1
|
|
919
|
+
self._database_load_active = False
|
|
920
|
+
self._database_load_state = None
|
|
921
|
+
self._database_load_worker = None
|
|
922
|
+
self._database_detail_generation = (
|
|
923
|
+
getattr(self, "_database_detail_generation", 0) + 1
|
|
924
|
+
)
|
|
925
|
+
self._database_detail_active = False
|
|
926
|
+
self._database_detail_worker = None
|
|
927
|
+
self._database_detail_instance = None
|
|
928
|
+
self._database_expensive_detail_generation = (
|
|
929
|
+
getattr(self, "_database_expensive_detail_generation", 0) + 1
|
|
930
|
+
)
|
|
931
|
+
self._database_expensive_detail_active = False
|
|
932
|
+
self._database_expensive_detail_worker = None
|
|
933
|
+
self._database_expensive_detail_instance = None
|
|
934
|
+
self._database_refresh_generation = (
|
|
935
|
+
getattr(self, "_database_refresh_generation", 0) + 1
|
|
936
|
+
)
|
|
937
|
+
self._database_refresh_active = False
|
|
938
|
+
self._database_refresh_pending = False
|
|
939
|
+
self._database_refresh_worker = None
|
|
940
|
+
self._database_refresh_identity = None
|
|
941
|
+
self._database_refresh_instance = None
|
|
942
|
+
self._database_selected_run_generation = (
|
|
943
|
+
getattr(self, "_database_selected_run_generation", 0) + 1
|
|
944
|
+
)
|
|
945
|
+
self._database_selected_run_worker = None
|
|
946
|
+
self._database_selected_run_instance = None
|
|
947
|
+
self._database_selected_run_mode = None
|
|
948
|
+
self._restoring_selected_run_publication = False
|
|
949
|
+
self._selected_run_restore_pending = False
|
|
950
|
+
self._test_database_generation_active = False
|
|
951
|
+
self._test_database_generation_worker = None
|
|
952
|
+
self._test_database_replacement_state = None
|
|
953
|
+
self._database_view_released_for_generation = False
|
|
954
|
+
stop_refresh_timer = getattr(self, "_stop_automatic_refresh_timer", None)
|
|
955
|
+
if callable(stop_refresh_timer):
|
|
956
|
+
stop_refresh_timer()
|
|
957
|
+
else:
|
|
958
|
+
self.monitor.stop()
|
|
959
|
+
self.close_plot_windows(confirm=False, status=False)
|
|
960
|
+
self.close_database(status=False)
|
|
961
|
+
|
|
962
|
+
if MainWindow._shutdown_background_work_active(self):
|
|
963
|
+
self._shutdown_started = True
|
|
964
|
+
event.ignore()
|
|
965
|
+
hide = getattr(self, "hide", None)
|
|
966
|
+
if callable(hide):
|
|
967
|
+
hide()
|
|
968
|
+
shutdown_timer = getattr(self, "_shutdown_timer", None)
|
|
969
|
+
if shutdown_timer is not None:
|
|
970
|
+
shutdown_timer.start()
|
|
971
|
+
else:
|
|
972
|
+
QtCore.QTimer.singleShot(25, self._finish_deferred_shutdown)
|
|
973
|
+
return
|
|
974
|
+
|
|
975
|
+
self._shutdown_ready = True
|
|
976
|
+
event.accept()
|
|
977
|
+
qtw.QApplication.closeAllWindows()
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _cancel_plot_work(self):
|
|
981
|
+
"""Cancel running plot loads and remove work that has not started."""
|
|
982
|
+
|
|
983
|
+
workers = set(getattr(self, "_plot_workers", ()))
|
|
984
|
+
for window in list(getattr(self, "windows", ())):
|
|
985
|
+
worker = getattr(window, "worker", None)
|
|
986
|
+
if worker is not None:
|
|
987
|
+
workers.add(worker)
|
|
988
|
+
|
|
989
|
+
for worker in workers:
|
|
990
|
+
cancel = getattr(worker, "cancel", None)
|
|
991
|
+
if callable(cancel):
|
|
992
|
+
cancel()
|
|
993
|
+
|
|
994
|
+
pool = getattr(self, "threadPool", None)
|
|
995
|
+
clear = getattr(pool, "clear", None)
|
|
996
|
+
if callable(clear):
|
|
997
|
+
clear()
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def _publish_shutdown_diagnostics(self):
|
|
1001
|
+
"""Merge durable escalation errors with the newest liveness scan."""
|
|
1002
|
+
|
|
1003
|
+
diagnostics = tuple(
|
|
1004
|
+
(
|
|
1005
|
+
*getattr(self, "_shutdown_escalation_diagnostics", ()),
|
|
1006
|
+
*getattr(self, "_shutdown_liveness_diagnostics", ()),
|
|
1007
|
+
)
|
|
1008
|
+
)
|
|
1009
|
+
self._shutdown_last_diagnostics = diagnostics
|
|
1010
|
+
process_fail_safe = getattr(self, "_shutdown_process_fail_safe", None)
|
|
1011
|
+
if process_fail_safe is not None:
|
|
1012
|
+
process_fail_safe.update_diagnostics(diagnostics)
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def _shutdown_background_work_active(self):
|
|
1016
|
+
"""
|
|
1017
|
+
Reports whether a qPlot worker still needs the Qt event loop.
|
|
1018
|
+
|
|
1019
|
+
"""
|
|
1020
|
+
diagnostics = []
|
|
1021
|
+
active = False
|
|
1022
|
+
pool_names = (
|
|
1023
|
+
"threadPool",
|
|
1024
|
+
"databaseLoadThreadPool",
|
|
1025
|
+
"databaseDetailThreadPool",
|
|
1026
|
+
"databaseExpensiveDetailThreadPool",
|
|
1027
|
+
"databaseRefreshThreadPool",
|
|
1028
|
+
"databaseSelectedRunThreadPool",
|
|
1029
|
+
"testDatabaseGenerationThreadPool",
|
|
1030
|
+
)
|
|
1031
|
+
for pool_name in pool_names:
|
|
1032
|
+
pool = getattr(self, pool_name, None)
|
|
1033
|
+
if pool is None:
|
|
1034
|
+
continue
|
|
1035
|
+
try:
|
|
1036
|
+
count = pool.activeThreadCount()
|
|
1037
|
+
except BaseException as error:
|
|
1038
|
+
active = True
|
|
1039
|
+
diagnostics.append(
|
|
1040
|
+
f"pool {pool_name}: liveness raised "
|
|
1041
|
+
f"{type(error).__name__}: {error}"
|
|
1042
|
+
)
|
|
1043
|
+
continue
|
|
1044
|
+
if count > 0:
|
|
1045
|
+
active = True
|
|
1046
|
+
diagnostics.append(f"pool {pool_name}: active_threads={count}")
|
|
1047
|
+
|
|
1048
|
+
reap_services = getattr(self, "_reap_retired_trusted_read_services", None)
|
|
1049
|
+
if callable(reap_services):
|
|
1050
|
+
try:
|
|
1051
|
+
reap_services()
|
|
1052
|
+
except BaseException as error:
|
|
1053
|
+
active = True
|
|
1054
|
+
diagnostics.append(
|
|
1055
|
+
"retired-service reaper raised "
|
|
1056
|
+
f"{type(error).__name__}: {error}"
|
|
1057
|
+
)
|
|
1058
|
+
diagnostics.extend(
|
|
1059
|
+
getattr(self, "_retired_service_reap_diagnostics", {}).values()
|
|
1060
|
+
)
|
|
1061
|
+
services = set(getattr(self, "_retired_trusted_read_services", ()))
|
|
1062
|
+
active_service = getattr(self, "_trusted_read_service", None)
|
|
1063
|
+
if active_service is not None:
|
|
1064
|
+
services.add(active_service)
|
|
1065
|
+
services.update(
|
|
1066
|
+
getattr(self, "_pending_trusted_read_services", {}).values()
|
|
1067
|
+
)
|
|
1068
|
+
for service in services:
|
|
1069
|
+
service_label = (
|
|
1070
|
+
f"{type(service).__module__}.{type(service).__qualname__}"
|
|
1071
|
+
f"@{id(service):x}"
|
|
1072
|
+
)
|
|
1073
|
+
service_errors = []
|
|
1074
|
+
for error_name in ("fatal_error", "close_error"):
|
|
1075
|
+
try:
|
|
1076
|
+
service_error = getattr(service, error_name, None)
|
|
1077
|
+
except BaseException as error:
|
|
1078
|
+
active = True
|
|
1079
|
+
service_errors.append(
|
|
1080
|
+
f"{error_name} probe raised {type(error).__name__}: {error}"
|
|
1081
|
+
)
|
|
1082
|
+
continue
|
|
1083
|
+
if service_error is not None:
|
|
1084
|
+
service_errors.append(
|
|
1085
|
+
f"{error_name}={type(service_error).__name__}: {service_error}"
|
|
1086
|
+
)
|
|
1087
|
+
try:
|
|
1088
|
+
liveness = service.liveness()
|
|
1089
|
+
except BaseException as error:
|
|
1090
|
+
active = True
|
|
1091
|
+
diagnostics.append(
|
|
1092
|
+
f"service {service_label}: liveness raised "
|
|
1093
|
+
f"{type(error).__name__}: {error}"
|
|
1094
|
+
+ ("; " + "; ".join(service_errors) if service_errors else "")
|
|
1095
|
+
)
|
|
1096
|
+
continue
|
|
1097
|
+
field_names = (
|
|
1098
|
+
"dispatcher_alive",
|
|
1099
|
+
"control_alive",
|
|
1100
|
+
"helper_alive",
|
|
1101
|
+
"helper_pid",
|
|
1102
|
+
"receiver_alive",
|
|
1103
|
+
"open_supervisor_endpoints",
|
|
1104
|
+
"unreaped_incarnations",
|
|
1105
|
+
"resource_cleanup_pending",
|
|
1106
|
+
"outstanding_requests",
|
|
1107
|
+
"closing",
|
|
1108
|
+
"closed",
|
|
1109
|
+
)
|
|
1110
|
+
values = {
|
|
1111
|
+
field_name: getattr(liveness, field_name, None)
|
|
1112
|
+
for field_name in field_names
|
|
1113
|
+
}
|
|
1114
|
+
service_active = bool(
|
|
1115
|
+
values["dispatcher_alive"]
|
|
1116
|
+
or values["control_alive"]
|
|
1117
|
+
or values["helper_alive"]
|
|
1118
|
+
or values["receiver_alive"]
|
|
1119
|
+
or values["open_supervisor_endpoints"]
|
|
1120
|
+
or values["unreaped_incarnations"]
|
|
1121
|
+
or values["resource_cleanup_pending"]
|
|
1122
|
+
or values["outstanding_requests"]
|
|
1123
|
+
or not values["closed"]
|
|
1124
|
+
)
|
|
1125
|
+
if service_active:
|
|
1126
|
+
active = True
|
|
1127
|
+
diagnostics.append(
|
|
1128
|
+
f"service {service_label}: "
|
|
1129
|
+
+ ", ".join(
|
|
1130
|
+
f"{field_name}={values[field_name]!r}"
|
|
1131
|
+
for field_name in field_names
|
|
1132
|
+
)
|
|
1133
|
+
+ ("; " + "; ".join(service_errors) if service_errors else "")
|
|
1134
|
+
)
|
|
1135
|
+
|
|
1136
|
+
preview = getattr(getattr(self, "infoBox", None), "preview", None)
|
|
1137
|
+
preview_workers = getattr(preview, "_workers", {})
|
|
1138
|
+
if preview_workers:
|
|
1139
|
+
active = True
|
|
1140
|
+
diagnostics.append(f"preview_workers={len(preview_workers)}")
|
|
1141
|
+
derived_bridge = getattr(self, "_trusted_derived_bridge", None)
|
|
1142
|
+
if derived_bridge is not None:
|
|
1143
|
+
try:
|
|
1144
|
+
if derived_bridge.background_active():
|
|
1145
|
+
active = True
|
|
1146
|
+
diagnostic = derived_bridge.shutdown_diagnostic()
|
|
1147
|
+
if diagnostic:
|
|
1148
|
+
diagnostics.append(diagnostic)
|
|
1149
|
+
except BaseException as error:
|
|
1150
|
+
active = True
|
|
1151
|
+
diagnostics.append(
|
|
1152
|
+
"trusted derived bridge liveness raised "
|
|
1153
|
+
f"{type(error).__name__}: {error}"
|
|
1154
|
+
)
|
|
1155
|
+
self._shutdown_liveness_diagnostics = tuple(diagnostics)
|
|
1156
|
+
MainWindow._publish_shutdown_diagnostics(self)
|
|
1157
|
+
return active
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def _escalate_shutdown_cleanup(self):
|
|
1161
|
+
"""Repeat cancellation and zero-wait cleanup once during shutdown."""
|
|
1162
|
+
|
|
1163
|
+
if getattr(self, "_shutdown_cleanup_escalated", False):
|
|
1164
|
+
return
|
|
1165
|
+
self._shutdown_cleanup_escalated = True
|
|
1166
|
+
escalation_diagnostics = []
|
|
1167
|
+
|
|
1168
|
+
try:
|
|
1169
|
+
MainWindow._cancel_plot_work(self)
|
|
1170
|
+
except BaseException as error:
|
|
1171
|
+
escalation_diagnostics.append(
|
|
1172
|
+
f"plot cancellation raised {type(error).__name__}: {error}"
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
for pool_name in (
|
|
1176
|
+
"threadPool",
|
|
1177
|
+
"databaseLoadThreadPool",
|
|
1178
|
+
"databaseDetailThreadPool",
|
|
1179
|
+
"databaseExpensiveDetailThreadPool",
|
|
1180
|
+
"databaseRefreshThreadPool",
|
|
1181
|
+
"databaseSelectedRunThreadPool",
|
|
1182
|
+
"testDatabaseGenerationThreadPool",
|
|
1183
|
+
):
|
|
1184
|
+
pool = getattr(self, pool_name, None)
|
|
1185
|
+
clear = getattr(pool, "clear", None)
|
|
1186
|
+
if not callable(clear):
|
|
1187
|
+
continue
|
|
1188
|
+
try:
|
|
1189
|
+
clear()
|
|
1190
|
+
except BaseException as error:
|
|
1191
|
+
escalation_diagnostics.append(
|
|
1192
|
+
f"pool {pool_name} clear raised {type(error).__name__}: {error}"
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
preview = getattr(getattr(self, "infoBox", None), "preview", None)
|
|
1196
|
+
shutdown = getattr(preview, "shutdown", None)
|
|
1197
|
+
if callable(shutdown):
|
|
1198
|
+
try:
|
|
1199
|
+
shutdown()
|
|
1200
|
+
except BaseException as error:
|
|
1201
|
+
escalation_diagnostics.append(
|
|
1202
|
+
f"preview shutdown raised {type(error).__name__}: {error}"
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1205
|
+
derived_bridge = getattr(self, "_trusted_derived_bridge", None)
|
|
1206
|
+
if derived_bridge is not None:
|
|
1207
|
+
try:
|
|
1208
|
+
derived_bridge.escalate_cleanup()
|
|
1209
|
+
except BaseException as error:
|
|
1210
|
+
escalation_diagnostics.append(
|
|
1211
|
+
"trusted derived cleanup raised "
|
|
1212
|
+
f"{type(error).__name__}: {error}"
|
|
1213
|
+
)
|
|
1214
|
+
|
|
1215
|
+
services = set(getattr(self, "_retired_trusted_read_services", ()))
|
|
1216
|
+
active_service = getattr(self, "_trusted_read_service", None)
|
|
1217
|
+
if active_service is not None:
|
|
1218
|
+
services.add(active_service)
|
|
1219
|
+
services.update(
|
|
1220
|
+
getattr(self, "_pending_trusted_read_services", {}).values()
|
|
1221
|
+
)
|
|
1222
|
+
for service in services:
|
|
1223
|
+
escalate = getattr(service, "escalate_cleanup_async", None)
|
|
1224
|
+
if not callable(escalate):
|
|
1225
|
+
escalate = getattr(service, "close_async", None)
|
|
1226
|
+
if not callable(escalate):
|
|
1227
|
+
continue
|
|
1228
|
+
try:
|
|
1229
|
+
escalate()
|
|
1230
|
+
except BaseException as error:
|
|
1231
|
+
escalation_diagnostics.append(
|
|
1232
|
+
"service cleanup escalation raised "
|
|
1233
|
+
f"{type(error).__name__}: {error}"
|
|
1234
|
+
)
|
|
1235
|
+
|
|
1236
|
+
reap_services = getattr(self, "_reap_retired_trusted_read_services", None)
|
|
1237
|
+
if callable(reap_services):
|
|
1238
|
+
try:
|
|
1239
|
+
reap_services()
|
|
1240
|
+
except BaseException as error:
|
|
1241
|
+
escalation_diagnostics.append(
|
|
1242
|
+
"retired-service escalation reap raised "
|
|
1243
|
+
f"{type(error).__name__}: {error}"
|
|
1244
|
+
)
|
|
1245
|
+
if escalation_diagnostics:
|
|
1246
|
+
self._shutdown_escalation_diagnostics = tuple(
|
|
1247
|
+
(
|
|
1248
|
+
*getattr(self, "_shutdown_escalation_diagnostics", ()),
|
|
1249
|
+
*escalation_diagnostics,
|
|
1250
|
+
)
|
|
1251
|
+
)
|
|
1252
|
+
MainWindow._publish_shutdown_diagnostics(self)
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
def _complete_deferred_shutdown(self, *, deadline_exhausted=False):
|
|
1256
|
+
"""Stop shutdown polling and make the next close event unconditional."""
|
|
1257
|
+
|
|
1258
|
+
shutdown_timer = getattr(self, "_shutdown_timer", None)
|
|
1259
|
+
if shutdown_timer is not None:
|
|
1260
|
+
shutdown_timer.stop()
|
|
1261
|
+
reaper_timer = getattr(self, "_retired_service_reaper_timer", None)
|
|
1262
|
+
if reaper_timer is not None:
|
|
1263
|
+
reaper_timer.stop()
|
|
1264
|
+
|
|
1265
|
+
if deadline_exhausted:
|
|
1266
|
+
diagnostics = tuple(getattr(self, "_shutdown_last_diagnostics", ()))
|
|
1267
|
+
if not diagnostics:
|
|
1268
|
+
diagnostics = ("background work remained active without diagnostics",)
|
|
1269
|
+
self._shutdown_diagnostics = diagnostics
|
|
1270
|
+
process_fail_safe = getattr(self, "_shutdown_process_fail_safe", None)
|
|
1271
|
+
if process_fail_safe is not None:
|
|
1272
|
+
process_fail_safe.update_diagnostics(diagnostics)
|
|
1273
|
+
process_fail_safe.persist_async()
|
|
1274
|
+
else:
|
|
1275
|
+
started_at = getattr(self, "_shutdown_started_at", None)
|
|
1276
|
+
if started_at is None:
|
|
1277
|
+
started_at = monotonic()
|
|
1278
|
+
_persist_shutdown_diagnostics(
|
|
1279
|
+
started_at=started_at,
|
|
1280
|
+
total_timeout=_APPLICATION_SHUTDOWN_TIMEOUT_SECONDS,
|
|
1281
|
+
diagnostics=diagnostics,
|
|
1282
|
+
)
|
|
1283
|
+
self._shutdown_deadline_exhausted = bool(deadline_exhausted)
|
|
1284
|
+
|
|
1285
|
+
self._shutdown_started = False
|
|
1286
|
+
self._shutdown_ready = True
|
|
1287
|
+
qtw.QApplication.closeAllWindows()
|
|
1288
|
+
application = qtw.QApplication.instance()
|
|
1289
|
+
if application is not None and isinstance(self, qtw.QWidget):
|
|
1290
|
+
application.quit()
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
@QtCore.pyqtSlot()
|
|
1294
|
+
def _finish_deferred_shutdown(self):
|
|
1295
|
+
"""
|
|
1296
|
+
Finishes Quit after cancelled background workers have returned.
|
|
1297
|
+
|
|
1298
|
+
"""
|
|
1299
|
+
if not getattr(self, "_shutdown_started", False):
|
|
1300
|
+
return
|
|
1301
|
+
active = MainWindow._shutdown_background_work_active(self)
|
|
1302
|
+
if active and not getattr(self, "_shutdown_cleanup_escalated", False):
|
|
1303
|
+
MainWindow._escalate_shutdown_cleanup(self)
|
|
1304
|
+
active = MainWindow._shutdown_background_work_active(self)
|
|
1305
|
+
|
|
1306
|
+
deadline = getattr(self, "_shutdown_deadline", None)
|
|
1307
|
+
if deadline is None:
|
|
1308
|
+
deadline = monotonic() + _APPLICATION_SHUTDOWN_TIMEOUT_SECONDS
|
|
1309
|
+
self._shutdown_deadline = deadline
|
|
1310
|
+
if active and monotonic() < deadline:
|
|
1311
|
+
return
|
|
1312
|
+
|
|
1313
|
+
MainWindow._complete_deferred_shutdown(
|
|
1314
|
+
self,
|
|
1315
|
+
deadline_exhausted=active,
|
|
1316
|
+
)
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
@QtCore.pyqtSlot()
|
|
1320
|
+
def closeAll(self):
|
|
1321
|
+
"""
|
|
1322
|
+
Event handler for close all menu button.
|
|
1323
|
+
Closes all windows other than the main window.
|
|
1324
|
+
|
|
1325
|
+
"""
|
|
1326
|
+
self.close_plot_windows(confirm=True, status=True)
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def close_plot_windows(self, confirm=True, status=True):
|
|
1330
|
+
"""
|
|
1331
|
+
Closes all plot windows, optionally asking for confirmation.
|
|
1332
|
+
|
|
1333
|
+
"""
|
|
1334
|
+
plot_windows = self.windows.copy()
|
|
1335
|
+
if not plot_windows:
|
|
1336
|
+
if status:
|
|
1337
|
+
self.show_status("No plot windows to close.", 3000)
|
|
1338
|
+
return True
|
|
1339
|
+
|
|
1340
|
+
if confirm and close_all_warning_enabled(self.config):
|
|
1341
|
+
count = len(plot_windows)
|
|
1342
|
+
noun = "window" if count == 1 else "windows"
|
|
1343
|
+
reply = ask_confirmation_with_dont_ask_again(
|
|
1344
|
+
self,
|
|
1345
|
+
"Close All Plot Windows",
|
|
1346
|
+
f"Close {count} plot {noun}?",
|
|
1347
|
+
CONFIRM_CLOSE_ALL_KEY,
|
|
1348
|
+
qtw.QMessageBox.StandardButton.No,
|
|
1349
|
+
)
|
|
1350
|
+
if reply != qtw.QMessageBox.StandardButton.Yes:
|
|
1351
|
+
if status:
|
|
1352
|
+
self.show_status("Close all plot windows cancelled.", 3000)
|
|
1353
|
+
return False
|
|
1354
|
+
|
|
1355
|
+
if status:
|
|
1356
|
+
self.show_status("Closing plot windows...", 3000)
|
|
1357
|
+
for win in plot_windows:
|
|
1358
|
+
win.close()
|
|
1359
|
+
return True
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
def change_theme(self, theme, action):
|
|
1363
|
+
"""
|
|
1364
|
+
Event handler for changing style/theme.
|
|
1365
|
+
Updates Main Window theme and all other Plot windows.
|
|
1366
|
+
|
|
1367
|
+
Parameters
|
|
1368
|
+
----------
|
|
1369
|
+
theme : str
|
|
1370
|
+
Name of the theme to change to.
|
|
1371
|
+
action : PyQt6.QtWidgets.QAction
|
|
1372
|
+
Button which sent the signal for the action.
|
|
1373
|
+
|
|
1374
|
+
"""
|
|
1375
|
+
if self.config.get("user_preference.theme") == theme: #already selected
|
|
1376
|
+
set_widget_value_without_signals(action, action.setChecked, True)
|
|
1377
|
+
self.show_status(f"{theme.title()} theme already selected.", 3000)
|
|
1378
|
+
return True
|
|
1379
|
+
|
|
1380
|
+
def sync_theme_actions():
|
|
1381
|
+
current_theme = self.config.get("user_preference.theme")
|
|
1382
|
+
actions = list(getattr(self, "themes", []))
|
|
1383
|
+
if action not in actions:
|
|
1384
|
+
actions.append(action)
|
|
1385
|
+
blocked_states = [
|
|
1386
|
+
theme_action.blockSignals(True)
|
|
1387
|
+
for theme_action in actions
|
|
1388
|
+
]
|
|
1389
|
+
try:
|
|
1390
|
+
for theme_action in actions:
|
|
1391
|
+
action_theme = theme_action.text().replace("&", "").lower()
|
|
1392
|
+
theme_action.setChecked(action_theme == current_theme)
|
|
1393
|
+
finally:
|
|
1394
|
+
for theme_action, signals_were_blocked in zip(
|
|
1395
|
+
actions,
|
|
1396
|
+
blocked_states,
|
|
1397
|
+
strict=True,
|
|
1398
|
+
):
|
|
1399
|
+
theme_action.blockSignals(signals_were_blocked)
|
|
1400
|
+
|
|
1401
|
+
if not persist_config_value(
|
|
1402
|
+
self,
|
|
1403
|
+
self.config,
|
|
1404
|
+
"user_preference.theme",
|
|
1405
|
+
theme,
|
|
1406
|
+
"the theme preference",
|
|
1407
|
+
sync_theme_actions,
|
|
1408
|
+
):
|
|
1409
|
+
return False
|
|
1410
|
+
|
|
1411
|
+
sync_theme_actions()
|
|
1412
|
+
|
|
1413
|
+
# Update all windows.
|
|
1414
|
+
self.setStyleSheet(self.config.theme.main)
|
|
1415
|
+
for win in self.windows:
|
|
1416
|
+
win.update_theme(self.config)
|
|
1417
|
+
self.show_status(f"Theme changed to {theme}.", 2000)
|
|
1418
|
+
return True
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def change_preview_size(self, preview_size):
|
|
1422
|
+
"""
|
|
1423
|
+
Updates preview image size and regenerates preview thumbnails.
|
|
1424
|
+
|
|
1425
|
+
"""
|
|
1426
|
+
preview_size = int(preview_size)
|
|
1427
|
+
if preview_size == self.preview_size:
|
|
1428
|
+
return True
|
|
1429
|
+
|
|
1430
|
+
if not self._save_preview_size(preview_size):
|
|
1431
|
+
return False
|
|
1432
|
+
|
|
1433
|
+
self._apply_preview_size(preview_size)
|
|
1434
|
+
self.show_status(f"Preview size set to {preview_size} px.", 3000)
|
|
1435
|
+
return True
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
@QtCore.pyqtSlot()
|
|
1439
|
+
def restore_default_settings(self):
|
|
1440
|
+
"""
|
|
1441
|
+
Confirms and resets all user settings to schema defaults.
|
|
1442
|
+
|
|
1443
|
+
"""
|
|
1444
|
+
reply = qtw.QMessageBox.question(
|
|
1445
|
+
self,
|
|
1446
|
+
"Reset All Settings",
|
|
1447
|
+
"Reset all qPlot settings to their defaults? "
|
|
1448
|
+
"This will also close the current database and all plot windows.",
|
|
1449
|
+
qtw.QMessageBox.StandardButton.Yes | qtw.QMessageBox.StandardButton.No,
|
|
1450
|
+
qtw.QMessageBox.StandardButton.No,
|
|
1451
|
+
)
|
|
1452
|
+
if reply != qtw.QMessageBox.StandardButton.Yes:
|
|
1453
|
+
self.show_status("Settings reset cancelled.", 3000)
|
|
1454
|
+
return
|
|
1455
|
+
|
|
1456
|
+
if not persist_config_action(
|
|
1457
|
+
self,
|
|
1458
|
+
self.config.reset_to_defaults,
|
|
1459
|
+
"the default settings",
|
|
1460
|
+
):
|
|
1461
|
+
return False
|
|
1462
|
+
|
|
1463
|
+
self.close_plot_windows(confirm=False, status=False)
|
|
1464
|
+
self.apply_current_settings()
|
|
1465
|
+
self.close_database(status=False)
|
|
1466
|
+
self.show_status("Settings reset to defaults.", 5000)
|
|
1467
|
+
return True
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
def show_preferences_dialog(self):
|
|
1471
|
+
"""
|
|
1472
|
+
Opens the preferences dialog.
|
|
1473
|
+
|
|
1474
|
+
"""
|
|
1475
|
+
dialog = PreferencesDialog(self.config, self)
|
|
1476
|
+
dialog.preferencesApplied.connect(self.apply_current_settings)
|
|
1477
|
+
dialog.preferencesApplied.connect(
|
|
1478
|
+
lambda: self.show_status("Preferences saved.", 3000)
|
|
1479
|
+
)
|
|
1480
|
+
try:
|
|
1481
|
+
dialog.exec()
|
|
1482
|
+
finally:
|
|
1483
|
+
dialog.deleteLater()
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
def apply_current_settings(self):
|
|
1487
|
+
"""
|
|
1488
|
+
Applies config-backed settings that can be updated in open windows.
|
|
1489
|
+
|
|
1490
|
+
"""
|
|
1491
|
+
self._sync_theme_actions()
|
|
1492
|
+
self._sync_preview_size_actions()
|
|
1493
|
+
self._sync_refresh_interval()
|
|
1494
|
+
set_widget_value_without_signals(
|
|
1495
|
+
self.autoPlotBox,
|
|
1496
|
+
self.autoPlotBox.setChecked,
|
|
1497
|
+
self.config.get(AUTO_PLOT_KEY),
|
|
1498
|
+
)
|
|
1499
|
+
self._sync_thread_pool_settings()
|
|
1500
|
+
run_list = getattr(self, "RunList", None)
|
|
1501
|
+
if run_list is not None:
|
|
1502
|
+
if hasattr(run_list, "apply_configured_column_widths"):
|
|
1503
|
+
run_list.apply_configured_column_widths()
|
|
1504
|
+
if hasattr(run_list, "apply_configured_column_visibility"):
|
|
1505
|
+
run_list.apply_configured_column_visibility()
|
|
1506
|
+
self.setStyleSheet(self.config.theme.main)
|
|
1507
|
+
for win in self.windows:
|
|
1508
|
+
win.update_theme(self.config)
|
|
1509
|
+
apply_major_ticks = getattr(
|
|
1510
|
+
win,
|
|
1511
|
+
"apply_axis_major_tick_count_preference",
|
|
1512
|
+
None,
|
|
1513
|
+
)
|
|
1514
|
+
if callable(apply_major_ticks):
|
|
1515
|
+
apply_major_ticks()
|
|
1516
|
+
apply_colorbar_width = getattr(win, "apply_colorbar_width_preference", None)
|
|
1517
|
+
if callable(apply_colorbar_width):
|
|
1518
|
+
apply_colorbar_width()
|
|
1519
|
+
self._sync_mouse_mode_settings()
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
def _sync_theme_actions(self):
|
|
1523
|
+
current_theme = self.config.get("user_preference.theme")
|
|
1524
|
+
for action in getattr(self, "themes", []):
|
|
1525
|
+
action.blockSignals(True)
|
|
1526
|
+
action.setChecked(action.text().replace("&", "").lower() == current_theme)
|
|
1527
|
+
action.blockSignals(False)
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def _sync_preview_size_actions(self):
|
|
1531
|
+
self._apply_preview_size(self._configured_preview_size())
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
def _apply_preview_size(self, preview_size):
|
|
1535
|
+
"""Apply a persisted preview size to the live UI once.
|
|
1536
|
+
|
|
1537
|
+
Persistence is intentionally kept outside this method. This lets menu
|
|
1538
|
+
actions, Preferences, and resetting settings share the same runtime
|
|
1539
|
+
cache invalidation and visible-row regeneration path.
|
|
1540
|
+
"""
|
|
1541
|
+
preview_size = int(preview_size)
|
|
1542
|
+
size_changed = preview_size != self.preview_size
|
|
1543
|
+
self.preview_size = preview_size
|
|
1544
|
+
for action in getattr(self, "previewSizeActions", []):
|
|
1545
|
+
action.blockSignals(True)
|
|
1546
|
+
action.setChecked(action.data() == self.preview_size)
|
|
1547
|
+
action.blockSignals(False)
|
|
1548
|
+
|
|
1549
|
+
if size_changed and hasattr(self, "infoBox"):
|
|
1550
|
+
self.infoBox.set_preview_size(self.preview_size)
|
|
1551
|
+
derived_bridge = getattr(self, "_trusted_derived_bridge", None)
|
|
1552
|
+
if derived_bridge is not None:
|
|
1553
|
+
derived_bridge.update_preview_size(self.preview_size)
|
|
1554
|
+
prioritize_previews = getattr(self, "_prioritize_preview_runs", None)
|
|
1555
|
+
if callable(prioritize_previews):
|
|
1556
|
+
prioritize_previews()
|
|
1557
|
+
if hasattr(self, "runInfoSplitter"):
|
|
1558
|
+
self.runInfoSplitter.setSizes([380, self._details_pane_height()])
|
|
1559
|
+
|
|
1560
|
+
return size_changed
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def _sync_thread_pool_settings(self):
|
|
1564
|
+
if hasattr(self, "threadPool"):
|
|
1565
|
+
self.threadPool.setMaxThreadCount(
|
|
1566
|
+
self.config.get("runtime_settings.max_threads")
|
|
1567
|
+
)
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
def _sync_mouse_mode_settings(self):
|
|
1571
|
+
for win in getattr(self, "windows", []):
|
|
1572
|
+
if hasattr(win, "apply_mouse_mode_preference"):
|
|
1573
|
+
win.apply_mouse_mode_preference()
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
def _save_preview_size(self, preview_size):
|
|
1577
|
+
previous_size = self._configured_preview_size()
|
|
1578
|
+
|
|
1579
|
+
def rollback():
|
|
1580
|
+
actions = list(getattr(self, "previewSizeActions", []))
|
|
1581
|
+
blocked_states = [action.blockSignals(True) for action in actions]
|
|
1582
|
+
try:
|
|
1583
|
+
for action in actions:
|
|
1584
|
+
action.setChecked(action.data() == previous_size)
|
|
1585
|
+
finally:
|
|
1586
|
+
for action, signals_were_blocked in zip(
|
|
1587
|
+
actions,
|
|
1588
|
+
blocked_states,
|
|
1589
|
+
strict=True,
|
|
1590
|
+
):
|
|
1591
|
+
action.blockSignals(signals_were_blocked)
|
|
1592
|
+
|
|
1593
|
+
return persist_config_value(
|
|
1594
|
+
self,
|
|
1595
|
+
self.config,
|
|
1596
|
+
"GUI.preview_size",
|
|
1597
|
+
int(preview_size),
|
|
1598
|
+
"the preview size",
|
|
1599
|
+
rollback,
|
|
1600
|
+
)
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
###############################################################################
|
|
1604
|
+
#Other funcs
|
|
1605
|
+
|
|
1606
|
+
def show_status(self, message : str, timeout : int = 5000):
|
|
1607
|
+
"""
|
|
1608
|
+
Shows a short message in the main window status bar.
|
|
1609
|
+
|
|
1610
|
+
"""
|
|
1611
|
+
status_bar = cast(qtw.QStatusBar, self.statusBar())
|
|
1612
|
+
status_bar.showMessage(message, timeout)
|
|
1613
|
+
|
|
1614
|
+
# A preview-drop action is initiated in a separate plot window, which
|
|
1615
|
+
# can cover the main window and its status bar. Mirror feedback to
|
|
1616
|
+
# that plot for the duration of the action so rejected drops have an
|
|
1617
|
+
# immediately visible explanation.
|
|
1618
|
+
target = getattr(self, "_preview_drop_feedback_window", None)
|
|
1619
|
+
target_show_status = getattr(target, "show_status", None)
|
|
1620
|
+
if callable(target_show_status):
|
|
1621
|
+
target_show_status(message, timeout)
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
def show_error(self, title : str, message : str, details : str | None = None):
|
|
1625
|
+
"""
|
|
1626
|
+
Shows an error both in the status bar and in a message box.
|
|
1627
|
+
|
|
1628
|
+
"""
|
|
1629
|
+
log_user_error(title, message, details, __name__)
|
|
1630
|
+
self.show_status(message, 10_000)
|
|
1631
|
+
|
|
1632
|
+
box = qtw.QMessageBox(qtw.QMessageBox.Icon.Warning, title, message, parent=self)
|
|
1633
|
+
if details:
|
|
1634
|
+
box.setDetailedText(details)
|
|
1635
|
+
box.exec()
|