Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
bf350e1
feat(remote): add_group returns the UUID of the new group
PierreRaybaut Oct 21, 2026
892d056
fix(hdf5): drop the obsolete uint32 clipping warning
PierreRaybaut Oct 21, 2026
42ffc08
docs: release notes for add_group and HDF5 import
PierreRaybaut Oct 21, 2026
fdea49c
feat(hdf5): open NeXus files and keep their origin in workspaces
PierreRaybaut Oct 21, 2026
5382084
docs: release notes for NeXus import and HDF5 previews
PierreRaybaut Oct 21, 2026
36f5428
feat(plugins): run instruments outside the GUI thread, with open/close
PierreRaybaut Oct 21, 2026
cc56a7e
docs: instrument open/close and threading
PierreRaybaut Oct 21, 2026
85091b2
docs: release notes for HDF5 data mappings
PierreRaybaut Oct 21, 2026
c41916c
docs: French translation of HDF5, NeXus and instrument release notes
PierreRaybaut Oct 21, 2026
3b9f9b9
Merge remote-tracking branch 'origin/develop' into feature/experiment…
PierreRaybaut Oct 21, 2026
6fcd590
Merge branch 'develop' into feature/experimental-data-integration
PierreRaybaut Oct 21, 2026
c744826
Merge branch 'develop' into feature/experimental-data-integration
PierreRaybaut Oct 22, 2026
a1d3074
docs: complete experimental data integration guidance
PierreRaybaut Oct 22, 2026
b81cf3e
Merge branch 'develop' into feature/experimental-data-integration
PierreRaybaut Oct 23, 2026
ec20645
fix(instruments): serialize instrument calls across window reopenings
PierreRaybaut Oct 23, 2026
f11336c
fix(instruments): show open() errors and retry opening before the nex…
PierreRaybaut Oct 23, 2026
6c03a4f
test(hdf5): check experimental_source types and NeXus axes after work…
PierreRaybaut Oct 23, 2026
3a49d77
fix(h5io): import the other HDF5 datasets when one cannot be imported
PierreRaybaut Oct 23, 2026
00a6674
docs(i18n): synchronize French remote control and installation catalogs
PierreRaybaut Oct 23, 2026
277c3f1
fix(h5io): close HDF5 files when an import fails
PierreRaybaut Oct 23, 2026
5a13135
fix(instruments): keep Live mode off when unchecked during an acquisi…
PierreRaybaut Oct 23, 2026
13c5d32
fix(instruments): replace an instrument window closed while hidden
PierreRaybaut Oct 23, 2026
549d857
docs: document broken HDF5 links and preview clearing
PierreRaybaut Oct 23, 2026
2cdd3a5
fix(remote): annotate RemoteServer.add_group as returning the group UUID
PierreRaybaut Oct 23, 2026
67846e0
fix(instruments): bound the total wait for instruments on close
PierreRaybaut Oct 23, 2026
dce5cce
docs(i18n): translate dependency descriptions in French catalogs
PierreRaybaut Oct 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions datalab/control/baseproxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,13 +401,16 @@ def set_object(self, obj: SignalObj | ImageObj) -> None:
@abc.abstractmethod
def add_group(
self, title: str, panel: str | None = None, select: bool = False
) -> None:
) -> str:
"""Add group to DataLab.

Args:
title: Group title
panel: Panel name (valid values: "signal", "image"). Defaults to None.
select: Select the group after creation. Defaults to False.

Returns:
UUID of the created group
"""

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

def add_group(
self, title: str, panel: str | None = None, select: bool = False
) -> None:
) -> str:
"""Add group to DataLab.

Args:
title: Group title
panel: Panel name (valid values: "signal", "image"). Defaults to None.
select: Select the group after creation. Defaults to False.

Returns:
UUID of the created group
"""
self._datalab.add_group(title, panel, select)
return self._datalab.add_group(title, panel, select)

