Skip to content

Commit bee78fd

Browse files
Merge pull request #422 from DataLab-Platform/feature/experimental-data-integration
Integrate experimental data: NeXus files, robust HDF5 import and threaded plugin instruments
2 parents d124871 + dce5cce commit bee78fd

26 files changed

Lines changed: 1174 additions & 211 deletions

File tree

‎datalab/control/baseproxy.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -401,13 +401,16 @@ def set_object(self, obj: SignalObj | ImageObj) -> None:
401401
@abc.abstractmethod
402402
def add_group(
403403
self, title: str, panel: str | None = None, select: bool = False
404-
) -> None:
404+
) -> str:
405405
"""Add group to DataLab.
406406
407407
Args:
408408
title: Group title
409409
panel: Panel name (valid values: "signal", "image"). Defaults to None.
410410
select: Select the group after creation. Defaults to False.
411+
412+
Returns:
413+
UUID of the created group
411414
"""
412415

413416
@abc.abstractmethod
@@ -923,15 +926,18 @@ def get_current_object_uuid(self) -> str | None:
923926

924927
def add_group(
925928
self, title: str, panel: str | None = None, select: bool = False
926-
) -> None:
929+
) -> str:
927930
"""Add group to DataLab.
928931
929932
Args:
930933
title: Group title
931934
panel: Panel name (valid values: "signal", "image"). Defaults to None.
932935
select: Select the group after creation. Defaults to False.
936+
937+
Returns:
938+
UUID of the created group
933939
"""
934-
self._datalab.add_group(title, panel, select)
940+
return self._datalab.add_group(title, panel, select)
935941

936942
def select_objects(
937943
self,

‎datalab/control/remote.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,6 @@ def __init__(self, win: DLMainWindow) -> None:
126126
for signal, command in (
127127
(self.SIG_ADD_OBJECT, win.add_object),
128128
(self.SIG_SET_OBJECT, win.set_object),
129-
(self.SIG_ADD_GROUP, win.add_group),
130129
(self.SIG_LOAD_FROM_FILES, win.load_from_files),
131130
(self.SIG_LOAD_FROM_DIRECTORY, win.load_from_directory),
132131
(self.SIG_SELECT_OBJECTS, win.select_objects),
@@ -149,6 +148,9 @@ def __init__(self, win: DLMainWindow) -> None:
149148
(self.SIG_IMPORT_MACRO_FROM_FILE, win.import_macro_from_file),
150149
):
151150
signal.connect(functools.partial(win.run_remote_command, command))
151+
self.SIG_ADD_GROUP.connect(
152+
functools.partial(win.run_remote_command, win.add_group, return_result=True)
153+
)
152154
self.SIG_CALL_METHOD.connect(win.call_method_slot)
153155

154156
def serve(self) -> None:
@@ -572,15 +574,20 @@ def get_current_object_uuid(self) -> str | None:
572574
@remote_call
573575
def add_group(
574576
self, title: str, panel: str | None = None, select: bool = False
575-
) -> None:
577+
) -> str:
576578
"""Add group to DataLab.
577579
578580
Args:
579581
title: Group title
580582
panel: Panel name (valid values: "signal", "image"). Defaults to None.
581583
select: Select the group after creation. Defaults to False.
584+
585+
Returns:
586+
UUID of the created group
582587
"""
583588
self.SIG_ADD_GROUP.emit(title, panel, select)
589+
# The decorator waits for the main window slot, which sets the result
590+
return self.result
584591

