datalab-platform 1.0.4__py3-none-any.whl → 1.1.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.
- datalab/__init__.py +1 -1
- datalab/config.py +4 -0
- datalab/control/baseproxy.py +160 -0
- datalab/control/remote.py +175 -1
- datalab/data/doc/DataLab_en.pdf +0 -0
- datalab/data/doc/DataLab_fr.pdf +0 -0
- datalab/data/icons/control/copy_connection_info.svg +11 -0
- datalab/data/icons/control/start_webapi_server.svg +19 -0
- datalab/data/icons/control/stop_webapi_server.svg +7 -0
- datalab/gui/main.py +221 -2
- datalab/gui/settings.py +10 -0
- datalab/gui/tour.py +2 -3
- datalab/locale/fr/LC_MESSAGES/datalab.mo +0 -0
- datalab/locale/fr/LC_MESSAGES/datalab.po +87 -1
- datalab/tests/__init__.py +32 -1
- datalab/tests/backbone/config_unit_test.py +1 -1
- datalab/tests/backbone/main_app_test.py +4 -0
- datalab/tests/backbone/memory_leak.py +1 -1
- datalab/tests/features/common/createobject_unit_test.py +1 -1
- datalab/tests/features/common/misc_app_test.py +5 -0
- datalab/tests/features/control/call_method_unit_test.py +104 -0
- datalab/tests/features/control/embedded1_unit_test.py +8 -0
- datalab/tests/features/control/remoteclient_app_test.py +39 -35
- datalab/tests/features/control/simpleclient_unit_test.py +7 -3
- datalab/tests/features/hdf5/h5browser2_unit.py +1 -1
- datalab/tests/features/image/background_dialog_test.py +2 -2
- datalab/tests/features/image/imagetools_unit_test.py +1 -1
- datalab/tests/features/signal/baseline_dialog_test.py +1 -1
- datalab/tests/webapi_test.py +395 -0
- datalab/webapi/__init__.py +95 -0
- datalab/webapi/actions.py +318 -0
- datalab/webapi/adapter.py +642 -0
- datalab/webapi/controller.py +379 -0
- datalab/webapi/routes.py +576 -0
- datalab/webapi/schema.py +198 -0
- datalab/webapi/serialization.py +388 -0
- datalab/widgets/status.py +61 -0
- {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/METADATA +6 -2
- {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/RECORD +45 -33
- /datalab/data/icons/{libre-gui-link.svg → control/libre-gui-link.svg} +0 -0
- /datalab/data/icons/{libre-gui-unlink.svg → control/libre-gui-unlink.svg} +0 -0
- {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/WHEEL +0 -0
- {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/entry_points.txt +0 -0
- {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {datalab_platform-1.0.4.dist-info → datalab_platform-1.1.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
# Copyright (c) DataLab Platform Developers, BSD 3-Clause License
|
|
2
|
+
# See LICENSE file for details
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
Web API Adapter
|
|
6
|
+
===============
|
|
7
|
+
|
|
8
|
+
Thread-safe adapter for accessing the DataLab workspace from Web API handlers.
|
|
9
|
+
|
|
10
|
+
The Web API server runs in a separate thread from the Qt GUI. This adapter
|
|
11
|
+
provides safe access to workspace operations by using Qt's signal/slot mechanism
|
|
12
|
+
to marshal calls to the main thread when necessary.
|
|
13
|
+
|
|
14
|
+
Design
|
|
15
|
+
------
|
|
16
|
+
|
|
17
|
+
The adapter provides the same interface as the workspace but ensures:
|
|
18
|
+
|
|
19
|
+
1. Read operations are safe (DataLab's data model is mostly immutable)
|
|
20
|
+
2. Write operations are marshaled to the Qt main thread
|
|
21
|
+
3. Errors are properly propagated back to the calling thread
|
|
22
|
+
|
|
23
|
+
Usage
|
|
24
|
+
-----
|
|
25
|
+
|
|
26
|
+
The adapter is instantiated once when the Web API starts and passed to route
|
|
27
|
+
handlers via FastAPI dependency injection.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import threading
|
|
33
|
+
from typing import TYPE_CHECKING, Any, Union
|
|
34
|
+
|
|
35
|
+
from qtpy.QtCore import QCoreApplication, QObject, QThread, Signal, Slot
|
|
36
|
+
|
|
37
|
+
from datalab.objectmodel import get_uuid
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from sigima.objects import ImageObj, SignalObj
|
|
41
|
+
|
|
42
|
+
DataObject = Union[SignalObj, ImageObj]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MainThreadExecutor(QObject):
|
|
46
|
+
"""Helper to execute functions on the main thread using Qt signals.
|
|
47
|
+
|
|
48
|
+
This class uses Qt's signal/slot mechanism to safely marshal function calls
|
|
49
|
+
from worker threads (like the Uvicorn server thread) to the Qt main thread.
|
|
50
|
+
This is essential because Qt GUI operations must be performed on the main
|
|
51
|
+
thread.
|
|
52
|
+
|
|
53
|
+
The implementation uses a signal connected with Qt.QueuedConnection to post
|
|
54
|
+
work to the main thread's event loop, and a threading.Event to synchronize
|
|
55
|
+
the calling thread with the result.
|
|
56
|
+
|
|
57
|
+
Important: This class must be instantiated on the main thread!
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
# Signal to request execution on main thread
|
|
61
|
+
_execute_signal = Signal(object, object) # (func, result_holder)
|
|
62
|
+
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
super().__init__()
|
|
65
|
+
# Connect signal to slot - this connection will queue calls to main thread
|
|
66
|
+
self._execute_signal.connect(self._execute_on_main_thread)
|
|
67
|
+
# Store the main thread for comparison
|
|
68
|
+
self._main_thread = QThread.currentThread()
|
|
69
|
+
|
|
70
|
+
@Slot(object, object)
|
|
71
|
+
def _execute_on_main_thread(self, func, result_holder: dict) -> None:
|
|
72
|
+
"""Slot that executes the function on the main thread.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
func: Zero-argument callable to execute.
|
|
76
|
+
result_holder: Dict to store result/exception and signal completion.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
result_holder["result"] = func()
|
|
80
|
+
result_holder["exception"] = None
|
|
81
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
82
|
+
result_holder["result"] = None
|
|
83
|
+
result_holder["exception"] = e
|
|
84
|
+
finally:
|
|
85
|
+
# Signal that execution is complete
|
|
86
|
+
result_holder["event"].set()
|
|
87
|
+
|
|
88
|
+
def run_on_main_thread(self, func) -> Any:
|
|
89
|
+
"""Run a function on the Qt main thread and wait for the result.
|
|
90
|
+
|
|
91
|
+
If already on the main thread, executes directly. Otherwise, uses
|
|
92
|
+
Qt's signal/slot mechanism to marshal the call to the main thread.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
func: Zero-argument callable to execute.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The result of the function.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
Any exception raised by the function.
|
|
102
|
+
"""
|
|
103
|
+
# Check if we're already on the main thread
|
|
104
|
+
if QThread.currentThread() == self._main_thread:
|
|
105
|
+
return func()
|
|
106
|
+
|
|
107
|
+
# Create result holder with synchronization event
|
|
108
|
+
result_holder = {
|
|
109
|
+
"result": None,
|
|
110
|
+
"exception": None,
|
|
111
|
+
"event": threading.Event(),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# Emit signal to queue execution on main thread
|
|
115
|
+
self._execute_signal.emit(func, result_holder)
|
|
116
|
+
|
|
117
|
+
# Wait for execution to complete
|
|
118
|
+
# Use a timeout to avoid hanging forever if something goes wrong
|
|
119
|
+
if not result_holder["event"].wait(timeout=30.0):
|
|
120
|
+
raise TimeoutError("Main thread execution timed out after 30 seconds")
|
|
121
|
+
|
|
122
|
+
# Process pending events to ensure UI updates are applied
|
|
123
|
+
QCoreApplication.processEvents()
|
|
124
|
+
|
|
125
|
+
# Re-raise any exception from the main thread
|
|
126
|
+
if result_holder["exception"] is not None:
|
|
127
|
+
raise result_holder["exception"] # pylint: disable=raising-bad-type
|
|
128
|
+
|
|
129
|
+
return result_holder["result"]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class WorkspaceAdapter(QObject):
|
|
133
|
+
"""Thread-safe adapter for workspace access.
|
|
134
|
+
|
|
135
|
+
This class wraps access to DataLab's workspace, ensuring that all
|
|
136
|
+
modifying operations are executed on the Qt main thread.
|
|
137
|
+
|
|
138
|
+
Attributes:
|
|
139
|
+
main_window: Reference to the DataLab main window.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(self, main_window=None) -> None:
|
|
143
|
+
"""Initialize the adapter.
|
|
144
|
+
|
|
145
|
+
This should be called from the main thread to ensure the executor
|
|
146
|
+
is properly initialized with working Qt signals.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
main_window: The DataLab main window. If None, operations will fail.
|
|
150
|
+
"""
|
|
151
|
+
super().__init__()
|
|
152
|
+
self._main_window = main_window
|
|
153
|
+
# Create executor on main thread to ensure proper Qt signal connection
|
|
154
|
+
self._executor = MainThreadExecutor()
|
|
155
|
+
|
|
156
|
+
def set_main_window(self, main_window) -> None:
|
|
157
|
+
"""Set the main window reference.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
main_window: The DataLab main window.
|
|
161
|
+
"""
|
|
162
|
+
self._main_window = main_window
|
|
163
|
+
|
|
164
|
+
def _ensure_main_window(self) -> None:
|
|
165
|
+
"""Ensure main window is available."""
|
|
166
|
+
if self._main_window is None:
|
|
167
|
+
raise RuntimeError("DataLab main window not available")
|
|
168
|
+
|
|
169
|
+
# =========================================================================
|
|
170
|
+
# Read operations (marshaled to Qt main thread for thread safety)
|
|
171
|
+
# =========================================================================
|
|
172
|
+
|
|
173
|
+
def list_objects(self) -> list[tuple[str, str]]:
|
|
174
|
+
"""List all objects in the workspace.
|
|
175
|
+
|
|
176
|
+
This operation is marshaled to the Qt main thread for thread safety.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
List of (name, panel) tuples for all objects.
|
|
180
|
+
"""
|
|
181
|
+
self._ensure_main_window()
|
|
182
|
+
|
|
183
|
+
def do_list():
|
|
184
|
+
result = []
|
|
185
|
+
# Access signal panel
|
|
186
|
+
sig_panel = self._main_window.signalpanel
|
|
187
|
+
if sig_panel is not None:
|
|
188
|
+
for obj in sig_panel.objmodel:
|
|
189
|
+
result.append((obj.title, "signal"))
|
|
190
|
+
|
|
191
|
+
# Access image panel
|
|
192
|
+
img_panel = self._main_window.imagepanel
|
|
193
|
+
if img_panel is not None:
|
|
194
|
+
for obj in img_panel.objmodel:
|
|
195
|
+
result.append((obj.title, "image"))
|
|
196
|
+
return result
|
|
197
|
+
|
|
198
|
+
return self._executor.run_on_main_thread(do_list)
|
|
199
|
+
|
|
200
|
+
def get_object(self, name: str) -> DataObject:
|
|
201
|
+
"""Get an object by name.
|
|
202
|
+
|
|
203
|
+
This operation is marshaled to the Qt main thread for thread safety.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
name: Object name/title.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
The requested object.
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
KeyError: If object not found.
|
|
213
|
+
"""
|
|
214
|
+
self._ensure_main_window()
|
|
215
|
+
|
|
216
|
+
def do_get():
|
|
217
|
+
# Search in signal panel
|
|
218
|
+
sig_panel = self._main_window.signalpanel
|
|
219
|
+
if sig_panel is not None:
|
|
220
|
+
for obj in sig_panel.objmodel:
|
|
221
|
+
if obj.title == name:
|
|
222
|
+
return obj.copy()
|
|
223
|
+
|
|
224
|
+
# Search in image panel
|
|
225
|
+
img_panel = self._main_window.imagepanel
|
|
226
|
+
if img_panel is not None:
|
|
227
|
+
for obj in img_panel.objmodel:
|
|
228
|
+
if obj.title == name:
|
|
229
|
+
return obj.copy()
|
|
230
|
+
|
|
231
|
+
raise KeyError(f"Object '{name}' not found")
|
|
232
|
+
|
|
233
|
+
return self._executor.run_on_main_thread(do_get)
|
|
234
|
+
|
|
235
|
+
def object_exists(self, name: str) -> bool:
|
|
236
|
+
"""Check if an object exists.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
name: Object name/title.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
True if object exists.
|
|
243
|
+
"""
|
|
244
|
+
try:
|
|
245
|
+
self.get_object(name)
|
|
246
|
+
return True
|
|
247
|
+
except KeyError:
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
def get_object_panel(self, name: str) -> str | None:
|
|
251
|
+
"""Get the panel containing an object.
|
|
252
|
+
|
|
253
|
+
This operation is marshaled to the Qt main thread for thread safety.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
name: Object name/title.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
"signal" or "image", or None if not found.
|
|
260
|
+
"""
|
|
261
|
+
self._ensure_main_window()
|
|
262
|
+
|
|
263
|
+
def do_lookup():
|
|
264
|
+
sig_panel = self._main_window.signalpanel
|
|
265
|
+
if sig_panel is not None:
|
|
266
|
+
for obj in sig_panel.objmodel:
|
|
267
|
+
if obj.title == name:
|
|
268
|
+
return "signal"
|
|
269
|
+
|
|
270
|
+
img_panel = self._main_window.imagepanel
|
|
271
|
+
if img_panel is not None:
|
|
272
|
+
for obj in img_panel.objmodel:
|
|
273
|
+
if obj.title == name:
|
|
274
|
+
return "image"
|
|
275
|
+
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
return self._executor.run_on_main_thread(do_lookup)
|
|
279
|
+
|
|
280
|
+
# =========================================================================
|
|
281
|
+
# Write operations (must be marshaled to Qt main thread)
|
|
282
|
+
# =========================================================================
|
|
283
|
+
|
|
284
|
+
def add_object(self, obj: DataObject, overwrite: bool = False) -> None:
|
|
285
|
+
"""Add an object to the workspace.
|
|
286
|
+
|
|
287
|
+
This operation is marshaled to the Qt main thread as a single atomic
|
|
288
|
+
operation to ensure thread safety.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
obj: Object to add.
|
|
292
|
+
overwrite: If True, replace existing object with same name.
|
|
293
|
+
|
|
294
|
+
Raises:
|
|
295
|
+
ValueError: If object exists and overwrite is False.
|
|
296
|
+
"""
|
|
297
|
+
self._ensure_main_window()
|
|
298
|
+
name = obj.title
|
|
299
|
+
obj_type = type(obj).__name__
|
|
300
|
+
|
|
301
|
+
if obj_type not in ("SignalObj", "ImageObj"):
|
|
302
|
+
raise TypeError(f"Unsupported object type: {obj_type}")
|
|
303
|
+
|
|
304
|
+
# Check if object exists
|
|
305
|
+
if self.object_exists(name):
|
|
306
|
+
if not overwrite:
|
|
307
|
+
raise ValueError(f"Object '{name}' already exists")
|
|
308
|
+
# Remove existing object first using the working remove method
|
|
309
|
+
self._remove_object_sync(name)
|
|
310
|
+
|
|
311
|
+
# Add the new object
|
|
312
|
+
self._add_object_sync(obj)
|
|
313
|
+
|
|
314
|
+
def _add_object_sync(self, obj: DataObject) -> None:
|
|
315
|
+
"""Add object (called from main thread or marshaled via executor)."""
|
|
316
|
+
obj_type = type(obj).__name__
|
|
317
|
+
|
|
318
|
+
if obj_type == "SignalObj":
|
|
319
|
+
panel = self._main_window.signalpanel
|
|
320
|
+
elif obj_type == "ImageObj":
|
|
321
|
+
panel = self._main_window.imagepanel
|
|
322
|
+
else:
|
|
323
|
+
raise TypeError(f"Unsupported object type: {obj_type}")
|
|
324
|
+
|
|
325
|
+
# Use executor to run on main thread if necessary
|
|
326
|
+
self._executor.run_on_main_thread(lambda: panel.add_object(obj))
|
|
327
|
+
|
|
328
|
+
def remove_object(self, name: str) -> None:
|
|
329
|
+
"""Remove an object from the workspace.
|
|
330
|
+
|
|
331
|
+
This operation is marshaled to the Qt main thread.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
name: Object name/title.
|
|
335
|
+
|
|
336
|
+
Raises:
|
|
337
|
+
KeyError: If object not found.
|
|
338
|
+
"""
|
|
339
|
+
self._ensure_main_window()
|
|
340
|
+
|
|
341
|
+
if not self.object_exists(name):
|
|
342
|
+
raise KeyError(f"Object '{name}' not found")
|
|
343
|
+
|
|
344
|
+
self._remove_object_sync(name)
|
|
345
|
+
|
|
346
|
+
def _remove_object_sync(self, name: str) -> None:
|
|
347
|
+
"""Remove object (called from main thread or marshaled via executor)."""
|
|
348
|
+
panel_name = self.get_object_panel(name)
|
|
349
|
+
if panel_name is None:
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
main_window = self._main_window
|
|
353
|
+
|
|
354
|
+
# Use executor to run on main thread, including all panel access
|
|
355
|
+
def do_remove():
|
|
356
|
+
# All panel access happens inside the executor
|
|
357
|
+
if panel_name == "signal":
|
|
358
|
+
panel = main_window.signalpanel
|
|
359
|
+
else:
|
|
360
|
+
panel = main_window.imagepanel
|
|
361
|
+
|
|
362
|
+
# Find the object
|
|
363
|
+
target_obj = None
|
|
364
|
+
for obj in panel.objmodel:
|
|
365
|
+
if obj.title == name:
|
|
366
|
+
target_obj = obj
|
|
367
|
+
break
|
|
368
|
+
|
|
369
|
+
if target_obj is None:
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
obj_uuid = get_uuid(target_obj)
|
|
373
|
+
|
|
374
|
+
# Remove using the same approach as remove_all_objects but for single object
|
|
375
|
+
# Remove from plot handler
|
|
376
|
+
panel.plothandler.remove_item(obj_uuid)
|
|
377
|
+
# Remove from tree view
|
|
378
|
+
panel.objview.remove_item(obj_uuid, refresh=False)
|
|
379
|
+
# Remove from object model
|
|
380
|
+
panel.objmodel.remove_object(target_obj)
|
|
381
|
+
# Update tree
|
|
382
|
+
panel.objview.update_tree()
|
|
383
|
+
# Emit signal
|
|
384
|
+
panel.SIG_OBJECT_REMOVED.emit()
|
|
385
|
+
|
|
386
|
+
self._executor.run_on_main_thread(do_remove)
|
|
387
|
+
|
|
388
|
+
def update_metadata(self, name: str, metadata: dict) -> None:
|
|
389
|
+
"""Update object metadata.
|
|
390
|
+
|
|
391
|
+
This operation modifies Qt objects and should be marshaled to the
|
|
392
|
+
Qt main thread for thread safety.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
name: Object name/title.
|
|
396
|
+
metadata: Dictionary of metadata fields to update.
|
|
397
|
+
|
|
398
|
+
Raises:
|
|
399
|
+
KeyError: If object not found.
|
|
400
|
+
"""
|
|
401
|
+
self._ensure_main_window()
|
|
402
|
+
|
|
403
|
+
panel_name = self.get_object_panel(name)
|
|
404
|
+
if panel_name is None:
|
|
405
|
+
raise KeyError(f"Object '{name}' not found")
|
|
406
|
+
|
|
407
|
+
if panel_name == "signal":
|
|
408
|
+
panel = self._main_window.signalpanel
|
|
409
|
+
else:
|
|
410
|
+
panel = self._main_window.imagepanel
|
|
411
|
+
|
|
412
|
+
def do_update():
|
|
413
|
+
# Find and update object
|
|
414
|
+
for obj in panel.objmodel:
|
|
415
|
+
if obj.title == name:
|
|
416
|
+
for key, value in metadata.items():
|
|
417
|
+
if value is not None and hasattr(obj, key):
|
|
418
|
+
setattr(obj, key, value)
|
|
419
|
+
# Refresh display
|
|
420
|
+
panel.SIG_REFRESH_PLOT.emit("selected", True)
|
|
421
|
+
break
|
|
422
|
+
|
|
423
|
+
self._executor.run_on_main_thread(do_update)
|
|
424
|
+
|
|
425
|
+
def clear(self) -> None:
|
|
426
|
+
"""Clear all objects from the workspace.
|
|
427
|
+
|
|
428
|
+
This operation is marshaled to the Qt main thread.
|
|
429
|
+
"""
|
|
430
|
+
self._ensure_main_window()
|
|
431
|
+
|
|
432
|
+
def do_clear():
|
|
433
|
+
# Clear both panels using remove_all_objects (no confirmation dialog)
|
|
434
|
+
for panel in [self._main_window.signalpanel, self._main_window.imagepanel]:
|
|
435
|
+
if panel is not None:
|
|
436
|
+
panel.remove_all_objects()
|
|
437
|
+
|
|
438
|
+
self._executor.run_on_main_thread(do_clear)
|
|
439
|
+
|
|
440
|
+
# =========================================================================
|
|
441
|
+
# Computation operations (for calc API)
|
|
442
|
+
# =========================================================================
|
|
443
|
+
|
|
444
|
+
def select_objects(
|
|
445
|
+
self, names: list[str], panel: str | None = None
|
|
446
|
+
) -> tuple[list[str], str]:
|
|
447
|
+
"""Select objects by name in a panel.
|
|
448
|
+
|
|
449
|
+
This operation is marshaled to the Qt main thread for thread safety.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
names: List of object names/titles to select.
|
|
453
|
+
panel: Panel name ("signal" or "image"). None = auto-detect or current.
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
Tuple of (list of selected names, panel name).
|
|
457
|
+
|
|
458
|
+
Raises:
|
|
459
|
+
KeyError: If any object not found.
|
|
460
|
+
ValueError: If objects span multiple panels.
|
|
461
|
+
"""
|
|
462
|
+
self._ensure_main_window()
|
|
463
|
+
|
|
464
|
+
def do_select():
|
|
465
|
+
# Determine panel for each object
|
|
466
|
+
panels_found = set()
|
|
467
|
+
obj_indices = []
|
|
468
|
+
|
|
469
|
+
for name in names:
|
|
470
|
+
obj_panel = self.get_object_panel(name)
|
|
471
|
+
if obj_panel is None:
|
|
472
|
+
raise KeyError(f"Object '{name}' not found")
|
|
473
|
+
panels_found.add(obj_panel)
|
|
474
|
+
|
|
475
|
+
if len(panels_found) > 1:
|
|
476
|
+
raise ValueError(
|
|
477
|
+
"Cannot select objects from multiple panels. "
|
|
478
|
+
f"Found objects in: {panels_found}"
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
if panel is not None:
|
|
482
|
+
target_panel = panel
|
|
483
|
+
elif panels_found:
|
|
484
|
+
target_panel = panels_found.pop()
|
|
485
|
+
else:
|
|
486
|
+
target_panel = "signal"
|
|
487
|
+
|
|
488
|
+
# Get the panel widget
|
|
489
|
+
if target_panel == "signal":
|
|
490
|
+
panel_widget = self._main_window.signalpanel
|
|
491
|
+
else:
|
|
492
|
+
panel_widget = self._main_window.imagepanel
|
|
493
|
+
|
|
494
|
+
# Find object indices (1-based) by name
|
|
495
|
+
for name in names:
|
|
496
|
+
for idx, obj in enumerate(panel_widget.objmodel):
|
|
497
|
+
if obj.title == name:
|
|
498
|
+
obj_indices.append(idx + 1) # 1-based indexing
|
|
499
|
+
break
|
|
500
|
+
|
|
501
|
+
# Select the objects using the panel's method
|
|
502
|
+
if obj_indices:
|
|
503
|
+
panel_widget.objview.select_objects(obj_indices)
|
|
504
|
+
|
|
505
|
+
return names, target_panel
|
|
506
|
+
|
|
507
|
+
return self._executor.run_on_main_thread(do_select)
|
|
508
|
+
|
|
509
|
+
def get_selected_objects(self, panel: str | None = None) -> list[str]:
|
|
510
|
+
"""Get names of currently selected objects.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
panel: Panel name. None = current panel.
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
List of selected object names.
|
|
517
|
+
"""
|
|
518
|
+
self._ensure_main_window()
|
|
519
|
+
|
|
520
|
+
def do_get_selected():
|
|
521
|
+
if panel == "signal":
|
|
522
|
+
panel_widget = self._main_window.signalpanel
|
|
523
|
+
elif panel == "image":
|
|
524
|
+
panel_widget = self._main_window.imagepanel
|
|
525
|
+
else:
|
|
526
|
+
# Use current panel
|
|
527
|
+
panel_widget = self._main_window.tabwidget.currentWidget()
|
|
528
|
+
if not hasattr(panel_widget, "objmodel"):
|
|
529
|
+
return []
|
|
530
|
+
|
|
531
|
+
return [obj.title for obj in panel_widget.objview.get_sel_objects()]
|
|
532
|
+
|
|
533
|
+
return self._executor.run_on_main_thread(do_get_selected)
|
|
534
|
+
|
|
535
|
+
def calc(self, name: str, param: dict | None = None) -> tuple[bool, list[str]]:
|
|
536
|
+
"""Call a computation function on currently selected objects.
|
|
537
|
+
|
|
538
|
+
This operation is marshaled to the Qt main thread for thread safety.
|
|
539
|
+
|
|
540
|
+
Args:
|
|
541
|
+
name: Computation function name (e.g., "normalize", "fft").
|
|
542
|
+
param: Optional parameters as a dictionary.
|
|
543
|
+
|
|
544
|
+
Returns:
|
|
545
|
+
Tuple of (success, list of new object names created).
|
|
546
|
+
|
|
547
|
+
Raises:
|
|
548
|
+
ValueError: If computation function not found.
|
|
549
|
+
"""
|
|
550
|
+
self._ensure_main_window()
|
|
551
|
+
|
|
552
|
+
def do_calc():
|
|
553
|
+
# Get objects before calc to track new ones
|
|
554
|
+
before_names = set(self._get_all_object_names())
|
|
555
|
+
|
|
556
|
+
# Convert param dict to DataSet if provided
|
|
557
|
+
param_dataset = None
|
|
558
|
+
if param is not None:
|
|
559
|
+
param_dataset = self._dict_to_dataset(name, param)
|
|
560
|
+
|
|
561
|
+
# Call the main window's calc method with edit=False to prevent
|
|
562
|
+
# blocking modal dialogs when called from the API
|
|
563
|
+
try:
|
|
564
|
+
self._main_window.calc(name, param_dataset, edit=False)
|
|
565
|
+
success = True
|
|
566
|
+
except ValueError:
|
|
567
|
+
raise
|
|
568
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
569
|
+
raise RuntimeError(f"Computation '{name}' failed: {e}") from e
|
|
570
|
+
|
|
571
|
+
# Get objects after calc to find new ones
|
|
572
|
+
after_names = set(self._get_all_object_names())
|
|
573
|
+
new_names = list(after_names - before_names)
|
|
574
|
+
|
|
575
|
+
return success, new_names
|
|
576
|
+
|
|
577
|
+
return self._executor.run_on_main_thread(do_calc)
|
|
578
|
+
|
|
579
|
+
def _get_all_object_names(self) -> list[str]:
|
|
580
|
+
"""Get all object names from all panels."""
|
|
581
|
+
names = []
|
|
582
|
+
for panel in [self._main_window.signalpanel, self._main_window.imagepanel]:
|
|
583
|
+
if panel is not None:
|
|
584
|
+
for obj in panel.objmodel:
|
|
585
|
+
names.append(obj.title)
|
|
586
|
+
return names
|
|
587
|
+
|
|
588
|
+
def _dict_to_dataset(self, func_name: str, param_dict: dict):
|
|
589
|
+
"""Convert a parameter dictionary to a DataSet object.
|
|
590
|
+
|
|
591
|
+
This looks up the parameter class for the given function and
|
|
592
|
+
creates an instance with the provided values.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
func_name: Computation function name.
|
|
596
|
+
param_dict: Dictionary of parameter values.
|
|
597
|
+
|
|
598
|
+
Returns:
|
|
599
|
+
DataSet instance, or None if no parameters needed.
|
|
600
|
+
"""
|
|
601
|
+
import guidata.dataset as gds # pylint: disable=import-outside-toplevel
|
|
602
|
+
|
|
603
|
+
# Try to find the parameter class from the processor
|
|
604
|
+
# First, look in the current panel's processor
|
|
605
|
+
panel = self._main_window.tabwidget.currentWidget()
|
|
606
|
+
if hasattr(panel, "processor"):
|
|
607
|
+
try:
|
|
608
|
+
feature = panel.processor.get_feature(func_name)
|
|
609
|
+
if feature.paramclass is not None:
|
|
610
|
+
# Create instance and set values
|
|
611
|
+
param_obj = feature.paramclass()
|
|
612
|
+
for key, value in param_dict.items():
|
|
613
|
+
if hasattr(param_obj, key):
|
|
614
|
+
setattr(param_obj, key, value)
|
|
615
|
+
return param_obj
|
|
616
|
+
except ValueError:
|
|
617
|
+
pass
|
|
618
|
+
|
|
619
|
+
# Fallback: try to import common parameter classes from sigima
|
|
620
|
+
try:
|
|
621
|
+
import sigima.params # pylint: disable=import-outside-toplevel
|
|
622
|
+
|
|
623
|
+
# Try to find matching param class (e.g., "normalize" -> NormalizeParam)
|
|
624
|
+
param_class_name = func_name.title().replace("_", "") + "Param"
|
|
625
|
+
if hasattr(sigima.params, param_class_name):
|
|
626
|
+
param_class = getattr(sigima.params, param_class_name)
|
|
627
|
+
return param_class.create(**param_dict)
|
|
628
|
+
except ImportError:
|
|
629
|
+
pass
|
|
630
|
+
|
|
631
|
+
# If we can't find a param class, create a simple DataSet
|
|
632
|
+
if param_dict:
|
|
633
|
+
|
|
634
|
+
class DynamicParam(gds.DataSet):
|
|
635
|
+
"""Dynamic parameter class created at runtime."""
|
|
636
|
+
|
|
637
|
+
param_obj = DynamicParam()
|
|
638
|
+
for key, value in param_dict.items():
|
|
639
|
+
setattr(param_obj, key, value)
|
|
640
|
+
return param_obj
|
|
641
|
+
|
|
642
|
+
return None
|