def select_objects(
self,
Expand Down
11 changes: 9 additions & 2 deletions datalab/control/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@ def __init__(self, win: DLMainWindow) -> None:
for signal, command in (
(self.SIG_ADD_OBJECT, win.add_object),
(self.SIG_SET_OBJECT, win.set_object),
(self.SIG_ADD_GROUP, win.add_group),
(self.SIG_LOAD_FROM_FILES, win.load_from_files),
(self.SIG_LOAD_FROM_DIRECTORY, win.load_from_directory),
(self.SIG_SELECT_OBJECTS, win.select_objects),
Expand All @@ -149,6 +148,9 @@ def __init__(self, win: DLMainWindow) -> None:
(self.SIG_IMPORT_MACRO_FROM_FILE, win.import_macro_from_file),
):
signal.connect(functools.partial(win.run_remote_command, command))
self.SIG_ADD_GROUP.connect(
functools.partial(win.run_remote_command, win.add_group, return_result=True)
)
self.SIG_CALL_METHOD.connect(win.call_method_slot)

def serve(self) -> None:
Expand Down Expand Up @@ -572,15 +574,20 @@ def get_current_object_uuid(self) -> str | None:
@remote_call
def add_group(
self, title: str, panel: str | None = None, select: bool = False
) -> None:
) -> str:
"""Add group to DataLab.

Args:
title: Group title
panel: Panel name (valid values: "signal", "image"). Defaults to None.
select: Select the group after creation. Defaults to False.

Returns:
UUID of the created group
"""
self.SIG_ADD_GROUP.emit(title, panel, select)
# The decorator waits for the main window slot, which sets the result
return self.result