585592
@remote_call
586593
def select_objects(

‎datalab/gui/h5io.py‎

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ class H5InputOutput:
6868

6969
def __init__(self, mainwindow: DLMainWindow) -> None:
7070
self.mainwindow = mainwindow
71-
self.uint32_wng: bool = None
7271
#: True when the user was already told, for the current opening, that
7372
#: the provenance of appended workspaces is not imported (no repeat).
7473
self.append_informed = False
@@ -273,29 +272,36 @@ def __add_object_from_node(self, node: BaseNode) -> None:
273272
obj = node.get_native_object()
274273
if obj is None:
275274
return
276-
self.uint32_wng = self.uint32_wng or node.uint32_wng
277275
if isinstance(obj, SignalObj):
278276
self.mainwindow.signalpanel.add_object(obj)
279277
else:
280278
self.mainwindow.imagepanel.add_object(obj)
281279

282-
def __eventually_show_warnings(self) -> None:
283-
"""Eventually show warnings after everything is imported"""
284-
if self.uint32_wng:
285-
QW.QMessageBox.warning(
286-
self.mainwindow, _("Warning"), _("Clipping uint32 data to int32.")
287-
)
288-
289280
def import_files(
290281
self, filenames: list[str], import_all: bool, reset_all: bool
291282
) -> None:
292283
"""Import HDF5 files"""
293284
h5browser = H5BrowserDialog(self.mainwindow)
285+
try:
286+
self.__import_files_from_browser(
287+
h5browser, filenames, import_all, reset_all
288+
)
289+
finally:
290+
# Close the HDF5 files, even if an import error is raised
291+
h5browser.cleanup()
292+
293+
def __import_files_from_browser(
294+
self,
295+
h5browser: H5BrowserDialog,
296+
filenames: list[str],
297+
import_all: bool,
298+
reset_all: bool,
299+
) -> None:
300+
"""Open HDF5 files in browser, then import the selected datasets"""
294301
for filename in filenames:
295302
with qt_try_loadsave_file(self.mainwindow, filename, "load"):
296303
h5browser.open_file(filename)
297304
if h5browser.is_empty():
298-
h5browser.cleanup()
299305
if not execenv.unattended:
300306
QW.QMessageBox.warning(
301307
self.mainwindow,
@@ -314,27 +320,52 @@ def import_files(
314320
if nodes is not None:
315321
if reset_all:
316322
self.mainwindow.reset_all()
323+
errors: list[str] = []
317324
with qt_try_loadsave_file(self.mainwindow, "*.h5", "load"):
318325
with create_progress_bar(self.mainwindow, "", len(nodes)) as prog:
319-
self.uint32_wng = False
320326
for idx, node in enumerate(nodes):
321327
prog.setLabelText(self.__progbartitle(node.h5file.filename))
322328
prog.setValue(idx + 1)
323329
QW.QApplication.processEvents()
324330
if prog.wasCanceled():
325331
break
326-
self.__add_object_from_node(node)
327-
self.__eventually_show_warnings()
328-
h5browser.cleanup()
332+
try:
333+
self.__add_object_from_node(node)
334+
except Exception as exc: # pylint: disable=broad-except
335+
# Data of third-party files: import the other
336+
# datasets, then report all errors at once
337+
errors.append(f"{node.id}: {exc}")
338+
self.__report_import_errors(errors)
339+
340+
def __report_import_errors(self, errors: list[str]) -> None:
341+
"""Report the datasets that could not be imported
342+
343+
Args:
344+
errors: description of each error ("dataset: reason")
345+
346+
Raises:
347+
ValueError: in unattended mode, if any dataset could not be imported
348+
"""
349+
if not errors:
350+
return
351+
if execenv.unattended:
352+
raise ValueError("\n".join(errors))
353+
shown = errors[:10]
354+
if len(errors) > len(shown):
355+
shown.append(_("... and %d more") % (len(errors) - len(shown)))
356+
QW.QMessageBox.warning(
357+
self.mainwindow,
358+
_("Warning"),
359+
_("Some datasets could not be imported:") + "\n\n" + "\n".join(shown),
360+
)
329361

330362
def import_dataset_from_file(self, filename: str, dsetname: str) -> None:
331363
"""Import dataset from HDF5 file"""
332364
h5importer = H5Importer(filename)
333365
try:
334366
node = h5importer.get(dsetname)
335-
self.uint32_wng = False
336367
self.__add_object_from_node(node)
337-
self.__eventually_show_warnings()
338368
except KeyError as exc:
339369
raise KeyError(f"Dataset not found: {dsetname}") from exc
340-
h5importer.close()
370+
finally:
371+
h5importer.close()

‎datalab/gui/main.py‎

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import sys
2626
import time
2727
import traceback
28+
from concurrent.futures import Future
2829
from contextlib import ExitStack
2930
from datetime import datetime
3031
from typing import TYPE_CHECKING
@@ -75,7 +76,11 @@
7576
from datalab.gui.panel import base, history, image, macro, signal
7677
from datalab.gui.plugins.applications import ApplicationsDialog, get_plugin_icon
7778
from datalab.gui.plugins.config import PluginConfigDialog
78-
from datalab.gui.plugins.instrument import InstrumentWindow
79+
from datalab.gui.plugins.instrument import (
80+
InstrumentWindow,
81+
SerialWorker,
82+
wait_instruments_closed,
83+
)
7984
from datalab.gui.processor.base import FeatureNotFoundError
8085
from datalab.gui.processor.preview import PreviewExecutorCache
8186
from datalab.gui.settings import AI_OPTION_NAMES, edit_settings
@@ -172,6 +177,10 @@ def __init__(self, console=None, hide_on_close=False):
172177
self.aiassistantpanel = None # type: ignore[assignment]
173178
self.__applications_dialog: ApplicationsDialog | None = None
174179
self.__instrument_windows: dict[tuple[str, str], InstrumentWindow] = {}
180+
#: Worker of each plugin instrument, shared by its successive windows
181+
self.__instrument_workers: dict[tuple[str, str], SerialWorker] = {}
182+
#: Title and closing call of the last closed window of each instrument
183+
self.__instrument_closings: dict[tuple[str, str], tuple[str, Future]] = {}
175184
self.welcomepanel: WelcomePanel | None = None
176185

177186
self.signalpanel_toolbar: QW.QToolBar | None = None
@@ -509,15 +518,18 @@ def add_group(
509518
title: str,
510519
panel: Literal["signal", "image"] | None = None,
511520
select: bool = False,
512-
) -> None:
521+
) -> str:
513522
"""Add group to DataLab.
514523
515524
Args:
516525
title: Group title
517526
panel: Panel name. Defaults to None.
518527
select: Select the group after creation. Defaults to False.
528+
529+
Returns:
530+
UUID of the created group
519531
"""
520-
self.__get_datapanel(panel).add_group(title, select)
532+
return get_uuid(self.__get_datapanel(panel).add_group(title, select))
521533

522534
@remote_controlled
523535
def select_objects(
@@ -674,7 +686,9 @@ def call_method_slot(
674686
self.remote_server.exception = exc
675687

676688
@remote_controlled
677-
def run_remote_command(self, command: Callable[..., object], *args) -> None:
689+
def run_remote_command(
690+
self, command: Callable[..., object], *args, return_result: bool = False
691+
) -> None:
678692
"""Slot to run a command requested by a remote client in GUI thread.
679693
680694
The exception raised by the command, if any, is stored in RemoteServer to
@@ -684,9 +698,13 @@ def run_remote_command(self, command: Callable[..., object], *args) -> None:
684698
Args:
685699
command: Main window method running the command
686700
args: Command arguments
701+
return_result: If True, store the command result in RemoteServer, to be
702+
returned to the client (it must be XML-RPC serializable)
687703
"""
688704
try:
689-
command(*args)
705+
result = command(*args)
706+
if return_result:
707+
self.remote_server.result = result
690708
except Exception as exc: # pylint: disable=broad-except
691709
self.remote_server.exception = exc
692710

@@ -1143,11 +1161,24 @@ def __launch_plugin_tool(self, plugin: PluginBase, tool_id: str) -> None:
11431161
def open_plugin_instrument(
11441162
self, plugin: PluginBase, tool: PluginTool
11451163
) -> InstrumentWindow:
1146-
"""Show the instrument window of a plugin tool, creating it if needed."""
1164+
"""Show the instrument window of a plugin tool, creating it if needed.
1165+
1166+
The successive windows of an instrument share its worker: a window
1167+
opened again calls the instrument once the closed one has closed it.
1168+
"""
11471169
key = (plugin.plugin_id, tool.id)
11481170
window = self.__instrument_windows.get(key)
1171+
if window is not None and window.close_future is not None:
1172+
# Closed while hidden: Qt did not emit `finished`, release it now
1173+
self.__forget_instrument_window(key)
1174+
window = None
11491175
if window is None:
1150-
window = InstrumentWindow(self, plugin.get_instrument(tool.id), tool.title)
1176+
worker = self.__instrument_workers.get(key)
1177+
if worker is None:
1178+
worker = self.__instrument_workers[key] = SerialWorker()
1179+
window = InstrumentWindow(
1180+
self, plugin.get_instrument(tool.id), tool.title, worker
1181+
)
11511182
window.finished.connect(
11521183
functools.partial(self.__forget_instrument_window, key)
11531184
)
@@ -1158,15 +1189,33 @@ def open_plugin_instrument(
11581189
return window
11591190

11601191
def __forget_instrument_window(self, key: tuple[str, str], *_args) -> None:
1161-
"""Release a closed instrument window."""
1192+
"""Release a closed instrument window, keeping track of its instrument
1193+
until it is closed."""
11621194
window = self.__instrument_windows.pop(key, None)
11631195
if window is not None:
1196+
window.stop()
1197+
closing = (window.windowTitle(), window.close_future)
1198+
self.__instrument_closings[key] = closing
11641199
window.deleteLater()
11651200

11661201
def close_plugin_instruments(self) -> None:
1167-
"""Close all plugin instrument windows."""
1202+
"""Close all plugin instrument windows, and wait for their instruments,
1203+
including those of windows closed before, within a common time limit."""
11681204
for window in list(self.__instrument_windows.values()):
11691205
window.close()
1206+
# Windows that were not visible do not emit `finished` when closed
1207+
for key in list(self.__instrument_windows):
1208+
self.__forget_instrument_window(key)
1209+
timeout = InstrumentWindow.CLOSE_TIMEOUT
1210+
closings = self.__instrument_closings.values()
1211+
for title in wait_instruments_closed(closings, timeout):
1212+
logging.getLogger(__name__).warning(
1213+
"Instrument '%s' did not close within %g s", title, timeout
1214+
)
1215+
for worker in self.__instrument_workers.values():
1216+
worker.shutdown()
1217+
self.__instrument_workers.clear()
1218+
self.__instrument_closings.clear()
11701219

11711220
def __unregister_plugins(self) -> None:
11721221
"""Unregister all plugins and let them cleanup their hooks"""
@@ -2272,7 +2321,10 @@ def open_h5_files(
22722321
self,
22732322
_("Open"),
22742323
Conf.base_dir.get(),
2275-
_("HDF5 files (*.h5 *.hdf5 *.hdf *.he5);;All files (*)"),
2324+
_(
2325+
"HDF5 files (*.h5 *.hdf5 *.hdf *.he5 *.nxs *.nx5);;"
2326+
"All files (*)"
2327+
),
22762328
)
22772329
if not h5files:
22782330
return

‎datalab/gui/panel/base.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,9 @@ def is_hdf5_file(filename: str, check_content: bool = False) -> bool:
185185
True if the file is (likely) an HDF5 file, False otherwise.
186186
"""
187187
# First, check by extension (fast)
188-
has_hdf5_extension = filename.lower().endswith((".h5", ".hdf5", ".hdf", ".he5"))
188+
has_hdf5_extension = filename.lower().endswith(
189+
(".h5", ".hdf5", ".hdf", ".he5", ".nxs", ".nx5")
190+
)
189191

190192
if not check_content:
191193
return has_hdf5_extension

0 commit comments

Comments
 (0)