diff --git a/datalab/gui/applications.py b/datalab/gui/applications.py index 2f3bef5c7..70f38e1ac 100644 --- a/datalab/gui/applications.py +++ b/datalab/gui/applications.py @@ -780,13 +780,17 @@ def _add_datasets(self) -> None: ) def refresh_readiness(self) -> None: - """Assess every recipe on the current selection.""" + """Assess every recipe and tool on the current selection.""" for recipe_id, card in self.recipe_cards.items(): try: card.set_readiness(self.plugin.assess_recipe(recipe_id)) except Exception as exc: # pylint: disable=broad-except # Binding suggestions are third-party code: keep the catalog usable card.set_readiness(None, str(exc)) + for tool_id, button in self.tool_buttons.items(): + issue = self.plugin.assess_tool(tool_id) + button.setEnabled(issue is None) + button.setToolTip(issue or "") def show_outcome(self, outcome: RecipeOutcome) -> None: """Show the number of created objects and the diagnostics of a run.""" diff --git a/datalab/gui/instrument.py b/datalab/gui/instrument.py new file mode 100644 index 000000000..d992f267a --- /dev/null +++ b/datalab/gui/instrument.py @@ -0,0 +1,271 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Window showing a plugin instrument: live view and settings.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from guidata.configtools import get_icon +from guidata.dataset.qtwidgets import DataSetEditLayout +from guidata.qthelpers import win32_fix_title_bar_background +from plotpy.plot import PlotOptions, PlotWidget +from qtpy import QtCore as QC +from qtpy import QtWidgets as QW +from sigima.objects import ImageObj, SignalObj +from sigimax.adapters_plotpy.objects.signal import CURVESTYLES + +from datalab.adapters_plotpy import create_adapter_from_object +from datalab.config import _ +from datalab.objectmodel import get_uuid + +if TYPE_CHECKING: + from datalab.gui.main import DLMainWindow + from datalab.plugin_instruments import ( + InstrumentAcquisition, + InstrumentFrame, + PluginInstrument, + ) + +__all__ = ["InstrumentWindow"] + + +class InstrumentWindow(QW.QDialog): + """Live view of a plugin instrument, next to its settings. + + The view is refreshed when settings change, and periodically in live mode. + Each acquisition is added to the workspace in a new group. + + Args: + main: DataLab main window + instrument: instrument opened by a plugin tool + title: window title + """ + + #: Delay between a settings change and the refresh of the view (ms) + REFRESH_DELAY = 150 + + def __init__( + self, main: DLMainWindow, instrument: PluginInstrument, title: str + ) -> None: + super().__init__(main) + win32_fix_title_bar_background(self) + self.main = main + self.instrument = instrument + self.setWindowTitle(title.rstrip(".…")) + self.setModal(False) + self.plotwidget: PlotWidget | None = None + self.items: list = [] + self._plot_kind: str | None = None + self._plot_signature: tuple | None = None + + splitter = QW.QSplitter(QC.Qt.Horizontal) + self._stage = QW.QWidget() + self._stage.setMinimumSize(360, 280) + self._stage_layout = QW.QVBoxLayout(self._stage) + self._stage_layout.setContentsMargins(0, 0, 0, 0) + self.summary_label = QW.QLabel() + self.summary_label.setWordWrap(True) + self.summary_label.setTextFormat(QC.Qt.PlainText) + self._stage_layout.addWidget(self.summary_label) + splitter.addWidget(self._stage) + + settings_widget = QW.QWidget() + settings_layout = QW.QVBoxLayout(settings_widget) + settings_layout.setContentsMargins(0, 0, 0, 0) + scroll = QW.QScrollArea() + scroll.setWidgetResizable(True) + form = QW.QWidget() + form_layout = QW.QVBoxLayout(form) + grid = QW.QGridLayout() + grid.setAlignment(QC.Qt.AlignTop) + form_layout.addLayout(grid) + form_layout.addStretch() + scroll.setWidget(form) + settings_layout.addWidget(scroll, 1) + self.status_label = QW.QLabel() + self.status_label.setWordWrap(True) + self.status_label.setTextFormat(QC.Qt.PlainText) + settings_layout.addWidget(self.status_label) + commands = QW.QHBoxLayout() + self.live_button = QW.QPushButton(get_icon("replay.svg"), _("Live")) + self.live_button.setCheckable(True) + self.live_button.setToolTip(_("Refresh the view continuously")) + self.live_button.toggled.connect(self.set_live) + commands.addWidget(self.live_button) + commands.addStretch() + self.acquire_button = QW.QPushButton(get_icon("record.svg"), _("Acquire")) + self.acquire_button.setToolTip( + _("Add an acquisition to the workspace, in a new group") + ) + self.acquire_button.clicked.connect(self.acquire) + commands.addWidget(self.acquire_button) + settings_layout.addLayout(commands) + splitter.addWidget(settings_widget) + + self._refresh_timer = QC.QTimer(self) + self._refresh_timer.setSingleShot(True) + self._refresh_timer.timeout.connect(self.refresh) + self._live_timer = QC.QTimer(self) + self._live_timer.setInterval(max(50, int(instrument.live_interval_ms))) + self._live_timer.timeout.connect(self.refresh) + self.finished.connect(self.stop) + + self.edit_layout = DataSetEditLayout( + self, + instrument.settings, + grid, + change_callback=self.settings_changed, + auto_sliders=True, + ) + + buttons = QW.QDialogButtonBox(QW.QDialogButtonBox.Close) + buttons.rejected.connect(self.reject) + layout = QW.QVBoxLayout(self) + layout.addWidget(splitter, 1) + layout.addWidget(buttons) + + self.resize(1100, 650) + screen = self.screen().availableGeometry() + self.resize( + min(self.width(), screen.width()), min(self.height(), screen.height()) + ) + splitter.setSizes([650, 450]) + self._refresh_timer.start(0) + + def child_title(self, item) -> str: + """Supply the usual guidata title for nested editors.""" + return f"{self.windowTitle()} - {item.label()}" + + def settings_changed(self) -> None: + """Refresh the view shortly after the settings changed.""" + self._refresh_timer.start(self.REFRESH_DELAY) + + def set_live(self, live: bool) -> None: + """Start or stop refreshing the view continuously.""" + if live: + self._live_timer.start() + else: + self._live_timer.stop() + + def stop(self) -> None: + """Stop refreshing the view.""" + self._refresh_timer.stop() + self._live_timer.stop() + + def _apply_settings(self) -> bool: + """Write the edited values into the instrument settings.""" + if not self.edit_layout.check_all_values(): + self.status_label.setText(_("Invalid settings")) + return False + self.edit_layout.accept_changes() + return True + + def refresh(self) -> None: + """Show a live frame for the current settings.""" + if not self._apply_settings(): + return + try: + frame = self.instrument.preview() + self.render(frame) + except Exception as exc: # pylint: disable=broad-except + # Instruments are third-party code: keep the window usable + self.status_label.setText(str(exc)) + return + self.status_label.clear() + + def render(self, frame: InstrumentFrame) -> None: + """Draw a frame in the view.""" + first = frame.objects[0] + kind = "image" if isinstance(first, ImageObj) else "curve" + if self._plot_kind != kind: + if self.plotwidget is not None: + self._stage_layout.removeWidget(self.plotwidget) + self.plotwidget.deleteLater() + self.plotwidget = PlotWidget(self._stage, options=PlotOptions(type=kind)) + self._stage_layout.insertWidget(0, self.plotwidget, 1) + self._plot_kind = kind + self._plot_signature = None + self.items = [] + plot = self.plotwidget.plot + generator = CURVESTYLES.curve_style + try: + CURVESTYLES.curve_style = CURVESTYLES.style_generator() + items = [ + create_adapter_from_object(obj).make_item() for obj in frame.objects + ] + finally: + CURVESTYLES.curve_style = generator + if self.items: + plot.del_items(self.items) + self.items = items + for item in items: + plot.add_item(item) + item.unselect() + plot.set_titles( + title=first.title if kind == "image" else "", + xlabel=first.xlabel, + xunit=first.xunit, + ylabel=(first.ylabel, getattr(first, "zlabel", "")), + yunit=(first.yunit, getattr(first, "zunit", "")), + ) + if kind == "image": + signature = (kind, first.data.shape, first.x0, first.y0, first.dx, first.dy) + if frame.value_range is not None: + items[0].set_lut_range(frame.value_range) + plot.update_colormap_axis(items[0]) + else: + signature = ( + kind, + tuple( + (obj.x.size, obj.x[0], obj.x[-1]) if obj.x.size else (0,) + for obj in frame.objects + ), + ) + if signature != self._plot_signature: + plot.do_autoscale(replot=False) + if kind == "curve" and frame.value_range is not None: + plot.set_axis_limits("left", *frame.value_range) + self._plot_signature = signature + self.summary_label.setText(frame.summary) + plot.replot() + + def acquire(self) -> None: + """Add an acquisition to the workspace, in a new group.""" + if not self._apply_settings(): + return + live = self._live_timer.isActive() + self._live_timer.stop() + try: + acquisition = self.instrument.acquire() + self.add_acquisition(acquisition) + except Exception as exc: # pylint: disable=broad-except + # Instruments are third-party code: keep the window usable + self.status_label.setText(str(exc)) + return + finally: + if live: + self._live_timer.start() + self.status_label.setText( + _("%d objects added to group '%s'") + % (len(acquisition.objects), acquisition.group_title) + ) + + def add_acquisition(self, acquisition: InstrumentAcquisition) -> None: + """Add acquired objects to their panel, in a new group.""" + objects = acquisition.objects + panel_name = "signal" if isinstance(objects[0], SignalObj) else "image" + panel = ( + self.main.signalpanel if panel_name == "signal" else self.main.imagepanel + ) + with self.main.context_no_refresh(): + group = panel.add_group(acquisition.group_title) + try: + # pylint: disable=protected-access + panel._add_objects(objects, get_uuid(group)) + except Exception: + panel.objview.remove_item(get_uuid(group), refresh=False) + panel.objmodel.remove_group(group) + raise + self.main.set_current_panel(panel_name) + panel.objview.select_objects(objects) diff --git a/datalab/gui/main.py b/datalab/gui/main.py index ee05be3ed..e04176617 100644 --- a/datalab/gui/main.py +++ b/datalab/gui/main.py @@ -59,7 +59,7 @@ from datalab.control.remote import RemoteServer from datalab.env import execenv from datalab.gui.actionhandler import ActionCategory -from datalab.gui.applications import ApplicationsDialog +from datalab.gui.applications import ApplicationsDialog, get_plugin_icon from datalab.gui.commandpalette import ( CommandPaletteDialog, CommandSearchField, @@ -67,6 +67,7 @@ ) from datalab.gui.docks import DockablePlotWidget from datalab.gui.h5io import H5InputOutput +from datalab.gui.instrument import InstrumentWindow from datalab.gui.panel import base, history, image, macro, signal from datalab.gui.pluginconfig import PluginConfigDialog from datalab.gui.processor.base import FeatureNotFoundError @@ -74,6 +75,7 @@ from datalab.gui.settings import AI_OPTION_NAMES, edit_settings from datalab.gui.welcome import WelcomePanel from datalab.objectmodel import ObjectGroup, get_uuid +from datalab.plugin_tools import tool_accepts_selection from datalab.plugins import ( PluginRegistry, discover_plugins, @@ -81,7 +83,10 @@ migrate_enabled_plugin_ids, ) from datalab.utils import qthelpers as qth -from datalab.utils.qthelpers import configure_menu_about_to_show +from datalab.utils.qthelpers import ( + configure_menu_about_to_show, + qt_handle_error_message, +) from datalab.webapi import WEBAPI_AVAILABLE, get_webapi_controller from datalab.webapi.actions import WebApiActions from datalab.widgets import instconfviewer @@ -95,6 +100,7 @@ from datalab.gui.panel.image import ImagePanel from datalab.gui.panel.macro import MacroPanel from datalab.gui.panel.signal import SignalPanel + from datalab.plugin_tools import PluginTool from datalab.plugins import PluginBase @@ -145,6 +151,7 @@ def __init__(self, console=None, hide_on_close=False): self.historypanel: history.HistoryPanel | None = None self.aiassistantpanel = None # type: ignore[assignment] self.__applications_dialog: ApplicationsDialog | None = None + self.__instrument_windows: dict[tuple[str, str], InstrumentWindow] = {} self.welcomepanel: WelcomePanel | None = None self.signalpanel_toolbar: QW.QToolBar | None = None @@ -960,10 +967,91 @@ def __create_plugins_actions(self) -> None: plugin.register_computations() plugin.create_actions() rollback_stack.pop_all() + with sgmx_qth.try_or_log_error( + f"Create tool actions for {plugin.info.name}" + ): + self.__create_plugin_tool_actions(plugin) + + def __create_plugin_tool_actions(self, plugin: PluginBase) -> None: + """Add the plugin's tools to its submenu of the *Plugins* menu + + Each tool is listed in the panel of its object type (both panels when + it has none), after the plugin's own actions. + """ + title = plugin.info.name.replace("&", "&&") + for panel in (self.signalpanel, self.imagepanel): + tools = [ + tool + for tool in plugin.get_tools() + if tool.object_type is None + or tool.object_type.value == panel.PANEL_STR_ID + ] + if not tools: + continue + handler = panel.acthandler + with handler.new_menu(title): + for index, tool in enumerate(tools): + action = handler.new_action( + tool.title, + separator=index == 0, + triggered=functools.partial( + self.__launch_plugin_tool, plugin, tool.id + ), + tip=tool.description or None, + select_condition=functools.partial( + self.__tool_selection_condition, tool + ), + ) + action.setIcon(get_plugin_icon(tool.icon)) @staticmethod - def __unregister_plugins() -> None: + def __tool_selection_condition( + tool: PluginTool, + _selected_groups: list[ObjectGroup], + selected_objects: list, + ) -> bool: + """Return True if a tool's menu action is enabled on the selection.""" + return tool_accepts_selection(tool, selected_objects) + + def __launch_plugin_tool(self, plugin: PluginBase, tool_id: str) -> None: + """Open a plugin tool from the menu, reporting its failures.""" + try: + plugin.launch_tool(tool_id) + except Exception as exc: # pylint: disable=broad-except + # Plugin-owned tools are third-party code: never crash the app + qt_handle_error_message(self, exc, _("Opening tool '%s'") % tool_id) + + def open_plugin_instrument( + self, plugin: PluginBase, tool: PluginTool + ) -> InstrumentWindow: + """Show the instrument window of a plugin tool, creating it if needed.""" + key = (plugin.plugin_id, tool.id) + window = self.__instrument_windows.get(key) + if window is None: + window = InstrumentWindow(self, plugin.get_instrument(tool.id), tool.title) + window.finished.connect( + functools.partial(self.__forget_instrument_window, key) + ) + self.__instrument_windows[key] = window + window.show() + window.raise_() + window.activateWindow() + return window + + def __forget_instrument_window(self, key: tuple[str, str], *_args) -> None: + """Release a closed instrument window.""" + window = self.__instrument_windows.pop(key, None) + if window is not None: + window.deleteLater() + + def close_plugin_instruments(self) -> None: + """Close all plugin instrument windows.""" + for window in list(self.__instrument_windows.values()): + window.close() + + def __unregister_plugins(self) -> None: """Unregister all plugins and let them cleanup their hooks""" + self.close_plugin_instruments() with sgmx_qth.try_or_log_error("Unregistering plugins"): PluginRegistry.unregister_all_plugins() diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index 39427179b..ed6c84e1a 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -4596,3 +4596,48 @@ msgstr "inchangé" msgid "To set it, use Edit > Metadata > Add metadata..." msgstr "Pour la définir, utilisez Édition > Métadonnées > Ajouter des métadonnées..." + +msgid "Select one signal" +msgstr "Sélectionnez un signal" + +msgid "Select one image" +msgstr "Sélectionnez une image" + +msgid "Select one object" +msgstr "Sélectionnez un objet" + +msgid "Select at least one signal" +msgstr "Sélectionnez au moins un signal" + +msgid "Select at least one image" +msgstr "Sélectionnez au moins une image" + +msgid "Select at least one object" +msgstr "Sélectionnez au moins un objet" + +msgid "Select at least two signals" +msgstr "Sélectionnez au moins deux signaux" + +msgid "Select at least two images" +msgstr "Sélectionnez au moins deux images" + +msgid "Select at least two objects" +msgstr "Sélectionnez au moins deux objets" + +msgid "Live" +msgstr "En direct" + +msgid "Refresh the view continuously" +msgstr "Rafraîchir la vue en continu" + +msgid "Acquire" +msgstr "Acquérir" + +msgid "Add an acquisition to the workspace, in a new group" +msgstr "Ajouter une acquisition à l'espace de travail, dans un nouveau groupe" + +msgid "Invalid settings" +msgstr "Réglages non valides" + +msgid "%d objects added to group '%s'" +msgstr "%d objets ajoutés au groupe '%s'" diff --git a/datalab/plugin_instruments.py b/datalab/plugin_instruments.py new file mode 100644 index 000000000..660391d9f --- /dev/null +++ b/datalab/plugin_instruments.py @@ -0,0 +1,115 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Instruments exposed by application plugins as tools. + +An instrument is a headless object: it holds settings (a guidata DataSet), +returns a live frame for these settings, and acquires objects. DataLab shows +it in a window with the live view on the left and the settings on the right. +DataLab Desktop and DataLab-Web share this module. +""" + +from __future__ import annotations + +import abc +import dataclasses +import math +from collections.abc import Sequence + +import guidata.dataset as gds +from sigima.objects import ImageObj, SignalObj + +__all__ = ["InstrumentAcquisition", "InstrumentFrame", "PluginInstrument"] + + +def _validate_objects(objects: Sequence[SignalObj | ImageObj], name: str) -> tuple: + """Return objects as a tuple of signals or of images.""" + objects = tuple(objects) + if not objects: + raise ValueError(f"{name} must contain at least one object") + if not ( + all(isinstance(obj, SignalObj) for obj in objects) + or all(isinstance(obj, ImageObj) for obj in objects) + ): + raise TypeError(f"{name} must contain signals only or images only") + return objects + + +@dataclasses.dataclass(frozen=True) +class InstrumentFrame: + """Live frame shown by DataLab for the current instrument settings. + + Args: + objects: signals drawn together, or a single image + summary: short text shown below the view (e.g. measured levels) + value_range: fixed Y range of signals, or color range of the image + (``None``: automatic) + """ + + objects: Sequence[SignalObj | ImageObj] + summary: str = "" + value_range: tuple[float, float] | None = None + + def __post_init__(self) -> None: + """Validate the frame contents.""" + objects = _validate_objects(self.objects, "Instrument frame") + if isinstance(objects[0], ImageObj) and len(objects) > 1: + raise ValueError("Instrument frame must contain a single image") + object.__setattr__(self, "objects", objects) + if not isinstance(self.summary, str): + raise TypeError("Instrument frame summary must be a string") + if self.value_range is not None: + low, high = (float(value) for value in self.value_range) + if not (math.isfinite(low) and math.isfinite(high) and low < high): + raise ValueError("Instrument frame range must be finite and increasing") + object.__setattr__(self, "value_range", (low, high)) + + +@dataclasses.dataclass(frozen=True) +class InstrumentAcquisition: + """Objects acquired by an instrument, added by DataLab in a new group. + + Args: + group_title: title of the group created for the acquisition + objects: acquired signals, or acquired images + """ + + group_title: str + objects: Sequence[SignalObj | ImageObj] + + def __post_init__(self) -> None: + """Validate the acquisition contents.""" + if not isinstance(self.group_title, str) or not self.group_title.strip(): + raise ValueError("Instrument acquisition group title must not be empty") + object.__setattr__( + self, "objects", _validate_objects(self.objects, "Instrument acquisition") + ) + + +class PluginInstrument(abc.ABC): + """Instrument opened by a plugin tool. + + DataLab writes the edited values into :attr:`settings` before calling + :meth:`preview` or :meth:`acquire`. Both methods raise ``ValueError`` + with a user-facing message when the settings cannot be used. The plugin + keeps one instance per tool during the session, so settings are kept + from one opening to the next. + + Args: + settings: instrument settings, edited by DataLab + """ + + #: Interval between two frames in live mode, in milliseconds + live_interval_ms: int = 500 + + def __init__(self, settings: gds.DataSet) -> None: + if not isinstance(settings, gds.DataSet): + raise TypeError("Instrument settings must be a guidata DataSet") + self.settings = settings + + @abc.abstractmethod + def preview(self) -> InstrumentFrame: + """Return a live frame for the current settings.""" + + @abc.abstractmethod + def acquire(self) -> InstrumentAcquisition: + """Acquire objects with the current settings.""" diff --git a/datalab/plugin_tools.py b/datalab/plugin_tools.py index 288460c3d..718d7de04 100644 --- a/datalab/plugin_tools.py +++ b/datalab/plugin_tools.py @@ -1,14 +1,31 @@ # Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. -"""Declarations of plugin-owned tools listed in the Applications catalog.""" +"""Declarations of plugin-owned tools listed in the Applications catalog. + +DataLab Desktop and DataLab-Web share this module. +""" from __future__ import annotations import dataclasses +import enum +from collections.abc import Sequence + +from sigima.objects import ImageObj, SignalObj from datalab.plugin_resources import LOCAL_ID_PATTERN, split_package_resource +from datalab.recipes import RecipeObjectType + +__all__ = ["PluginTool", "ToolSelection", "tool_accepts_selection"] -__all__ = ["PluginTool"] + +class ToolSelection(str, enum.Enum): + """Selection a tool needs to be opened (names follow DataLab's conditions).""" + + NONE = "none" + EXACTLY_ONE = "exactly_one" + AT_LEAST_ONE = "at_least_one" + AT_LEAST_TWO = "at_least_two" @dataclasses.dataclass(frozen=True) @@ -17,7 +34,13 @@ class PluginTool: Recipes cover headless analyses run by DataLab; a tool covers any other interaction owned by the plugin (wizard, interactive editor, data - preparation...). Tools are a Desktop feature. + preparation, instrument...). DataLab lists tools in the Applications + catalog and in the plugin's submenu of the *Plugins* menu. + + A tool is opened either by a ``launcher`` method, called without + arguments, or by an ``instrument`` method returning a + :class:`datalab.plugin_instruments.PluginInstrument`, which DataLab shows + in a window with a live view and the instrument settings. Args: id: plugin-local tool ID (lowercase letters, digits, ``.``, ``_``, ``-``) @@ -27,24 +50,38 @@ class PluginTool: description: short description of what the tool does icon: ``package:path`` resource (SVG or bitmap) or DataLab icon file name; ``None`` falls back to the plugin icon + instrument: name of the plugin method returning the instrument shown + by DataLab (exclusive with ``launcher``) + object_type: type of the objects the tool works on or creates; the + tool is listed in the menu of that panel (``None``: both panels) + selection: selection needed to open the tool, counting objects of + ``object_type`` only """ id: str title: str - launcher: str + launcher: str | None = None description: str = "" icon: str | None = None + instrument: str | None = None + object_type: RecipeObjectType | None = None + selection: ToolSelection = ToolSelection.NONE def __post_init__(self) -> None: - """Validate identity, launcher name, and icon resource.""" + """Validate identity, opening method, selection, and icon resource.""" if not isinstance(self.id, str) or not LOCAL_ID_PATTERN.fullmatch(self.id): raise ValueError( "Plugin tool ID must contain lowercase letters, digits, '.', '_' or '-'" ) if not isinstance(self.title, str) or not self.title.strip(): raise ValueError("Plugin tool title must be a non-empty string") - if not isinstance(self.launcher, str) or not self.launcher.isidentifier(): - raise ValueError("Plugin tool launcher must be a method name") + if (self.launcher is None) == (self.instrument is None): + raise ValueError("Plugin tool needs either a launcher or an instrument") + for method_name in (self.launcher, self.instrument): + if method_name is not None and ( + not isinstance(method_name, str) or not method_name.isidentifier() + ): + raise ValueError("Plugin tool launcher must be a method name") if not isinstance(self.description, str): raise TypeError("Plugin tool description must be a string") if self.icon is not None: @@ -52,3 +89,33 @@ def __post_init__(self) -> None: raise ValueError("Plugin tool icon must be a non-empty string") if ":" in self.icon: split_package_resource(self.icon, "Plugin tool icon") + if self.object_type is not None: + object.__setattr__(self, "object_type", RecipeObjectType(self.object_type)) + object.__setattr__(self, "selection", ToolSelection(self.selection)) + + +def tool_accepts_selection( + tool: PluginTool, objects: Sequence[SignalObj | ImageObj] +) -> bool: + """Return True if the selected objects allow opening the tool. + + Args: + tool: tool to open + objects: selected objects + + Returns: + True if the number of selected objects of the tool's object type + matches the tool's selection + """ + object_class = { + RecipeObjectType.SIGNAL: SignalObj, + RecipeObjectType.IMAGE: ImageObj, + None: (SignalObj, ImageObj), + }[tool.object_type] + count = sum(isinstance(obj, object_class) for obj in objects) + return { + ToolSelection.NONE: True, + ToolSelection.EXACTLY_ONE: count == 1, + ToolSelection.AT_LEAST_ONE: count >= 1, + ToolSelection.AT_LEAST_TWO: count >= 2, + }[tool.selection] diff --git a/datalab/plugins.py b/datalab/plugins.py index 237bb130d..e7bbf3452 100644 --- a/datalab/plugins.py +++ b/datalab/plugins.py @@ -58,8 +58,9 @@ from datalab.env import execenv from datalab.objectmodel import get_uuid from datalab.plugin_examples import PluginExample, PluginExampleData +from datalab.plugin_instruments import PluginInstrument from datalab.plugin_tiles import WelcomeTile -from datalab.plugin_tools import PluginTool +from datalab.plugin_tools import PluginTool, ToolSelection, tool_accepts_selection from datalab.recipe_binding import ( RecipeReadiness, assess_recipe_inputs, @@ -300,6 +301,23 @@ def __post_init__(self) -> None: raise ValueError("Plugin documentation URL must use HTTP or HTTPS") +def format_tool_requirement(tool: PluginTool) -> str: + """Return the message telling which objects to select to open a tool.""" + object_type = None if tool.object_type is None else tool.object_type.value + messages = { + (ToolSelection.EXACTLY_ONE, "signal"): _("Select one signal"), + (ToolSelection.EXACTLY_ONE, "image"): _("Select one image"), + (ToolSelection.EXACTLY_ONE, None): _("Select one object"), + (ToolSelection.AT_LEAST_ONE, "signal"): _("Select at least one signal"), + (ToolSelection.AT_LEAST_ONE, "image"): _("Select at least one image"), + (ToolSelection.AT_LEAST_ONE, None): _("Select at least one object"), + (ToolSelection.AT_LEAST_TWO, "signal"): _("Select at least two signals"), + (ToolSelection.AT_LEAST_TWO, "image"): _("Select at least two images"), + (ToolSelection.AT_LEAST_TWO, None): _("Select at least two objects"), + } + return messages.get((tool.selection, object_type), "") + + class PluginBaseMeta(PluginRegistry, abc.ABCMeta): """Mixed metaclass to avoid conflicts""" @@ -322,6 +340,7 @@ def __init__(self): self.main: main.DLMainWindow = None self.proxy: LocalProxy = None self._is_registered = False + self._instruments: dict[str, PluginInstrument] = {} self.info = self.PLUGIN_INFO if self.info is None: raise ValueError(f"Plugin info not set for {self.__class__.__name__}") @@ -521,9 +540,11 @@ def get_tools(cls) -> tuple[PluginTool, ...]: for tool in tools: if tool.id in tool_ids: raise ValueError(f"Duplicate plugin tool ID: {tool.id!r}") - if not callable(getattr(cls, tool.launcher, None)): + method_name = tool.launcher or tool.instrument + if not callable(getattr(cls, method_name, None)): + kind = "launcher" if tool.launcher else "instrument" raise ValueError( - f"Plugin tool launcher method {tool.launcher!r} is not callable" + f"Plugin tool {kind} method {method_name!r} is not callable" ) tool_ids.add(tool.id) return tuple( @@ -533,14 +554,65 @@ def get_tools(cls) -> tuple[PluginTool, ...]: for tool in tools ) + @classmethod + def get_tool(cls, tool_id: str) -> PluginTool: + """Return one tool by its plugin-local ID.""" + for tool in cls.get_tools(): + if tool.id == tool_id: + return tool + raise KeyError(f"Plugin tool {tool_id!r} not found") + + def assess_tool( + self, + tool_id: str, + objects: Sequence[SignalObj | ImageObj] | None = None, + ) -> str | None: + """Return why a tool cannot be opened on objects (default: selection). + + Returns: + Message telling which objects to select, or None if the tool + can be opened + """ + tool = self.get_tool(tool_id) + if tool.selection is ToolSelection.NONE: + return None + if objects is None: + objects = self.get_selected_objects() + if tool_accepts_selection(tool, objects): + return None + return format_tool_requirement(tool) + def launch_tool(self, tool_id: str) -> object: - """Open a plugin tool by calling its launcher method.""" + """Open a plugin tool: call its launcher, or show its instrument. + + Raises: + ValueError: if the selection does not allow opening the tool + """ if self.main is None: raise RuntimeError("Plugin must be registered before launching a tool") - tool = next((tool for tool in self.get_tools() if tool.id == tool_id), None) - if tool is None: - raise KeyError(f"Plugin tool {tool_id!r} not found") - return getattr(self, tool.launcher)() + tool = self.get_tool(tool_id) + issue = self.assess_tool(tool_id) + if issue is not None: + raise ValueError(issue) + if tool.launcher is not None: + return getattr(self, tool.launcher)() + return self.main.open_plugin_instrument(self, tool) + + def get_instrument(self, tool_id: str) -> PluginInstrument: + """Return the instrument of a tool, created once per registration.""" + tool = self.get_tool(tool_id) + if tool.instrument is None: + raise ValueError(f"Plugin tool {tool_id!r} has no instrument") + instruments = self._instruments + if tool_id not in instruments: + instrument = getattr(self, tool.instrument)() + if not isinstance(instrument, PluginInstrument): + raise TypeError( + f"Plugin tool instrument method {tool.instrument!r} must " + "return a PluginInstrument" + ) + instruments[tool_id] = instrument + return instruments[tool_id] @classmethod def get_examples(cls) -> tuple[PluginExample, ...]: @@ -871,6 +943,7 @@ def _reset_registration_state(self) -> None: self._is_registered = False self.main = None self.proxy = None + self._instruments.clear() def register(self, main: main.DLMainWindow) -> None: """Register plugin""" diff --git a/datalab/tests/backbone/plugin_tiles_unit_test.py b/datalab/tests/backbone/plugin_tiles_unit_test.py index 3876c39ac..8d29b4b1c 100644 --- a/datalab/tests/backbone/plugin_tiles_unit_test.py +++ b/datalab/tests/backbone/plugin_tiles_unit_test.py @@ -9,12 +9,26 @@ import zipfile from types import SimpleNamespace +import guidata.dataset as gds +import numpy as np import pytest +from sigima.objects import create_image, create_signal +from datalab.plugin_instruments import ( + InstrumentAcquisition, + InstrumentFrame, + PluginInstrument, +) from datalab.plugin_resources import resolve_package_resource from datalab.plugin_tiles import WelcomeTile -from datalab.plugin_tools import PluginTool -from datalab.plugins import PluginBase, PluginCapability, PluginInfo, PluginRegistry +from datalab.plugin_tools import PluginTool, ToolSelection, tool_accepts_selection +from datalab.plugins import ( + PluginBase, + PluginCapability, + PluginInfo, + PluginRegistry, + format_tool_requirement, +) PLUGIN_ICON = "datalab:data/icons/libre-gui-plugin.svg" @@ -160,9 +174,13 @@ def create_actions(self) -> None: ({"id": "Invalid ID"}, "tool ID"), ({"title": " "}, "title"), ({"launcher": "open tool"}, "method name"), + ({"launcher": None}, "either a launcher or an instrument"), + ({"instrument": "open_instrument"}, "either a launcher or an instrument"), ({"description": None}, "description"), ({"icon": ""}, "icon"), ({"icon": "datalab:/absolute.svg"}, "relative"), + ({"object_type": "curve"}, "curve"), + ({"selection": "some"}, "some"), ], ) def test_plugin_tool_rejects_invalid_declarations(kwargs, message) -> None: @@ -173,9 +191,54 @@ def test_plugin_tool_rejects_invalid_declarations(kwargs, message) -> None: PluginTool(**values) +@pytest.mark.parametrize( + ("selection", "object_type", "counts", "accepted"), + [ + ("none", "image", (0, 0), True), + ("exactly_one", "image", (0, 1), True), + ("exactly_one", "image", (3, 2), False), + ("at_least_one", "signal", (1, 0), True), + ("at_least_one", "signal", (0, 3), False), + ("at_least_two", None, (1, 1), True), + ("at_least_two", "image", (2, 1), False), + ], +) +def test_tool_selection_counts_objects_of_the_tool_type( + selection, object_type, counts, accepted +) -> None: + """Tools count the selected objects of their own type only.""" + tool = PluginTool( + id="tool", + title="Tool", + launcher="open_tool", + object_type=object_type, + selection=selection, + ) + assert tool.selection is ToolSelection(selection) + signal_count, image_count = counts + objects = [ + create_signal("S", np.arange(3.0), np.arange(3.0)) + for _index in range(signal_count) + ] + [create_image("I", np.zeros((2, 2))) for _index in range(image_count)] + assert tool_accepts_selection(tool, objects) is accepted + + def test_plugin_validates_and_launches_tools() -> None: """Application plugins expose validated, plugin-owned tools.""" + class Instrument(PluginInstrument): + """Instrument returning fixed frames.""" + + def preview(self) -> InstrumentFrame: + """Return a single-point signal.""" + return InstrumentFrame((create_signal("Live", [0.0], [1.0]),)) + + def acquire(self) -> InstrumentAcquisition: + """Return a single-point signal.""" + return InstrumentAcquisition( + "Acquisition", (create_signal("Shot", [0.0], [1.0]),) + ) + class ToolPlugin(PluginBase): """Application plugin used to check tool contracts.""" @@ -186,32 +249,75 @@ class ToolPlugin(PluginBase): icon=PLUGIN_ICON, capabilities=(PluginCapability.APPLICATION,), ) - TOOLS = (PluginTool(id="annotate", title="Annotate", launcher="annotate"),) + TOOLS = ( + PluginTool(id="annotate", title="Annotate", launcher="annotate"), + PluginTool( + id="inspect", + title="Inspect", + launcher="annotate", + object_type="image", + selection="at_least_two", + ), + PluginTool(id="generator", title="Generator", instrument="generator"), + ) def annotate(self) -> str: """Return a marker instead of opening a tool.""" return "tool opened" + def generator(self) -> PluginInstrument: + """Return a new instrument.""" + return Instrument(gds.DataSet()) + def create_actions(self) -> None: """Create no actions for this contract test.""" try: - (tool,) = ToolPlugin.get_tools() + tool, inspect, generator = ToolPlugin.get_tools() assert tool.icon == PLUGIN_ICON + assert ToolPlugin.get_tool("generator") == generator plugin = ToolPlugin() with pytest.raises(RuntimeError, match="registered"): plugin.launch_tool("annotate") - plugin.main = SimpleNamespace() + opened: list[tuple[object, PluginTool]] = [] + plugin.main = SimpleNamespace( + open_plugin_instrument=lambda *args: opened.append(args) or "window" + ) + plugin.get_selected_objects = lambda: [] assert plugin.launch_tool("annotate") == "tool opened" with pytest.raises(KeyError, match="missing"): plugin.launch_tool("missing") + assert plugin.assess_tool("inspect") == format_tool_requirement(inspect) + assert plugin.assess_tool("inspect") == "Select at least two images" + with pytest.raises(ValueError, match="Select at least two images"): + plugin.launch_tool("inspect") + images = [create_image("I", np.zeros((2, 2))) for _index in range(2)] + assert plugin.assess_tool("inspect", images) is None + + assert plugin.launch_tool("generator") == "window" + assert opened == [(plugin, generator)] + instrument = plugin.get_instrument("generator") + assert plugin.get_instrument("generator") is instrument + with pytest.raises(ValueError, match="no instrument"): + plugin.get_instrument("annotate") + plugin._reset_registration_state() # pylint: disable=protected-access + assert plugin.get_instrument("generator") is not instrument + + ToolPlugin.generator = lambda self: "not an instrument" + with pytest.raises(TypeError, match="PluginInstrument"): + ToolPlugin().get_instrument("generator") for tools, error, message in ( ((tool, tool), ValueError, "Duplicate plugin tool ID"), ( (PluginTool(id="broken", title="Broken", launcher="missing"),), ValueError, - "not callable", + "launcher method 'missing' is not callable", + ), + ( + (PluginTool(id="broken", title="Broken", instrument="missing"),), + ValueError, + "instrument method 'missing' is not callable", ), (("not a tool",), TypeError, "PluginTool"), ): @@ -229,3 +335,26 @@ def create_actions(self) -> None: ToolPlugin.get_tools() finally: PluginRegistry.get_plugin_classes().remove(ToolPlugin) + + +def test_instrument_frames_and_acquisitions_are_validated() -> None: + """Frames hold signals or one image; acquisitions hold one object type.""" + signal = create_signal("S", [0.0, 1.0], [0.0, 1.0]) + image = create_image("I", np.zeros((2, 2))) + frame = InstrumentFrame([signal, signal], value_range=(0, 1)) + assert frame.objects == (signal, signal) + assert frame.value_range == (0.0, 1.0) + for kwargs, error, message in ( + ({"objects": ()}, ValueError, "at least one"), + ({"objects": (signal, image)}, TypeError, "signals only or images only"), + ({"objects": (image, image)}, ValueError, "single image"), + ({"objects": (signal,), "value_range": (1, 0)}, ValueError, "increasing"), + ({"objects": (signal,), "summary": None}, TypeError, "summary"), + ): + with pytest.raises(error, match=message): + InstrumentFrame(**kwargs) + assert InstrumentAcquisition("Frames", [image, image]).objects == (image, image) + with pytest.raises(ValueError, match="group title"): + InstrumentAcquisition(" ", (image,)) + with pytest.raises(TypeError, match="DataSet"): + PluginInstrument.__init__(SimpleNamespace(), settings=object()) diff --git a/datalab/tests/features/plugins/applications_dialog_test.py b/datalab/tests/features/plugins/applications_dialog_test.py index 964eefbce..ba81dd0af 100644 --- a/datalab/tests/features/plugins/applications_dialog_test.py +++ b/datalab/tests/features/plugins/applications_dialog_test.py @@ -114,6 +114,7 @@ def _plugin( get_examples=lambda: examples, get_tools=lambda: tools, assess_recipe=lambda recipe_id: readiness, + assess_tool=lambda tool_id: None, launch_recipe=lambda recipe_id: calls.append(("recipe", recipe_id)), try_example=lambda example_id, recipe_id: calls.append( ("try", example_id, recipe_id) @@ -195,6 +196,39 @@ def test_applications_dialog_filters_and_renders_declared_contracts() -> None: QW.QApplication.processEvents() +def test_tool_buttons_follow_the_tool_selection() -> None: + """A tool that cannot be opened on the selection has a disabled button.""" + QW.QApplication.instance() or QW.QApplication([]) + issues = {"annotate": "Select one image"} + application = _plugin( + "org.example.camera", + "Camera Characterization", + frozenset({PluginCapability.APPLICATION}), + tools=(TOOL,), + ) + application.assess_tool = issues.get + registry = PluginRegistry.get_plugins() + previous_plugins = list(registry) + registry[:] = [application] + try: + dialog = ApplicationsDialog() + dialog.show() + QW.QApplication.processEvents() + button = dialog.application_pages[0].tool_buttons[TOOL.id] + assert not button.isEnabled() + assert button.toolTip() == "Select one image" + issues.clear() + dialog.refresh_readiness() + assert button.isEnabled() + assert button.toolTip() == "" + finally: + registry[:] = previous_plugins + if "dialog" in locals(): + dialog.close() + dialog.deleteLater() + QW.QApplication.processEvents() + + def test_applications_dialog_has_an_explicit_empty_state() -> None: """An empty registry produces a stable catalog placeholder.""" qt_app = QW.QApplication.instance() or QW.QApplication([]) diff --git a/datalab/tests/features/plugins/plugin_tools_app_test.py b/datalab/tests/features/plugins/plugin_tools_app_test.py new file mode 100644 index 000000000..69990669b --- /dev/null +++ b/datalab/tests/features/plugins/plugin_tools_app_test.py @@ -0,0 +1,198 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Application test for plugin tools: Plugins menu entries and instruments.""" + +# guitest: show + +from __future__ import annotations + +import textwrap + +import numpy as np +import pytest +from qtpy import QtWidgets as QW +from sigima.objects import create_image + +from datalab.config import Conf +from datalab.gui.instrument import InstrumentWindow +from datalab.gui.main import DLMainWindow +from datalab.plugins import PluginRegistry +from datalab.tests import datalab_test_app_context +from datalab.tests.features.plugins.plugin_test_dataset import temporary_plugin_dir + +PLUGIN_SOURCE = textwrap.dedent( + ''' + """Application plugin declaring a launcher tool and an instrument tool.""" + + import guidata.dataset as gds + import numpy as np + from sigima.objects import create_signal + + from datalab.plugin_instruments import ( + InstrumentAcquisition, + InstrumentFrame, + PluginInstrument, + ) + from datalab.plugin_tools import PluginTool + from datalab.plugins import PluginBase, PluginCapability, PluginInfo + + + class GeneratorSettings(gds.DataSet): + """Signal generator settings.""" + + amplitude = gds.FloatItem("Amplitude", default=1.0) + count = gds.IntItem("Shots", default=3, min=1) + + + class SignalGenerator(PluginInstrument): + """Instrument generating ramps.""" + + live_interval_ms = 100 + + def __init__(self): + super().__init__(GeneratorSettings()) + self.frames = 0 + + def preview(self): + self.frames += 1 + x = np.linspace(0.0, 1.0, 11) + signal = create_signal("Live", x, self.settings.amplitude * x) + return InstrumentFrame( + (signal,), summary=f"Frame {self.frames}", value_range=(-2.0, 2.0) + ) + + def acquire(self): + x = np.linspace(0.0, 1.0, 11) + return InstrumentAcquisition( + "Generator acquisition", + [ + create_signal(f"Shot {index}", x, x * index) + for index in range(self.settings.count) + ], + ) + + + class ToolsTestPlugin(PluginBase): + """Plugin exposing tools only.""" + + PLUGIN_INFO = PluginInfo( + id="org.example.tools-app", + name="Tool test application", + version="1.0.0", + capabilities=(PluginCapability.APPLICATION,), + ) + TOOLS = ( + PluginTool( + id="generator", + title="Signal generator...", + instrument="create_generator", + object_type="signal", + ), + PluginTool( + id="inspect", + title="Inspect images...", + launcher="inspect_images", + object_type="image", + selection="at_least_one", + ), + ) + + def create_generator(self): + return SignalGenerator() + + def inspect_images(self): + return len(self.get_selected_objects()) + + def create_actions(self): + pass + ''' +) + + +def _plugin_submenu_actions(win: DLMainWindow) -> dict[str, QW.QAction]: + """Return the actions of the test plugin's submenu in the Plugins menu.""" + win.plugins_menu.aboutToShow.emit() + for action in win.plugins_menu.actions(): + menu = action.menu() + if menu is not None and menu.title() == "Tool test application": + return { + sub_action.text(): sub_action + for sub_action in menu.actions() + if not sub_action.isSeparator() + } + return {} + + +def test_plugin_tools_in_menus_and_instrument_window( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tools are listed in their panel menu and open an instrument window.""" + with ( + Conf.plugins_enabled.context(True), + Conf.plugins_enabled_list.context(None), + temporary_plugin_dir() as plugin_dir, + ): + with open( + f"{plugin_dir}/datalab_test_plugin_tools.py", "w", encoding="utf-8" + ) as handle: + handle.write(PLUGIN_SOURCE) + with datalab_test_app_context(console=False) as win: + plugin = PluginRegistry.get_plugin("org.example.tools-app") + assert plugin is not None + + win.set_current_panel("signal") + QW.QApplication.processEvents() + assert list(_plugin_submenu_actions(win)) == ["Signal generator..."] + + win.set_current_panel("image") + QW.QApplication.processEvents() + inspect_action = _plugin_submenu_actions(win)["Inspect images..."] + assert not inspect_action.isEnabled() + assert plugin.assess_tool("inspect") == "Select at least one image" + win.imagepanel.add_object(create_image("Frame", np.zeros((4, 4)))) + QW.QApplication.processEvents() + assert inspect_action.isEnabled() + assert plugin.assess_tool("inspect") is None + assert plugin.launch_tool("inspect") == 1 + + win.set_current_panel("signal") + with pytest.raises(ValueError, match="Select at least one image"): + plugin.launch_tool("inspect") + + window = plugin.launch_tool("generator") + assert isinstance(window, InstrumentWindow) + assert plugin.launch_tool("generator") is window + window.refresh() + assert len(window.items) == 1 + assert window.summary_label.text().startswith("Frame") + assert window.plotwidget.plot.get_axis_limits("left") == (-2.0, 2.0) + + window.live_button.setChecked(True) + assert window._live_timer.isActive() # pylint: disable=protected-access + window.live_button.setChecked(False) + + instrument = plugin.get_instrument("generator") + + def failing_preview(): + raise ValueError("Amplitude out of range") + + monkeypatch.setattr(instrument, "preview", failing_preview) + window.refresh() + assert window.status_label.text() == "Amplitude out of range" + monkeypatch.undo() + + window.acquire() + groups = win.signalpanel.objmodel.get_groups() + assert groups[-1].title == "Generator acquisition" + assert len(groups[-1]) == 3 + assert win.get_current_panel() == "signal" + assert "3 objects" in window.status_label.text() + + closed: list[int] = [] + window.finished.connect(closed.append) + win.reload_plugins() + QW.QApplication.processEvents() + assert closed + reloaded = PluginRegistry.get_plugin("org.example.tools-app") + assert reloaded.launch_tool("generator") is not window + win.close_plugin_instruments() diff --git a/doc/features/advanced/plugins.rst b/doc/features/advanced/plugins.rst index 63d986052..183019d0d 100644 --- a/doc/features/advanced/plugins.rst +++ b/doc/features/advanced/plugins.rst @@ -445,11 +445,12 @@ The **Applications** catalog presents each recipe as a method with its expected Plugin tools ~~~~~~~~~~~~ -A recipe covers a headless analysis run by DataLab. Any other interaction owned by an application plugin, such as a wizard, an interactive editor or a data preparation step, is a tool. Declare tools in the class-level ``TOOLS`` tuple of :class:`datalab.plugin_tools.PluginTool` values; the **Applications** catalog lists them in a **Tools** section: +A recipe covers a headless analysis run by DataLab. Any other interaction owned by an application plugin, such as a wizard, an interactive editor, a data preparation step or a simulated instrument, is a tool. Declare tools in the class-level ``TOOLS`` tuple of :class:`datalab.plugin_tools.PluginTool` values: .. code-block:: python - from datalab.plugin_tools import PluginTool + from datalab.plugin_tools import PluginTool, ToolSelection + from datalab.recipes import RecipeObjectType class MyPlugin(PluginBase): TOOLS = ( @@ -458,13 +459,68 @@ A recipe covers a headless analysis run by DataLab. Any other interaction owned title="Annotate frames...", launcher="annotate_frames", description="Set the acquisition metadata of the selected frames", + object_type=RecipeObjectType.IMAGE, + selection=ToolSelection.AT_LEAST_ONE, ), ) def annotate_frames(self): ... -The launcher is called without arguments. :meth:`datalab.plugins.PluginBase.get_tools` validates unique local IDs and launcher methods, and rejects tools declared by plugins without the ``APPLICATION`` capability. Tools are a Desktop feature: DataLab-Web does not list them. +DataLab lists each tool in two places: in the **Tools** section of the **Applications** catalog, and in the plugin's submenu of the **Plugins** menu, after the plugin's own actions. ``object_type`` gives the panel whose menu shows the tool; with ``None``, both panels show it. ``selection`` states which selection the tool needs: ``NONE`` (the default), ``EXACTLY_ONE``, ``AT_LEAST_ONE`` or ``AT_LEAST_TWO`` objects of ``object_type``. When the selection does not match, DataLab disables the menu entry and the catalog button, and the button tooltip tells what to select. ``icon`` takes a ``package:path`` resource or a DataLab icon file name; by default, the tool uses the plugin icon. + +The launcher is called without arguments. :meth:`datalab.plugins.PluginBase.get_tools` validates unique local IDs and the tool methods, and rejects tools declared by plugins without the ``APPLICATION`` capability. :meth:`datalab.plugins.PluginBase.assess_tool` returns the reason why a tool cannot be opened for the current selection, or ``None``. + +Instruments +^^^^^^^^^^^ + +A tool may name an ``instrument`` method instead of a ``launcher``. This method returns a :class:`datalab.plugin_instruments.PluginInstrument`, and DataLab opens a window for it: a live view on the left, the instrument settings on the right, a **Live** button that refreshes the view continuously, and an **Acquire** button. The plugin writes no user interface code: + +.. code-block:: python + + import guidata.dataset as gds + from datalab.plugin_instruments import ( + InstrumentAcquisition, + InstrumentFrame, + PluginInstrument, + ) + + class SourceSettings(gds.DataSet): + amplitude = gds.FloatItem("Amplitude", default=1.0, unit="V") + count = gds.IntItem("Signals per acquisition", default=10, min=1) + + class Source(PluginInstrument): + live_interval_ms = 200 + + def __init__(self): + super().__init__(SourceSettings()) + + def preview(self): + signal = self.make_signal() + return InstrumentFrame([signal], summary="...", value_range=(-2.0, 2.0)) + + def acquire(self): + signals = [self.make_signal() for _ in range(self.settings.count)] + return InstrumentAcquisition("Source - acquisition 001", signals) + + class MyPlugin(PluginBase): + TOOLS = ( + PluginTool( + id="source", + title="Signal source...", + instrument="signal_source", + object_type=RecipeObjectType.SIGNAL, + ), + ) + + def signal_source(self): + return Source() + +DataLab edits :attr:`~datalab.plugin_instruments.PluginInstrument.settings` in place, with the active state, groups and tabs of the guidata DataSet, then calls :meth:`~datalab.plugin_instruments.PluginInstrument.preview` after each change and, in live mode, every ``live_interval_ms`` milliseconds. An :class:`~datalab.plugin_instruments.InstrumentFrame` holds signals drawn together or a single image, a short summary shown below the view, and an optional fixed Y range (signals) or color range (image). :meth:`~datalab.plugin_instruments.PluginInstrument.acquire` returns an :class:`~datalab.plugin_instruments.InstrumentAcquisition`: DataLab adds its objects to a new group of the matching panel and selects them, so that a recipe can run on them at once. Both methods raise ``ValueError`` with a user-facing message when the settings cannot be used; DataLab shows the message in the window. + +DataLab creates the instrument once per tool and keeps it until the plugins are reloaded, so the settings remain from one opening to the next. + +DataLab-Web supports tools too: it lists them in the same places, opens instruments in the same window, and calls launchers in the plugin's Pyodide host. Welcome page tiles ------------------ @@ -672,5 +728,11 @@ Public API .. automodule:: datalab.plugin_tiles :members: +.. automodule:: datalab.plugin_tools + :members: + +.. automodule:: datalab.plugin_instruments + :members: + .. automodule:: datalab.gui.recipe_runner :members: diff --git a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po index e9f52e2f5..27faee107 100644 --- a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po +++ b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po @@ -316,11 +316,29 @@ msgstr "Le catalogue **Applications** présente chaque recette comme une méthod msgid "Plugin tools" msgstr "Outils de plugin" -msgid "A recipe covers a headless analysis run by DataLab. Any other interaction owned by an application plugin, such as a wizard, an interactive editor or a data preparation step, is a tool. Declare tools in the class-level ``TOOLS`` tuple of :class:`datalab.plugin_tools.PluginTool` values; the **Applications** catalog lists them in a **Tools** section:" -msgstr "Une recette couvre une analyse sans interface exécutée par DataLab. Toute autre interaction propre à un plugin d'application, comme un assistant, un éditeur interactif ou une étape de préparation des données, est un outil. Déclarez les outils dans le tuple de classe ``TOOLS`` de valeurs :class:`datalab.plugin_tools.PluginTool` ; le catalogue **Applications** les liste dans une section **Outils** :" +msgid "A recipe covers a headless analysis run by DataLab. Any other interaction owned by an application plugin, such as a wizard, an interactive editor, a data preparation step or a simulated instrument, is a tool. Declare tools in the class-level ``TOOLS`` tuple of :class:`datalab.plugin_tools.PluginTool` values:" +msgstr "Une recette couvre une analyse sans interface exécutée par DataLab. Toute autre interaction propre à un plugin d'application, comme un assistant, un éditeur interactif, une étape de préparation des données ou un instrument simulé, est un outil. Déclarez les outils dans le tuple de classe ``TOOLS`` de valeurs :class:`datalab.plugin_tools.PluginTool` :" -msgid "The launcher is called without arguments. :meth:`datalab.plugins.PluginBase.get_tools` validates unique local IDs and launcher methods, and rejects tools declared by plugins without the ``APPLICATION`` capability. Tools are a Desktop feature: DataLab-Web does not list them." -msgstr "Le lanceur est appelé sans argument. :meth:`datalab.plugins.PluginBase.get_tools` vérifie l'unicité des identifiants locaux et l'existence des méthodes de lancement, et rejette les outils déclarés par des plugins sans la capacité ``APPLICATION``. Les outils sont une fonctionnalité Desktop : DataLab-Web ne les liste pas." +msgid "DataLab lists each tool in two places: in the **Tools** section of the **Applications** catalog, and in the plugin's submenu of the **Plugins** menu, after the plugin's own actions. ``object_type`` gives the panel whose menu shows the tool; with ``None``, both panels show it. ``selection`` states which selection the tool needs: ``NONE`` (the default), ``EXACTLY_ONE``, ``AT_LEAST_ONE`` or ``AT_LEAST_TWO`` objects of ``object_type``. When the selection does not match, DataLab disables the menu entry and the catalog button, and the button tooltip tells what to select. ``icon`` takes a ``package:path`` resource or a DataLab icon file name; by default, the tool uses the plugin icon." +msgstr "DataLab liste chaque outil à deux endroits : dans la section **Outils** du catalogue **Applications**, et dans le sous-menu du plugin du menu **Plugins**, après les actions propres au plugin. ``object_type`` indique le panneau dont le menu affiche l'outil ; avec ``None``, les deux panneaux l'affichent. ``selection`` indique la sélection dont l'outil a besoin : ``NONE`` (par défaut), ``EXACTLY_ONE``, ``AT_LEAST_ONE`` ou ``AT_LEAST_TWO`` objets de type ``object_type``. Lorsque la sélection ne convient pas, DataLab désactive l'entrée de menu et le bouton du catalogue, et l'info-bulle du bouton indique quoi sélectionner. ``icon`` accepte une ressource ``package:path`` ou un nom de fichier d'icône DataLab ; par défaut, l'outil utilise l'icône du plugin." + +msgid "The launcher is called without arguments. :meth:`datalab.plugins.PluginBase.get_tools` validates unique local IDs and the tool methods, and rejects tools declared by plugins without the ``APPLICATION`` capability. :meth:`datalab.plugins.PluginBase.assess_tool` returns the reason why a tool cannot be opened for the current selection, or ``None``." +msgstr "Le lanceur est appelé sans argument. :meth:`datalab.plugins.PluginBase.get_tools` vérifie l'unicité des identifiants locaux et les méthodes des outils, et rejette les outils déclarés par des plugins sans la capacité ``APPLICATION``. :meth:`datalab.plugins.PluginBase.assess_tool` renvoie la raison pour laquelle un outil ne peut pas être ouvert pour la sélection courante, ou ``None``." + +msgid "Instruments" +msgstr "Instruments" + +msgid "A tool may name an ``instrument`` method instead of a ``launcher``. This method returns a :class:`datalab.plugin_instruments.PluginInstrument`, and DataLab opens a window for it: a live view on the left, the instrument settings on the right, a **Live** button that refreshes the view continuously, and an **Acquire** button. The plugin writes no user interface code:" +msgstr "Un outil peut désigner une méthode ``instrument`` au lieu d'un ``launcher``. Cette méthode renvoie un :class:`datalab.plugin_instruments.PluginInstrument`, et DataLab ouvre une fenêtre pour lui : une vue en direct à gauche, les réglages de l'instrument à droite, un bouton **En direct** qui rafraîchit la vue en continu et un bouton **Acquérir**. Le plugin n'écrit aucun code d'interface utilisateur :" + +msgid "DataLab edits :attr:`~datalab.plugin_instruments.PluginInstrument.settings` in place, with the active state, groups and tabs of the guidata DataSet, then calls :meth:`~datalab.plugin_instruments.PluginInstrument.preview` after each change and, in live mode, every ``live_interval_ms`` milliseconds. An :class:`~datalab.plugin_instruments.InstrumentFrame` holds signals drawn together or a single image, a short summary shown below the view, and an optional fixed Y range (signals) or color range (image). :meth:`~datalab.plugin_instruments.PluginInstrument.acquire` returns an :class:`~datalab.plugin_instruments.InstrumentAcquisition`: DataLab adds its objects to a new group of the matching panel and selects them, so that a recipe can run on them at once. Both methods raise ``ValueError`` with a user-facing message when the settings cannot be used; DataLab shows the message in the window." +msgstr "DataLab modifie :attr:`~datalab.plugin_instruments.PluginInstrument.settings` sur place, en respectant l'état actif, les groupes et les onglets du DataSet guidata, puis appelle :meth:`~datalab.plugin_instruments.PluginInstrument.preview` après chaque modification et, en mode direct, toutes les ``live_interval_ms`` millisecondes. Un :class:`~datalab.plugin_instruments.InstrumentFrame` contient des signaux tracés ensemble ou une seule image, un court résumé affiché sous la vue, et une plage fixe facultative en Y (signaux) ou de couleurs (image). :meth:`~datalab.plugin_instruments.PluginInstrument.acquire` renvoie un :class:`~datalab.plugin_instruments.InstrumentAcquisition` : DataLab ajoute ses objets dans un nouveau groupe du panneau correspondant et les sélectionne, afin qu'une recette puisse s'exécuter aussitôt sur eux. Les deux méthodes lèvent ``ValueError`` avec un message destiné à l'utilisateur lorsque les réglages ne peuvent pas être utilisés ; DataLab affiche ce message dans la fenêtre." + +msgid "DataLab creates the instrument once per tool and keeps it until the plugins are reloaded, so the settings remain from one opening to the next." +msgstr "DataLab crée l'instrument une seule fois par outil et le conserve jusqu'au rechargement des plugins : les réglages sont ainsi conservés d'une ouverture à l'autre." + +msgid "DataLab-Web supports tools too: it lists them in the same places, opens instruments in the same window, and calls launchers in the plugin's Pyodide host." +msgstr "DataLab-Web prend aussi en charge les outils : il les liste aux mêmes endroits, ouvre les instruments dans la même fenêtre et appelle les lanceurs dans l'hôte Pyodide du plugin." msgid "Welcome page tiles" msgstr "Tuiles de la page d'accueil" @@ -637,8 +655,29 @@ msgstr "Ouvre un exemple, puis lance l'une des recettes pour lesquelles il est c msgid "Return validated tools listed in the Applications catalog." msgstr "Renvoie les outils validés listés dans le catalogue Applications." -msgid "Open a plugin tool by calling its launcher method." -msgstr "Ouvre un outil du plugin en appelant sa méthode de lancement." +msgid "Return the message telling which objects to select to open a tool." +msgstr "Renvoie le message indiquant quels objets sélectionner pour ouvrir un outil." + +msgid "Return one tool by its plugin-local ID." +msgstr "Renvoie un outil à partir de son identifiant local au plugin." + +msgid "Return why a tool cannot be opened on objects (default: selection)." +msgstr "Renvoie la raison pour laquelle un outil ne peut pas être ouvert sur des objets (par défaut : la sélection)." + +msgid "Message telling which objects to select, or None if the tool can be opened" +msgstr "Message indiquant quels objets sélectionner, ou None si l'outil peut être ouvert" + +msgid "Open a plugin tool: call its launcher, or show its instrument." +msgstr "Ouvre un outil du plugin : appelle sa méthode de lancement, ou affiche son instrument." + +msgid "Raises" +msgstr "Lève" + +msgid "if the selection does not allow opening the tool" +msgstr "si la sélection ne permet pas d'ouvrir l'outil" + +msgid "Return the instrument of a tool, created once per registration." +msgstr "Renvoie l'instrument d'un outil, créé une seule fois par enregistrement." msgid "Return validated packaged examples exposed by this plugin." msgstr "Renvoie les exemples empaquetés validés exposés par ce plugin." @@ -1028,6 +1067,102 @@ msgstr "nom de la méthode du plugin appelée lors d'un clic sur la tuile ; ``No msgid "Return True if the icon is a ``package:path`` resource" msgstr "Renvoie True si l'icône est une ressource ``package:path``" +msgid "Declarations of plugin-owned tools listed in the Applications catalog." +msgstr "Déclarations des outils propres aux plugins listés dans le catalogue Applications." + +msgid "DataLab Desktop and DataLab-Web share this module." +msgstr "DataLab Desktop et DataLab-Web partagent ce module." + +msgid "Tool with its own user interface, exposed by an application plugin" +msgstr "Outil doté de sa propre interface utilisateur, exposé par un plugin d'application" + +msgid "Recipes cover headless analyses run by DataLab; a tool covers any other interaction owned by the plugin (wizard, interactive editor, data preparation, instrument...). DataLab lists tools in the Applications catalog and in the plugin's submenu of the *Plugins* menu." +msgstr "Les recettes couvrent les analyses sans interface exécutées par DataLab ; un outil couvre toute autre interaction propre au plugin (assistant, éditeur interactif, préparation des données, instrument...). DataLab liste les outils dans le catalogue Applications et dans le sous-menu du plugin du menu *Plugins*." + +msgid "A tool is opened either by a ``launcher`` method, called without arguments, or by an ``instrument`` method returning a :class:`datalab.plugin_instruments.PluginInstrument`, which DataLab shows in a window with a live view and the instrument settings." +msgstr "Un outil est ouvert soit par une méthode ``launcher``, appelée sans argument, soit par une méthode ``instrument`` renvoyant un :class:`datalab.plugin_instruments.PluginInstrument`, que DataLab affiche dans une fenêtre avec une vue en direct et les réglages de l'instrument." + +msgid "plugin-local tool ID (lowercase letters, digits, ``.``, ``_``, ``-``)" +msgstr "identifiant de l'outil local au plugin (lettres minuscules, chiffres, ``.``, ``_``, ``-``)" + +msgid "tool title" +msgstr "titre de l'outil" + +msgid "name of the plugin method called, without arguments, to open the tool" +msgstr "nom de la méthode du plugin appelée, sans argument, pour ouvrir l'outil" + +msgid "short description of what the tool does" +msgstr "courte description de ce que fait l'outil" + +msgid "name of the plugin method returning the instrument shown by DataLab (exclusive with ``launcher``)" +msgstr "nom de la méthode du plugin renvoyant l'instrument affiché par DataLab (exclusif avec ``launcher``)" + +msgid "type of the objects the tool works on or creates; the tool is listed in the menu of that panel (``None``: both panels)" +msgstr "type des objets sur lesquels l'outil travaille ou qu'il crée ; l'outil est listé dans le menu de ce panneau (``None`` : les deux panneaux)" + +msgid "selection needed to open the tool, counting objects of ``object_type`` only" +msgstr "sélection nécessaire pour ouvrir l'outil, en ne comptant que les objets de type ``object_type``" + +msgid "Selection a tool needs to be opened (names follow DataLab's conditions)." +msgstr "Sélection nécessaire à l'ouverture d'un outil (les noms suivent les conditions de DataLab)." + +msgid "Return True if the selected objects allow opening the tool." +msgstr "Renvoie True si les objets sélectionnés permettent d'ouvrir l'outil." + +msgid "tool to open" +msgstr "outil à ouvrir" + +msgid "selected objects" +msgstr "objets sélectionnés" + +msgid "True if the number of selected objects of the tool's object type matches the tool's selection" +msgstr "True si le nombre d'objets sélectionnés du type de l'outil correspond à la sélection de l'outil" + +msgid "Instruments exposed by application plugins as tools." +msgstr "Instruments exposés comme outils par les plugins d'application." + +msgid "An instrument is a headless object: it holds settings (a guidata DataSet), returns a live frame for these settings, and acquires objects. DataLab shows it in a window with the live view on the left and the settings on the right. DataLab Desktop and DataLab-Web share this module." +msgstr "Un instrument est un objet sans interface : il porte des réglages (un DataSet guidata), renvoie une image en direct pour ces réglages et acquiert des objets. DataLab l'affiche dans une fenêtre avec la vue en direct à gauche et les réglages à droite. DataLab Desktop et DataLab-Web partagent ce module." + +msgid "Objects acquired by an instrument, added by DataLab in a new group." +msgstr "Objets acquis par un instrument, ajoutés par DataLab dans un nouveau groupe." + +msgid "title of the group created for the acquisition" +msgstr "titre du groupe créé pour l'acquisition" + +msgid "acquired signals, or acquired images" +msgstr "signaux acquis, ou images acquises" + +msgid "Live frame shown by DataLab for the current instrument settings." +msgstr "Image en direct affichée par DataLab pour les réglages courants de l'instrument." + +msgid "signals drawn together, or a single image" +msgstr "signaux tracés ensemble, ou une seule image" + +msgid "short text shown below the view (e.g. measured levels)" +msgstr "court texte affiché sous la vue (par exemple les niveaux mesurés)" + +msgid "fixed Y range of signals, or color range of the image (``None``: automatic)" +msgstr "plage fixe en Y des signaux, ou plage de couleurs de l'image (``None`` : automatique)" + +msgid "Instrument opened by a plugin tool." +msgstr "Instrument ouvert par un outil de plugin." + +msgid "DataLab writes the edited values into :attr:`settings` before calling :meth:`preview` or :meth:`acquire`. Both methods raise ``ValueError`` with a user-facing message when the settings cannot be used. The plugin keeps one instance per tool during the session, so settings are kept from one opening to the next." +msgstr "DataLab écrit les valeurs modifiées dans :attr:`settings` avant d'appeler :meth:`preview` ou :meth:`acquire`. Les deux méthodes lèvent ``ValueError`` avec un message destiné à l'utilisateur lorsque les réglages ne peuvent pas être utilisés. Le plugin conserve une instance par outil pendant la session : les réglages sont ainsi conservés d'une ouverture à l'autre." + +msgid "instrument settings, edited by DataLab" +msgstr "réglages de l'instrument, modifiés par DataLab" + +msgid "Interval between two frames in live mode, in milliseconds" +msgstr "Intervalle entre deux images en mode direct, en millisecondes" + +msgid "Return a live frame for the current settings." +msgstr "Renvoie une image en direct pour les réglages courants." + +msgid "Acquire objects with the current settings." +msgstr "Acquiert des objets avec les réglages courants." + msgid "Transactional Desktop runner for plugin-provided scientific recipes." msgstr "Exécuteur transactionnel Desktop pour les recettes scientifiques fournies par les plugins." diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po index 4f43c7225..0bbfe2c84 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po @@ -78,6 +78,12 @@ msgstr "Les exemples sont listés sous les méthodes pour lesquelles ils sont co msgid "Application plugins may also list tools, such as wizards or editors, in the catalog" msgstr "Les plugins d'application peuvent aussi lister des outils, comme des assistants ou des éditeurs, dans le catalogue" +msgid "Plugin tools also appear in the plugin's submenu of the **Plugins** menu, in the Signal or Image panel they work on. A tool that needs a selection is disabled until the right objects are selected, and its catalog button tells what to select" +msgstr "Les outils de plugin apparaissent aussi dans le sous-menu du plugin du menu **Plugins**, dans le panneau Signal ou Image sur lequel ils travaillent. Un outil qui a besoin d'une sélection est désactivé jusqu'à ce que les bons objets soient sélectionnés, et son bouton dans le catalogue indique quoi sélectionner" + +msgid "Application plugins may provide instruments, such as simulated cameras or oscilloscopes, without writing any user interface: DataLab shows a window with a live view and the instrument settings, refreshed as settings change or continuously in **Live** mode, and **Acquire** adds the acquired objects to a new group, ready for the application's methods" +msgstr "Les plugins d'application peuvent fournir des instruments, comme des caméras ou des oscilloscopes simulés, sans écrire d'interface utilisateur : DataLab affiche une fenêtre avec une vue en direct et les réglages de l'instrument, rafraîchie à chaque modification des réglages ou en continu en mode **En direct**, et **Acquérir** ajoute les objets acquis dans un nouveau groupe, prêts pour les méthodes de l'application" + msgid "Plugin developers describe recipe inputs declaratively (titles, minimum counts, metadata requirements) and may add binding suggestions and fast input checks; every recipe then runs through DataLab's generic launcher, without plugin-specific input dialogs" msgstr "Les développeurs de plugins décrivent les entrées des recettes de manière déclarative (titres, effectifs minimaux, métadonnées requises) et peuvent ajouter des suggestions d'affectation et des vérifications rapides des entrées ; chaque recette s'exécute alors via le lanceur générique de DataLab, sans boîte de dialogue d'entrées propre au plugin" diff --git a/doc/release_notes/release_1.04.md b/doc/release_notes/release_1.04.md index ad1f25be2..ffc8d0959 100644 --- a/doc/release_notes/release_1.04.md +++ b/doc/release_notes/release_1.04.md @@ -30,6 +30,8 @@ * **Run on selection...** starts a method directly when the selection is ready, and asks for the input assignment only when it is ambiguous or invalid; the assignment dialog checks the chosen objects before the run can continue * Examples are listed under the methods they are designed for: **Try with this example** opens the example, prefills the method parameters, and runs the method once they are accepted. One example may serve several methods, and examples designed for no method are listed as datasets * Application plugins may also list tools, such as wizards or editors, in the catalog +* Plugin tools also appear in the plugin's submenu of the **Plugins** menu, in the Signal or Image panel they work on. A tool that needs a selection is disabled until the right objects are selected, and its catalog button tells what to select +* Application plugins may provide instruments, such as simulated cameras or oscilloscopes, without writing any user interface: DataLab shows a window with a live view and the instrument settings, refreshed as settings change or continuously in **Live** mode, and **Acquire** adds the acquired objects to a new group, ready for the application's methods * Plugin developers describe recipe inputs declaratively (titles, minimum counts, metadata requirements) and may add binding suggestions and fast input checks; every recipe then runs through DataLab's generic launcher, without plugin-specific input dialogs * The catalog is non-modal and remains open after starting a method or opening an example, so users may continue interacting with the DataLab workspace * The methods, tools and datasets of an application form an accordion: one section is open at a time, and a colored dot tells, without opening it, whether each method can run on the current selection