@remote_call
def select_objects(
Expand Down
65 changes: 48 additions & 17 deletions datalab/gui/h5io.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ class H5InputOutput:

def __init__(self, mainwindow: DLMainWindow) -> None:
self.mainwindow = mainwindow
self.uint32_wng: bool = None
#: True when the user was already told, for the current opening, that
#: the provenance of appended workspaces is not imported (no repeat).
self.append_informed = False
Expand Down Expand Up @@ -273,29 +272,36 @@ def __add_object_from_node(self, node: BaseNode) -> None:
obj = node.get_native_object()
if obj is None:
return
self.uint32_wng = self.uint32_wng or node.uint32_wng
if isinstance(obj, SignalObj):
self.mainwindow.signalpanel.add_object(obj)
else:
self.mainwindow.imagepanel.add_object(obj)

def __eventually_show_warnings(self) -> None:
"""Eventually show warnings after everything is imported"""
if self.uint32_wng:
QW.QMessageBox.warning(
self.mainwindow, _("Warning"), _("Clipping uint32 data to int32.")
)

def import_files(
self, filenames: list[str], import_all: bool, reset_all: bool
) -> None:
"""Import HDF5 files"""
h5browser = H5BrowserDialog(self.mainwindow)
try:
self.__import_files_from_browser(
h5browser, filenames, import_all, reset_all
)
finally:
# Close the HDF5 files, even if an import error is raised
h5browser.cleanup()

def __import_files_from_browser(
self,
h5browser: H5BrowserDialog,
filenames: list[str],
import_all: bool,
reset_all: bool,
) -> None:
"""Open HDF5 files in browser, then import the selected datasets"""
for filename in filenames:
with qt_try_loadsave_file(self.mainwindow, filename, "load"):
h5browser.open_file(filename)
if h5browser.is_empty():
h5browser.cleanup()
if not execenv.unattended:
QW.QMessageBox.warning(
self.mainwindow,
Expand All @@ -314,27 +320,52 @@ def import_files(
if nodes is not None:
if reset_all:
self.mainwindow.reset_all()
errors: list[str] = []
with qt_try_loadsave_file(self.mainwindow, "*.h5", "load"):
with create_progress_bar(self.mainwindow, "", len(nodes)) as prog:
self.uint32_wng = False
for idx, node in enumerate(nodes):
prog.setLabelText(self.__progbartitle(node.h5file.filename))
prog.setValue(idx + 1)
QW.QApplication.processEvents()
if prog.wasCanceled():
break
self.__add_object_from_node(node)
self.__eventually_show_warnings()
h5browser.cleanup()
try:
self.__add_object_from_node(node)
except Exception as exc: # pylint: disable=broad-except
# Data of third-party files: import the other
# datasets, then report all errors at once
errors.append(f"{node.id}: {exc}")
self.__report_import_errors(errors)

def __report_import_errors(self, errors: list[str]) -> None:
"""Report the datasets that could not be imported

Args:
errors: description of each error ("dataset: reason")

Raises:
ValueError: in unattended mode, if any dataset could not be imported
"""
if not errors:
return
if execenv.unattended:
raise ValueError("\n".join(errors))
shown = errors[:10]
if len(errors) > len(shown):
shown.append(_("... and %d more") % (len(errors) - len(shown)))
QW.QMessageBox.warning(
self.mainwindow,
_("Warning"),
_("Some datasets could not be imported:") + "\n\n" + "\n".join(shown),
)

def import_dataset_from_file(self, filename: str, dsetname: str) -> None:
"""Import dataset from HDF5 file"""
h5importer = H5Importer(filename)
try:
node = h5importer.get(dsetname)
self.uint32_wng = False
self.__add_object_from_node(node)
self.__eventually_show_warnings()
except KeyError as exc:
raise KeyError(f"Dataset not found: {dsetname}") from exc
h5importer.close()
finally:
h5importer.close()
72 changes: 62 additions & 10 deletions datalab/gui/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import sys
import time
import traceback
from concurrent.futures import Future
from contextlib import ExitStack
from datetime import datetime
from typing import TYPE_CHECKING
Expand Down Expand Up @@ -75,7 +76,11 @@
from datalab.gui.panel import base, history, image, macro, signal
from datalab.gui.plugins.applications import ApplicationsDialog, get_plugin_icon
from datalab.gui.plugins.config import PluginConfigDialog
from datalab.gui.plugins.instrument import InstrumentWindow
from datalab.gui.plugins.instrument import (
InstrumentWindow,
SerialWorker,
wait_instruments_closed,
)
from datalab.gui.processor.base import FeatureNotFoundError
from datalab.gui.processor.preview import PreviewExecutorCache
from datalab.gui.settings import AI_OPTION_NAMES, edit_settings
Expand Down Expand Up @@ -172,6 +177,10 @@ def __init__(self, console=None, hide_on_close=False):
self.aiassistantpanel = None # type: ignore[assignment]
self.__applications_dialog: ApplicationsDialog | None = None
self.__instrument_windows: dict[tuple[str, str], InstrumentWindow] = {}
#: Worker of each plugin instrument, shared by its successive windows
self.__instrument_workers: dict[tuple[str, str], SerialWorker] = {}
#: Title and closing call of the last closed window of each instrument
self.__instrument_closings: dict[tuple[str, str], tuple[str, Future]] = {}
self.welcomepanel: WelcomePanel | None = None

self.signalpanel_toolbar: QW.QToolBar | None = None
Expand Down Expand Up @@ -509,15 +518,18 @@ def add_group(
title: str,
panel: Literal["signal", "image"] | None = None,
select: bool = False,
) -> None:
) -> str:
"""Add group to DataLab.

Args:
title: Group title
panel: Panel name. Defaults to None.
select: Select the group after creation. Defaults to False.

Returns:
UUID of the created group
"""
self.__get_datapanel(panel).add_group(title, select)
return get_uuid(self.__get_datapanel(panel).add_group(title, select))

@remote_controlled
def select_objects(
Expand Down Expand Up @@ -674,7 +686,9 @@ def call_method_slot(
self.remote_server.exception = exc

@remote_controlled
def run_remote_command(self, command: Callable[..., object], *args) -> None:
def run_remote_command(
self, command: Callable[..., object], *args, return_result: bool = False
) -> None:
"""Slot to run a command requested by a remote client in GUI thread.

The exception raised by the command, if any, is stored in RemoteServer to
Expand All @@ -684,9 +698,13 @@ def run_remote_command(self, command: Callable[..., object], *args) -> None:
Args:
command: Main window method running the command
args: Command arguments
return_result: If True, store the command result in RemoteServer, to be
returned to the client (it must be XML-RPC serializable)
"""
try:
command(*args)
result = command(*args)
if return_result:
self.remote_server.result = result
except Exception as exc: # pylint: disable=broad-except
self.remote_server.exception = exc

Expand Down Expand Up @@ -1143,11 +1161,24 @@ def __launch_plugin_tool(self, plugin: PluginBase, tool_id: str) -> None:
def open_plugin_instrument(
self, plugin: PluginBase, tool: PluginTool
) -> InstrumentWindow:
"""Show the instrument window of a plugin tool, creating it if needed."""
"""Show the instrument window of a plugin tool, creating it if needed.

The successive windows of an instrument share its worker: a window
opened again calls the instrument once the closed one has closed it.
"""
key = (plugin.plugin_id, tool.id)
window = self.__instrument_windows.get(key)
if window is not None and window.close_future is not None:
# Closed while hidden: Qt did not emit `finished`, release it now
self.__forget_instrument_window(key)
window = None
if window is None:
window = InstrumentWindow(self, plugin.get_instrument(tool.id), tool.title)
worker = self.__instrument_workers.get(key)
if worker is None:
worker = self.__instrument_workers[key] = SerialWorker()
window = InstrumentWindow(
self, plugin.get_instrument(tool.id), tool.title, worker
)
window.finished.connect(
functools.partial(self.__forget_instrument_window, key)
)
Expand All @@ -1158,15 +1189,33 @@ def open_plugin_instrument(
return window

def __forget_instrument_window(self, key: tuple[str, str], *_args) -> None:
"""Release a closed instrument window."""
"""Release a closed instrument window, keeping track of its instrument
until it is closed."""
window = self.__instrument_windows.pop(key, None)
if window is not None:
window.stop()
closing = (window.windowTitle(), window.close_future)
self.__instrument_closings[key] = closing
window.deleteLater()

def close_plugin_instruments(self) -> None:
"""Close all plugin instrument windows."""
"""Close all plugin instrument windows, and wait for their instruments,
including those of windows closed before, within a common time limit."""
for window in list(self.__instrument_windows.values()):
window.close()
# Windows that were not visible do not emit `finished` when closed
for key in list(self.__instrument_windows):
self.__forget_instrument_window(key)
timeout = InstrumentWindow.CLOSE_TIMEOUT
closings = self.__instrument_closings.values()
for title in wait_instruments_closed(closings, timeout):
logging.getLogger(__name__).warning(
"Instrument '%s' did not close within %g s", title, timeout
)
for worker in self.__instrument_workers.values():
worker.shutdown()
self.__instrument_workers.clear()
self.__instrument_closings.clear()

def __unregister_plugins(self) -> None:
"""Unregister all plugins and let them cleanup their hooks"""
Expand Down Expand Up @@ -2272,7 +2321,10 @@ def open_h5_files(
self,
_("Open"),
Conf.base_dir.get(),
_("HDF5 files (*.h5 *.hdf5 *.hdf *.he5);;All files (*)"),
_(
"HDF5 files (*.h5 *.hdf5 *.hdf *.he5 *.nxs *.nx5);;"
"All files (*)"
),
)
if not h5files:
return
Expand Down
4 changes: 3 additions & 1 deletion datalab/gui/panel/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,9 @@ def is_hdf5_file(filename: str, check_content: bool = False) -> bool:
True if the file is (likely) an HDF5 file, False otherwise.
"""
# First, check by extension (fast)
has_hdf5_extension = filename.lower().endswith((".h5", ".hdf5", ".hdf", ".he5"))
has_hdf5_extension = filename.lower().endswith(
(".h5", ".hdf5", ".hdf", ".he5", ".nxs", ".nx5")
)

if not check_content:
return has_hdf5_extension
Expand Down
Loading
Loading