From df34017edf819d141a7a0bfcd7ce18fc0e5dbbd6 Mon Sep 17 00:00:00 2001 From: Stefano Pierini Date: Mon, 18 May 2026 17:54:42 +0200 Subject: [PATCH 01/12] Fix existing test to avoid persistent state side effects --- .../plugins/pluginconfig_dialog_test.py | 103 ++++++++++-------- 1 file changed, 57 insertions(+), 46 deletions(-) diff --git a/datalab/tests/features/plugins/pluginconfig_dialog_test.py b/datalab/tests/features/plugins/pluginconfig_dialog_test.py index 2e4e363b..ec4f4c3d 100644 --- a/datalab/tests/features/plugins/pluginconfig_dialog_test.py +++ b/datalab/tests/features/plugins/pluginconfig_dialog_test.py @@ -197,53 +197,64 @@ def test_plugin_enable_disable_config(): def test_plugin_many_actions_menu_behavior(): """Test plugin with many actions in dropdown menu.""" - with temporary_template_plugin( - "datalab_test_plugin_many_actions.py", - "plugin_many_actions.py.template", - { - "{class_name}": "TestPluginManyActions", - "{plugin_name}": "Many Actions Test", - "{menu_name}": "Test Menu with Many Actions", - "{action_prefix}": "Test Action", - "{test_code_1}": "self.main._test_action_1 = True", - "{test_code_2}": "self.main._test_action_2 = True", - "{test_code_3}": "self.main._test_action_3 = True", - "{test_code_4}": "self.main._test_action_4 = True", - "{test_code_5}": "self.main._test_action_5 = True", - }, - ): - with datalab_test_app_context(console=False) as win: - QW.QApplication.processEvents() - win.tabwidget.setCurrentWidget(win.signalpanel) - QW.QApplication.processEvents() - win.plugins_menu.aboutToShow.emit() - assert "menu-scrollable" in win.plugins_menu.styleSheet() - plugin_actions = win.signalpanel.get_category_actions( - ActionCategory.PLUGINS - ) + main_config = Conf.to_dict().get("main", {}) + had_config = "plugins_enabled_list" in main_config + original_enabled_list = Conf.main.plugins_enabled_list.get(None) - test_menu = next( - item - for item in plugin_actions - if isinstance(item, QW.QMenu) - and item.title() == "Test Menu with Many Actions" - ) - test_menu.aboutToShow.emit() - assert "menu-scrollable" in test_menu.styleSheet() - - action_texts = [ - action.text() - for action in test_menu.actions() - if not action.isSeparator() - ] - assert len(action_texts) == 5 - assert "Test Action 3" in action_texts - action_3 = next( - action - for action in test_menu.actions() - if action.text() == "Test Action 3" - ) - assert action_3.isEnabled() + try: + Conf.main.plugins_enabled_list.set(None) + with temporary_template_plugin( + "datalab_test_plugin_many_actions.py", + "plugin_many_actions.py.template", + { + "{class_name}": "TestPluginManyActions", + "{plugin_name}": "Many Actions Test", + "{menu_name}": "Test Menu with Many Actions", + "{action_prefix}": "Test Action", + "{test_code_1}": "self.main._test_action_1 = True", + "{test_code_2}": "self.main._test_action_2 = True", + "{test_code_3}": "self.main._test_action_3 = True", + "{test_code_4}": "self.main._test_action_4 = True", + "{test_code_5}": "self.main._test_action_5 = True", + }, + ): + with datalab_test_app_context(console=False) as win: + QW.QApplication.processEvents() + win.tabwidget.setCurrentWidget(win.signalpanel) + QW.QApplication.processEvents() + win.plugins_menu.aboutToShow.emit() + assert "menu-scrollable" in win.plugins_menu.styleSheet() + plugin_actions = win.signalpanel.get_category_actions( + ActionCategory.PLUGINS + ) + + test_menu = next( + item + for item in plugin_actions + if isinstance(item, QW.QMenu) + and item.title() == "Test Menu with Many Actions" + ) + test_menu.aboutToShow.emit() + assert "menu-scrollable" in test_menu.styleSheet() + + action_texts = [ + action.text() + for action in test_menu.actions() + if not action.isSeparator() + ] + assert len(action_texts) == 5 + assert "Test Action 3" in action_texts + action_3 = next( + action + for action in test_menu.actions() + if action.text() == "Test Action 3" + ) + assert action_3.isEnabled() + finally: + if had_config: + Conf.main.plugins_enabled_list.set(original_enabled_list) + else: + Conf.main.plugins_enabled_list.remove() def test_plugin_long_description(): From d1983b56e02e58fb5059b5327c04f9240f7d9a8c Mon Sep 17 00:00:00 2001 From: Stefano Pierini Date: Tue, 19 May 2026 11:31:05 +0200 Subject: [PATCH 02/12] Add actions to open plugin files and show in folder Enhances the plugin configuration dialog by adding buttons to open the plugin's source file and reveal its location in the system file manager. Improves plugin debugging and management by making file access more convenient. Updates localization, introduces corresponding icons, and adds tests to verify the new features. --- datalab/data/icons/edit/open_file_source.svg | 11 ++ datalab/data/icons/io/show_in_folder.svg | 8 ++ datalab/gui/pluginconfig.py | 132 ++++++++++++++++++ datalab/locale/fr/LC_MESSAGES/datalab.po | 6 + datalab/plugins.py | 13 ++ .../plugins/pluginconfig_dialog_test.py | 109 ++++++++++++++- 6 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 datalab/data/icons/edit/open_file_source.svg create mode 100644 datalab/data/icons/io/show_in_folder.svg diff --git a/datalab/data/icons/edit/open_file_source.svg b/datalab/data/icons/edit/open_file_source.svg new file mode 100644 index 00000000..f81d24fd --- /dev/null +++ b/datalab/data/icons/edit/open_file_source.svg @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/datalab/data/icons/io/show_in_folder.svg b/datalab/data/icons/io/show_in_folder.svg new file mode 100644 index 00000000..f92989ff --- /dev/null +++ b/datalab/data/icons/io/show_in_folder.svg @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/datalab/gui/pluginconfig.py b/datalab/gui/pluginconfig.py index 76d2c8ac..67a71eab 100644 --- a/datalab/gui/pluginconfig.py +++ b/datalab/gui/pluginconfig.py @@ -9,10 +9,15 @@ from __future__ import annotations +import inspect import os import os.path as osp +import shutil +import subprocess +import sys from typing import TYPE_CHECKING +from guidata.configtools import get_icon from guidata.qthelpers import win32_fix_title_bar_background from qtpy import QtCore as QC from qtpy import QtGui as QG @@ -86,6 +91,44 @@ def _create_status_label(text: str, color: QG.QColor | None = None) -> QW.QLabel return status_label +def _open_local_path(path: str) -> bool: + """Open a local path with the desktop handler.""" + return QG.QDesktopServices.openUrl(QC.QUrl.fromLocalFile(path)) + + +def _show_in_folder(path: str) -> bool: + """Show a file in its containing folder, selecting it when supported.""" + filepath = osp.abspath(path) + directory = osp.dirname(filepath) + + if sys.platform.startswith("win"): + commands = [["explorer", f"/select,{osp.normpath(filepath)}"]] + elif sys.platform == "darwin": + commands = [["open", "-R", filepath]] + else: + commands = [] + if shutil.which("nautilus"): + commands.append(["nautilus", "--select", filepath]) + if shutil.which("dolphin"): + commands.append(["dolphin", "--select", filepath]) + if shutil.which("nemo"): + commands.append(["nemo", filepath]) + if shutil.which("caja"): + commands.append(["caja", "--select", filepath]) + + for command in commands: + try: + subprocess.Popen( # pylint: disable=consider-using-with + command, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return True + except OSError: + continue + return _open_local_path(directory) + + class PluginState: """Plugin state enumeration""" @@ -117,6 +160,9 @@ def __init__( self.initial_enabled = enabled self.state = state self._expanded = False + self.plugin_filepath = self._get_plugin_filepath() + self.open_file_button: QW.QPushButton | None = None + self.show_in_folder_button: QW.QPushButton | None = None self.setSizePolicy(QW.QSizePolicy.Preferred, QW.QSizePolicy.Maximum) # Main layout @@ -126,6 +172,9 @@ def __init__( layout.addLayout(self._create_top_row(enabled, state)) layout.addWidget(self._create_description_widget()) + actions_layout = self._create_actions_layout() + if actions_layout is not None: + layout.addLayout(actions_layout) layout.addWidget(self._create_separator()) def _create_top_row(self, enabled: bool, state: str) -> QW.QHBoxLayout: @@ -180,6 +229,50 @@ def _sync_description_expanded_state(self, expanded: bool) -> None: """Keep the legacy expanded state in sync with the description widget.""" self._expanded = expanded + def _get_plugin_filepath(self) -> str | None: + """Return the Python file defining the plugin class, when available.""" + filepath = getattr(self.plugin_class, "__plugin_filepath__", None) + if filepath: + return osp.abspath(filepath) + try: + filepath = inspect.getsourcefile(self.plugin_class) or inspect.getfile( + self.plugin_class + ) + except (OSError, TypeError): + return None + return osp.abspath(filepath) if filepath else None + + def _create_actions_layout(self) -> QW.QHBoxLayout | None: + """Create file/folder actions for the plugin source file.""" + if not self.plugin_filepath: + return None + + actions_layout = QW.QHBoxLayout() + actions_layout.addStretch() + + self.open_file_button = QW.QPushButton( + get_icon("open_file_source.svg"), _("Open file") + ) + self.open_file_button.clicked.connect(self._open_plugin_file) + actions_layout.addWidget(self.open_file_button) + + self.show_in_folder_button = QW.QPushButton( + get_icon("show_in_folder.svg"), _("Show in folder") + ) + self.show_in_folder_button.clicked.connect(self._show_plugin_in_folder) + actions_layout.addWidget(self.show_in_folder_button) + return actions_layout + + def _open_plugin_file(self) -> None: + """Open the plugin file with the desktop handler.""" + if self.plugin_filepath: + _open_local_path(self.plugin_filepath) + + def _show_plugin_in_folder(self) -> None: + """Show the plugin file in its containing folder.""" + if self.plugin_filepath: + _show_in_folder(self.plugin_filepath) + @staticmethod def _create_separator() -> QW.QFrame: """Create the row separator.""" @@ -236,6 +329,11 @@ def __init__( """ super().__init__(parent) self._expanded = False + self.plugin_filepath = ( + osp.abspath(failed_info.filepath) if failed_info.filepath else None + ) + self.open_file_button: QW.QPushButton | None = None + self.show_in_folder_button: QW.QPushButton | None = None self.setSizePolicy(QW.QSizePolicy.Preferred, QW.QSizePolicy.Maximum) # Main layout @@ -245,6 +343,9 @@ def __init__( layout.addLayout(self._create_top_row(failed_info)) layout.addWidget(self._create_description_widget(failed_info)) + actions_layout = self._create_actions_layout() + if actions_layout is not None: + layout.addLayout(actions_layout) layout.addWidget(PluginInfoWidget._create_separator()) def _create_top_row(self, failed_info: FailedPluginInfo) -> QW.QHBoxLayout: @@ -286,6 +387,37 @@ def _create_description_widget(self, failed_info: FailedPluginInfo) -> QW.QWidge self._toggle_btn = self.description_widget.toggle_button return self.description_widget + def _create_actions_layout(self) -> QW.QHBoxLayout | None: + """Create file/folder actions for the failed plugin path.""" + if not self.plugin_filepath: + return None + + actions_layout = QW.QHBoxLayout() + actions_layout.addStretch() + + self.open_file_button = QW.QPushButton( + get_icon("open_file_source.svg"), _("Open file") + ) + self.open_file_button.clicked.connect(self._open_plugin_file) + actions_layout.addWidget(self.open_file_button) + + self.show_in_folder_button = QW.QPushButton( + get_icon("show_in_folder.svg"), _("Show in folder") + ) + self.show_in_folder_button.clicked.connect(self._show_plugin_in_folder) + actions_layout.addWidget(self.show_in_folder_button) + return actions_layout + + def _open_plugin_file(self) -> None: + """Open the failed plugin file with the desktop handler.""" + if self.plugin_filepath: + _open_local_path(self.plugin_filepath) + + def _show_plugin_in_folder(self) -> None: + """Show the failed plugin file in its containing folder.""" + if self.plugin_filepath: + _show_in_folder(self.plugin_filepath) + def _toggle_description(self): """Toggle between collapsed and expanded description""" self.description_widget.set_expanded(not self.description_widget.is_expanded()) diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index e4a64fc0..0e44430a 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -1711,6 +1711,12 @@ msgstr "Désactivé" msgid "No description available" msgstr "Aucune description disponible" +msgid "Open file" +msgstr "Ouvrir le fichier" + +msgid "Show in folder" +msgstr "Afficher dans le dossier" + msgid "Import error" msgstr "Erreur d'importation" diff --git a/datalab/plugins.py b/datalab/plugins.py index 024b01a4..e54d3ff4 100644 --- a/datalab/plugins.py +++ b/datalab/plugins.py @@ -364,6 +364,18 @@ def create_actions(self): """Create actions""" +def _set_plugin_class_filepaths(module) -> None: + """Attach the module file path to plugin classes defined in that module.""" + filepath = getattr(module, "__file__", None) + if not filepath: + return + + filepath = osp.abspath(filepath) + for plugin_class in PluginRegistry.get_plugin_classes(): + if plugin_class.__module__ == module.__name__: + plugin_class.__plugin_filepath__ = filepath + + def discover_plugins() -> list[type[PluginBase]]: """Discover plugins using naming convention @@ -406,6 +418,7 @@ def discover_plugins() -> list[type[PluginBase]]: module = importlib.reload(sys.modules[name]) else: module = importlib.import_module(name) + _set_plugin_class_filepaths(module) modules.append(module) # Plugin discovery imports arbitrary third-party modules. We must catch # every failure here so discovery can continue and the error is exposed diff --git a/datalab/tests/features/plugins/pluginconfig_dialog_test.py b/datalab/tests/features/plugins/pluginconfig_dialog_test.py index ec4f4c3d..e70df944 100644 --- a/datalab/tests/features/plugins/pluginconfig_dialog_test.py +++ b/datalab/tests/features/plugins/pluginconfig_dialog_test.py @@ -9,6 +9,7 @@ from datalab.config import Conf from datalab.env import execenv +from datalab.gui import pluginconfig from datalab.gui.actionhandler import ActionCategory from datalab.gui.pluginconfig import ( ExpandableTextWidget, @@ -26,7 +27,7 @@ ) -def _make_dummy_plugin_class(name: str, description: str): +def _make_dummy_plugin_class(name: str, description: str, filepath: str | None = None): """Create a minimal plugin class for UI-only widget tests.""" class DummyPlugin: @@ -42,6 +43,8 @@ class DummyPlugin: "icon": None, }, )() + if filepath is not None: + DummyPlugin.__plugin_filepath__ = filepath return DummyPlugin @@ -80,11 +83,13 @@ def test_plugin_enable_disable_config(): main_config = Conf.to_dict().get("main", {}) had_config = "plugins_enabled_list" in main_config original_enabled_list = Conf.main.plugins_enabled_list.get(None) + plugin_1_path: str | None = None + plugin_2_path: str | None = None try: with temporary_plugin_dir() as plugin_dir: execenv.print(f"Using temporary plugin directory: {plugin_dir}") - create_plugin_file( + plugin_1_path = create_plugin_file( plugin_dir, "datalab_test_plugin_1.py", "TestPluginOne", @@ -92,7 +97,7 @@ def test_plugin_enable_disable_config(): "Action One", "action_1", ) - create_plugin_file( + plugin_2_path = create_plugin_file( plugin_dir, "datalab_test_plugin_2.py", "TestPluginTwo", @@ -112,6 +117,22 @@ def test_plugin_enable_disable_config(): ] assert "Test Plugin 1" in widget_names assert "Test Plugin 2" in widget_names + plugin_1_widget = next( + widget + for widget in dialog.plugin_widgets + if widget.plugin_class.PLUGIN_INFO.name == "Test Plugin 1" + ) + plugin_2_widget = next( + widget + for widget in dialog.plugin_widgets + if widget.plugin_class.PLUGIN_INFO.name == "Test Plugin 2" + ) + assert plugin_1_widget.plugin_filepath == plugin_1_path + assert plugin_2_widget.plugin_filepath == plugin_2_path + assert plugin_1_widget.open_file_button is not None + assert plugin_1_widget.show_in_folder_button is not None + assert plugin_2_widget.open_file_button is not None + assert plugin_2_widget.show_in_folder_button is not None assert dialog.toggle_all_checkbox.checkState() == QC.Qt.Checked dialog.filter_combo.setCurrentIndex(2) @@ -332,6 +353,48 @@ def test_plugin_description_toggle_depends_on_dialog_width(): assert app is not None +def test_plugin_widget_can_open_plugin_file_and_show_in_folder(monkeypatch): + """Plugin widget should expose actions for opening file and showing it.""" + opened_paths: list[str] = [] + shown_paths: list[str] = [] + + def _open_local_path(path: str) -> bool: + opened_paths.append(path) + return True + + def _show_in_folder(path: str) -> bool: + shown_paths.append(path) + return True + + monkeypatch.setattr(pluginconfig, "_open_local_path", _open_local_path) + monkeypatch.setattr(pluginconfig, "_show_in_folder", _show_in_folder) + + with datalab_test_app_context(console=False): + widget = PluginInfoWidget( + _make_dummy_plugin_class( + "Path Actions Test", + "Plugin with location actions.", + filepath=__file__, + ), + enabled=True, + state=PluginState.ENABLED, + ) + + assert widget.open_file_button is not None + assert widget.show_in_folder_button is not None + assert widget.show_in_folder_button.text() == "Show in folder" + + widget.open_file_button.click() + widget.show_in_folder_button.click() + + assert opened_paths == [__file__] + assert shown_paths == [__file__] + + widget.close() + widget.deleteLater() + QW.QApplication.processEvents() + + def test_plugin_very_long_description_scrolls_only_when_expanded(): """Very long descriptions should use an internal scrollbar only when expanded.""" description = " ".join(["Very long plugin description for scrollbar testing."] * 80) @@ -396,3 +459,43 @@ def test_failed_plugin_description_uses_same_expand_collapse_behavior(): widget.close() widget.deleteLater() QW.QApplication.processEvents() + + +def test_failed_plugin_widget_can_open_plugin_file_and_show_in_folder(monkeypatch): + """Failed plugin widget should expose actions for opening file and showing it.""" + opened_paths: list[str] = [] + shown_paths: list[str] = [] + + def _open_local_path(path: str) -> bool: + opened_paths.append(path) + return True + + def _show_in_folder(path: str) -> bool: + shown_paths.append(path) + return True + + monkeypatch.setattr(pluginconfig, "_open_local_path", _open_local_path) + monkeypatch.setattr(pluginconfig, "_show_in_folder", _show_in_folder) + + failed_info = FailedPluginInfo( + name="bad_plugin.py", + filepath=__file__, + traceback="Traceback", + ) + + with datalab_test_app_context(console=False): + widget = FailedPluginInfoWidget(failed_info) + + assert widget.open_file_button is not None + assert widget.show_in_folder_button is not None + assert widget.show_in_folder_button.text() == "Show in folder" + + widget.open_file_button.click() + widget.show_in_folder_button.click() + + assert opened_paths == [__file__] + assert shown_paths == [__file__] + + widget.close() + widget.deleteLater() + QW.QApplication.processEvents() From 4e0c25f5c2326eb629b58b1a0c0939d19dbd1c0d Mon Sep 17 00:00:00 2001 From: Stefano Pierini Date: Tue, 19 May 2026 15:42:37 +0200 Subject: [PATCH 03/12] Adds plugin load time to settings Shows when plugins were last loaded with today, yesterday, or a dated timestamp so users can quickly tell how fresh the plugin list is. Tracks the latest load time at startup and after reloads, and adds translation and test coverage for the new status text. --- datalab/gui/main.py | 6 ++ datalab/gui/pluginconfig.py | 66 ++++++++++++++++++- datalab/locale/fr/LC_MESSAGES/datalab.po | 10 +++ .../plugins/pluginconfig_dialog_test.py | 61 +++++++++++++++++ 4 files changed, 142 insertions(+), 1 deletion(-) diff --git a/datalab/gui/main.py b/datalab/gui/main.py index fc08b0a6..6f5d17ac 100644 --- a/datalab/gui/main.py +++ b/datalab/gui/main.py @@ -28,6 +28,7 @@ import time import traceback import webbrowser +from datetime import datetime from typing import TYPE_CHECKING import guidata.dataset as gds @@ -156,6 +157,8 @@ def __init__(self, console=None, hide_on_close=False): # pylint: disable=too-ma execenv.log(self, "Starting initialization") self.ready_flag = True + self.started_at = datetime.now().astimezone() + self.plugins_last_load_at = self.started_at self.hide_on_close = hide_on_close self.__old_size: tuple[int, int] | None = None @@ -1080,6 +1083,8 @@ def __register_plugins(self) -> None: plugin_class.__name__, filepath or "", tb_text ) + self.plugins_last_load_at = datetime.now().astimezone() + def __flush_startup_errors(self) -> None: """Write any buffered startup errors to the internal console. @@ -1208,6 +1213,7 @@ def reload_plugins(self) -> None: # Update plugin status in the status bar self.pluginstatus.update_status() self.__update_plugins_availability() + self.plugins_last_load_at = datetime.now().astimezone() def __configure_statusbar(self, console: bool) -> None: """Configure status bar diff --git a/datalab/gui/pluginconfig.py b/datalab/gui/pluginconfig.py index 67a71eab..d1227f65 100644 --- a/datalab/gui/pluginconfig.py +++ b/datalab/gui/pluginconfig.py @@ -15,6 +15,7 @@ import shutil import subprocess import sys +from datetime import datetime, timedelta from typing import TYPE_CHECKING from guidata.configtools import get_icon @@ -129,6 +130,49 @@ def _show_in_folder(path: str) -> bool: return _open_local_path(directory) +def _get_latest_plugin_load_at(main: DLMainWindow) -> datetime: + """Return the most recent relevant plugin load timestamp.""" + timestamps = [ + value + for value in ( + getattr(main, "started_at", None), + getattr(main, "plugins_last_load_at", None), + ) + if isinstance(value, datetime) + ] + if timestamps: + return max(timestamps) + return datetime.now().astimezone() + + +def _format_last_load_text( + timestamp: datetime, + now: datetime | None = None, + locale: QC.QLocale | None = None, +) -> str: + """Format the last load text with today/yesterday/date semantics.""" + if now is None: + now = datetime.now(timestamp.tzinfo) if timestamp.tzinfo else datetime.now() + if locale is None: + locale = QC.QLocale.system() + + if timestamp.date() == now.date(): + day_text = _("today") + elif timestamp.date() == (now - timedelta(days=1)).date(): + day_text = _("yesterday") + else: + day_text = locale.toString( + QC.QDate(timestamp.year, timestamp.month, timestamp.day), + QC.QLocale.ShortFormat, + ) + + time_text = locale.toString( + QC.QTime(timestamp.hour, timestamp.minute), + "HH:mm", + ) + return _("Last loaded: %s at %s") % (day_text, time_text) + + class PluginState: """Plugin state enumeration""" @@ -445,6 +489,7 @@ def __init__(self, parent: DLMainWindow): self.failed_plugin_widgets: list[FailedPluginInfoWidget] = [] self.toggle_all_checkbox: QW.QCheckBox | None = None self.filter_combo: QW.QComboBox | None = None + self.load_info_label: QW.QLabel | None = None self.plugins_layout: QW.QVBoxLayout | None = None self.setWindowTitle(_("Plugin Configuration")) @@ -471,7 +516,8 @@ def __init__(self, parent: DLMainWindow): ) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) - layout.addWidget(button_box) + layout.addLayout(self._create_footer_layout(button_box)) + self._update_load_info_label() @staticmethod def _create_title_label() -> QW.QLabel: @@ -630,6 +676,23 @@ def _create_scroll_area(self) -> QW.QScrollArea: scroll.setWidget(container) return scroll + def _create_footer_layout(self, button_box: QW.QDialogButtonBox) -> QW.QHBoxLayout: + """Create the footer with the last-load label and dialog buttons.""" + footer_layout = QW.QHBoxLayout() + self.load_info_label = QW.QLabel() + apply_subdued_color(self.load_info_label) + footer_layout.addWidget(self.load_info_label) + footer_layout.addStretch() + footer_layout.addWidget(button_box) + return footer_layout + + def _update_load_info_label(self) -> None: + """Update the text describing the latest plugin load time.""" + if self.load_info_label is None: + return + latest_load = _get_latest_plugin_load_at(self.main) + self.load_info_label.setText(_format_last_load_text(latest_load)) + def set_all_enabled(self, enabled: bool) -> None: """Set all plugin checkboxes to the same state.""" for widget in self.plugin_widgets: @@ -672,6 +735,7 @@ def _apply_filter(self) -> None: def populate_plugins(self): """Populate the dialog with all discovered plugins""" + self._update_load_info_label() registered_names = {p.info.name for p in PluginRegistry.get_plugins()} enabled_plugins = Conf.main.plugins_enabled_list.get(None) diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index 0e44430a..3deb6dd5 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -1702,6 +1702,16 @@ msgstr "Création des formes géométriques" msgid "Creating plot items" msgstr "Création des objets graphiques" +msgid "today" +msgstr "aujourd'hui" + +msgid "yesterday" +msgstr "hier" + +#, python-format +msgid "Last loaded: %s at %s" +msgstr "Dernier chargement : %s à %s" + msgid "Active" msgstr "Actif" diff --git a/datalab/tests/features/plugins/pluginconfig_dialog_test.py b/datalab/tests/features/plugins/pluginconfig_dialog_test.py index e70df944..a5ea8a5e 100644 --- a/datalab/tests/features/plugins/pluginconfig_dialog_test.py +++ b/datalab/tests/features/plugins/pluginconfig_dialog_test.py @@ -4,6 +4,8 @@ from __future__ import annotations +from datetime import datetime + from qtpy import QtCore as QC from qtpy import QtWidgets as QW @@ -216,6 +218,65 @@ def test_plugin_enable_disable_config(): Conf.main.plugins_enabled_list.remove() +def test_last_load_text_uses_today_yesterday_or_date(): + """Last load label should use relative day words when applicable.""" + + class DummyLocale: + """Deterministic locale stub for refresh label formatting.""" + + locale = DummyLocale() + locale.toString = lambda value, _format: ( + f"{value.year():04d}-{value.month():02d}-{value.day():02d}" + if isinstance(value, QC.QDate) + else f"{value.hour():02d}:{value.minute():02d}" + ) + now = datetime(2026, 5, 19, 15, 30) + + today_text = pluginconfig._format_last_load_text( + datetime(2026, 5, 19, 9, 45), now=now, locale=locale + ) + yesterday_text = pluginconfig._format_last_load_text( + datetime(2026, 5, 18, 23, 10), now=now, locale=locale + ) + older_timestamp = datetime(2026, 5, 17, 8, 5) + older_text = pluginconfig._format_last_load_text( + older_timestamp, now=now, locale=locale + ) + + assert today_text == "Last loaded: today at 09:45" + assert yesterday_text == "Last loaded: yesterday at 23:10" + assert "today" not in older_text + assert "yesterday" not in older_text + assert older_text.endswith("08:05") + assert "2026-05-17" in older_text + + +def test_plugin_dialog_shows_latest_load_text(monkeypatch): + """Dialog should display the most recent timestamp between startup and load.""" + + def _format_load_marker(timestamp, now=None, locale=None): + del now, locale + return f"Last loaded marker: {timestamp.hour:02d}:{timestamp.minute:02d}" + + monkeypatch.setattr( + pluginconfig, + "_format_last_load_text", + _format_load_marker, + ) + + with datalab_test_app_context(console=False) as win: + win.started_at = datetime(2026, 5, 19, 9, 0) + win.plugins_last_load_at = datetime(2026, 5, 19, 11, 30) + + dialog = PluginConfigDialog(win) + _show_dialog(dialog) + + assert dialog.load_info_label is not None + assert dialog.load_info_label.text() == "Last loaded marker: 11:30" + + _close_dialog(dialog) + + def test_plugin_many_actions_menu_behavior(): """Test plugin with many actions in dropdown menu.""" main_config = Conf.to_dict().get("main", {}) From 3873686f40b5c137e88501d212a8578289e52582 Mon Sep 17 00:00:00 2001 From: Stefano Pierini Date: Tue, 19 May 2026 16:11:39 +0200 Subject: [PATCH 04/12] Address pylint remarks Exposes the load-status text formatter so it can be exercised directly and mocked more cleanly in UI tests. Also reduces duplicated test literals and simplifies locale stubbing, which makes the plugin configuration tests easier to read and maintain. --- datalab/gui/pluginconfig.py | 4 +- .../plugins/pluginconfig_dialog_test.py | 56 ++++++++++--------- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/datalab/gui/pluginconfig.py b/datalab/gui/pluginconfig.py index d1227f65..f149d19d 100644 --- a/datalab/gui/pluginconfig.py +++ b/datalab/gui/pluginconfig.py @@ -145,7 +145,7 @@ def _get_latest_plugin_load_at(main: DLMainWindow) -> datetime: return datetime.now().astimezone() -def _format_last_load_text( +def format_last_load_text( timestamp: datetime, now: datetime | None = None, locale: QC.QLocale | None = None, @@ -691,7 +691,7 @@ def _update_load_info_label(self) -> None: if self.load_info_label is None: return latest_load = _get_latest_plugin_load_at(self.main) - self.load_info_label.setText(_format_last_load_text(latest_load)) + self.load_info_label.setText(format_last_load_text(latest_load)) def set_all_enabled(self, enabled: bool) -> None: """Set all plugin checkboxes to the same state.""" diff --git a/datalab/tests/features/plugins/pluginconfig_dialog_test.py b/datalab/tests/features/plugins/pluginconfig_dialog_test.py index a5ea8a5e..98b00e35 100644 --- a/datalab/tests/features/plugins/pluginconfig_dialog_test.py +++ b/datalab/tests/features/plugins/pluginconfig_dialog_test.py @@ -82,6 +82,8 @@ def _create_plugin_description_widget( def test_plugin_enable_disable_config(): """Test plugin enable/disable filtering and configuration dialog.""" + plugin_1_name = "Test Plugin 1" + plugin_2_name = "Test Plugin 2" main_config = Conf.to_dict().get("main", {}) had_config = "plugins_enabled_list" in main_config original_enabled_list = Conf.main.plugins_enabled_list.get(None) @@ -95,7 +97,7 @@ def test_plugin_enable_disable_config(): plugin_dir, "datalab_test_plugin_1.py", "TestPluginOne", - "Test Plugin 1", + plugin_1_name, "Action One", "action_1", ) @@ -103,7 +105,7 @@ def test_plugin_enable_disable_config(): plugin_dir, "datalab_test_plugin_2.py", "TestPluginTwo", - "Test Plugin 2", + plugin_2_name, "Action Two", "action_2", ) @@ -117,17 +119,17 @@ def test_plugin_enable_disable_config(): widget.plugin_class.PLUGIN_INFO.name for widget in dialog.plugin_widgets ] - assert "Test Plugin 1" in widget_names - assert "Test Plugin 2" in widget_names + assert plugin_1_name in widget_names + assert plugin_2_name in widget_names plugin_1_widget = next( widget for widget in dialog.plugin_widgets - if widget.plugin_class.PLUGIN_INFO.name == "Test Plugin 1" + if widget.plugin_class.PLUGIN_INFO.name == plugin_1_name ) plugin_2_widget = next( widget for widget in dialog.plugin_widgets - if widget.plugin_class.PLUGIN_INFO.name == "Test Plugin 2" + if widget.plugin_class.PLUGIN_INFO.name == plugin_2_name ) assert plugin_1_widget.plugin_filepath == plugin_1_path assert plugin_2_widget.plugin_filepath == plugin_2_path @@ -146,7 +148,7 @@ def test_plugin_enable_disable_config(): ] == [] _close_dialog(dialog) - Conf.main.plugins_enabled_list.set(["Test Plugin 1"]) + Conf.main.plugins_enabled_list.set([plugin_1_name]) win.reload_plugins() QW.QApplication.processEvents() @@ -157,7 +159,7 @@ def test_plugin_enable_disable_config(): for widget in dialog2.plugin_widgets if widget.checkbox.isChecked() ] - assert enabled_names == ["Test Plugin 1"] + assert enabled_names == [plugin_1_name] assert dialog2.toggle_all_checkbox.checkState() == ( QC.Qt.PartiallyChecked ) @@ -169,8 +171,8 @@ def test_plugin_enable_disable_config(): for widget in dialog2.plugin_widgets if widget.isVisible() ] - assert "Test Plugin 1" in visible_enabled_names - assert "Test Plugin 2" not in visible_enabled_names + assert plugin_1_name in visible_enabled_names + assert plugin_2_name not in visible_enabled_names dialog2.filter_combo.setCurrentIndex(2) QW.QApplication.processEvents() @@ -179,13 +181,13 @@ def test_plugin_enable_disable_config(): for widget in dialog2.plugin_widgets if widget.isVisible() ] - assert "Test Plugin 2" in visible_disabled_names - assert "Test Plugin 1" not in visible_disabled_names + assert plugin_2_name in visible_disabled_names + assert plugin_1_name not in visible_disabled_names plugin_2_widget = next( widget for widget in dialog2.plugin_widgets - if widget.plugin_class.PLUGIN_INFO.name == "Test Plugin 2" + if widget.plugin_class.PLUGIN_INFO.name == plugin_2_name ) plugin_2_widget.checkbox.setChecked(True) QW.QApplication.processEvents() @@ -194,7 +196,7 @@ def test_plugin_enable_disable_config(): for widget in dialog2.plugin_widgets if widget.isVisible() ] - assert "Test Plugin 2" in visible_disabled_names + assert plugin_2_name in visible_disabled_names dialog2.filter_combo.setCurrentIndex(1) QW.QApplication.processEvents() @@ -203,7 +205,7 @@ def test_plugin_enable_disable_config(): for widget in dialog2.plugin_widgets if widget.isVisible() ] - assert "Test Plugin 2" not in visible_enabled_names + assert plugin_2_name not in visible_enabled_names dialog2.set_all_enabled(True) QW.QApplication.processEvents() @@ -221,25 +223,25 @@ def test_plugin_enable_disable_config(): def test_last_load_text_uses_today_yesterday_or_date(): """Last load label should use relative day words when applicable.""" - class DummyLocale: - """Deterministic locale stub for refresh label formatting.""" + def locale_to_string(value, _format): + """Return deterministic date/time strings for label tests.""" + return ( + f"{value.year():04d}-{value.month():02d}-{value.day():02d}" + if isinstance(value, QC.QDate) + else f"{value.hour():02d}:{value.minute():02d}" + ) - locale = DummyLocale() - locale.toString = lambda value, _format: ( - f"{value.year():04d}-{value.month():02d}-{value.day():02d}" - if isinstance(value, QC.QDate) - else f"{value.hour():02d}:{value.minute():02d}" - ) + locale = type("DummyLocale", (), {"toString": staticmethod(locale_to_string)})() now = datetime(2026, 5, 19, 15, 30) - today_text = pluginconfig._format_last_load_text( + today_text = pluginconfig.format_last_load_text( datetime(2026, 5, 19, 9, 45), now=now, locale=locale ) - yesterday_text = pluginconfig._format_last_load_text( + yesterday_text = pluginconfig.format_last_load_text( datetime(2026, 5, 18, 23, 10), now=now, locale=locale ) older_timestamp = datetime(2026, 5, 17, 8, 5) - older_text = pluginconfig._format_last_load_text( + older_text = pluginconfig.format_last_load_text( older_timestamp, now=now, locale=locale ) @@ -260,7 +262,7 @@ def _format_load_marker(timestamp, now=None, locale=None): monkeypatch.setattr( pluginconfig, - "_format_last_load_text", + "format_last_load_text", _format_load_marker, ) From 1cc547d7d2f0de13b5426cfacd23ca5f7aaf21bc Mon Sep 17 00:00:00 2001 From: Stefano Pierini Date: Wed, 20 May 2026 11:58:39 +0200 Subject: [PATCH 05/12] Adds persistent plugin path management Moves third-party plugin directory management into the plugin dialog so users can add, edit, and remove multiple saved search paths in one place. Improves startup discovery by normalizing paths, deduplicating entries, filtering out the built-in location, and migrating older saved values for backward compatibility. Keeps default and environment-provided directories visible as read-only entries, and removes the obsolete single-path setting from general settings. --- datalab/config.py | 52 ++++- datalab/gui/pluginconfig.py | 212 +++++++++++++++--- datalab/gui/settings.py | 10 - datalab/locale/fr/LC_MESSAGES/datalab.po | 38 ++-- datalab/plugins.py | 22 +- .../plugins/launch_with_test_plugins.py | 17 +- .../plugins/pluginconfig_dialog_test.py | 130 ++++++++++- 7 files changed, 411 insertions(+), 70 deletions(-) diff --git a/datalab/config.py b/datalab/config.py index 68996035..304146c2 100644 --- a/datalab/config.py +++ b/datalab/config.py @@ -520,6 +520,56 @@ class Conf(conf.Configuration, metaclass=conf.ConfMeta): ai = AISection() +def normalize_plugin_paths(paths: list[str] | tuple[str, ...] | None) -> list[str]: + """Normalize a list of plugin directories and drop duplicates/empties.""" + normalized: list[str] = [] + seen: set[str] = set() + for raw_path in paths or []: + if not raw_path: + continue + path = osp.normpath(osp.abspath(osp.expanduser(raw_path))) + if path in seen: + continue + seen.add(path) + normalized.append(path) + return normalized + + +def get_user_plugin_paths() -> list[str]: + """Return user-configured extra plugin directories. + + ``plugins_path`` accepts both the legacy single-directory string and the + newer list-of-directories form. For backward compatibility, this helper also + migrates any stale ``plugins_extra_paths`` value that may already exist in + the configuration file. + """ + fixed_default = osp.normpath(Conf.get_path("plugins")) + configured_paths = Conf.main.plugins_path.get([]) + legacy_extra_paths = conf.CONF.get("main", "plugins_extra_paths", []) + + if isinstance(configured_paths, str): + candidates = [configured_paths] + elif configured_paths is None: + candidates = [] + else: + candidates = list(configured_paths) + + if isinstance(legacy_extra_paths, str): + legacy_extra_paths = [legacy_extra_paths] + elif legacy_extra_paths is None: + legacy_extra_paths = [] + + normalized = normalize_plugin_paths(candidates + list(legacy_extra_paths)) + return [path for path in normalized if path != fixed_default] + + +def set_user_plugin_paths(paths: list[str] | tuple[str, ...]) -> None: + """Persist user-configured extra plugin directories in ``plugins_path``.""" + normalized = normalize_plugin_paths(list(paths)) + Conf.main.plugins_path.set(normalized) + conf.CONF.remove_option("main", "plugins_extra_paths") + + def get_old_log_fname(fname): """Return old log fname from current log fname""" return osp.splitext(fname)[0] + ".1.log" @@ -550,7 +600,7 @@ def initialize(): Conf.main.plugins_enabled_list.get( None ) # None = all enabled, [] = none, list = specific - Conf.main.plugins_path.get(Conf.get_path("plugins")) + Conf.main.plugins_path.get([]) Conf.main.tour_enabled.get(True) Conf.main.v020_plugins_warning_ignore.get(False) # Console section diff --git a/datalab/gui/pluginconfig.py b/datalab/gui/pluginconfig.py index f149d19d..5554b826 100644 --- a/datalab/gui/pluginconfig.py +++ b/datalab/gui/pluginconfig.py @@ -23,6 +23,7 @@ from qtpy import QtCore as QC from qtpy import QtGui as QG from qtpy import QtWidgets as QW +from qtpy.compat import getexistingdirectory from datalab.config import ( DATALAB_PLUGINS_ENV_PATHS, @@ -32,6 +33,9 @@ PLUGIN_OK_COLOR, Conf, _, + get_user_plugin_paths, + normalize_plugin_paths, + set_user_plugin_paths, ) from datalab.plugins import PLUGINS_DEFAULT_PATH, PluginRegistry from datalab.widgets.expandabletext import ( @@ -473,6 +477,52 @@ def matches_filter(filter_mode: str) -> bool: return filter_mode in (FILTER_ALL, FILTER_ERRORS) +class SearchPathItemWidget(QW.QWidget): + """Widget representing one plugin search path.""" + + def __init__( + self, + path: str, + *, + editable: bool, + from_env: bool = False, + parent: QW.QWidget | None = None, + ) -> None: + super().__init__(parent) + self.path = path + self.edit_button: QW.QToolButton | None = None + self.delete_button: QW.QToolButton | None = None + + layout = QW.QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + self.setLayout(layout) + + path_label = QW.QLabel() + path_label.setTextInteractionFlags(QC.Qt.TextBrowserInteraction) + path_label.setOpenExternalLinks(True) + path_label.setWordWrap(True) + url = QC.QUrl.fromLocalFile(path).toString() + text = f'{path}' + if from_env: + text += f" ({_('from')} {DATALAB_PLUGINS_ENV_VAR})" + path_label.setText(text) + layout.addWidget(path_label, 1) + + if editable: + self.edit_button = QW.QToolButton() + self.edit_button.setIcon(get_icon("annotations_edit.svg")) + self.edit_button.setToolTip(_("Edit directory")) + self.edit_button.setAutoRaise(True) + layout.addWidget(self.edit_button) + + self.delete_button = QW.QToolButton() + self.delete_button.setIcon(get_icon("annotations_delete.svg")) + self.delete_button.setToolTip(_("Remove directory")) + self.delete_button.setAutoRaise(True) + layout.addWidget(self.delete_button) + + class PluginConfigDialog(QW.QDialog): """Dialog for configuring plugins""" @@ -487,10 +537,17 @@ def __init__(self, parent: DLMainWindow): self.main = parent self.plugin_widgets: list[PluginInfoWidget] = [] self.failed_plugin_widgets: list[FailedPluginInfoWidget] = [] + self.fixed_path_widgets: list[SearchPathItemWidget] = [] + self.extra_path_widgets: list[SearchPathItemWidget] = [] + self.original_extra_plugin_paths = get_user_plugin_paths() + self.extra_plugin_paths = list(self.original_extra_plugin_paths) self.toggle_all_checkbox: QW.QCheckBox | None = None self.filter_combo: QW.QComboBox | None = None self.load_info_label: QW.QLabel | None = None self.plugins_layout: QW.QVBoxLayout | None = None + self.extra_paths_layout: QW.QVBoxLayout | None = None + self.extra_paths_placeholder: QW.QLabel | None = None + self.add_path_button: QW.QPushButton | None = None self.setWindowTitle(_("Plugin Configuration")) self.setMinimumWidth(DIALOG_MIN_WIDTH) @@ -573,8 +630,8 @@ def _create_info_label() -> QW.QLabel: return info_label @staticmethod - def _collect_search_paths() -> list[tuple[str, bool]]: - """Return active plugin search paths with their env-var origin flag. + def _collect_fixed_search_paths() -> list[tuple[str, bool]]: + """Return fixed plugin search paths with their env-var origin flag. Returns: List of ``(path, from_env_var)`` tuples in discovery order. @@ -582,11 +639,7 @@ def _collect_search_paths() -> list[tuple[str, bool]]: seen: set[str] = set() entries: list[tuple[str, bool]] = [] env_paths_norm = {osp.normpath(p) for p in DATALAB_PLUGINS_ENV_PATHS} - candidates: list[str] = [] - custom = Conf.main.plugins_path.get() - if custom: - candidates.append(custom) - candidates.append(PLUGINS_DEFAULT_PATH) + candidates = [PLUGINS_DEFAULT_PATH] candidates.extend(OTHER_PLUGINS_PATHLIST) for raw in candidates: if not raw: @@ -599,7 +652,7 @@ def _collect_search_paths() -> list[tuple[str, bool]]: return entries def _create_search_paths_layout(self) -> QW.QVBoxLayout: - """Create the layout listing active plugin search paths.""" + """Create the layout listing plugin search paths.""" paths_layout = QW.QVBoxLayout() intro = QW.QLabel( @@ -608,31 +661,61 @@ def _create_search_paths_layout(self) -> QW.QVBoxLayout: intro.setWordWrap(True) paths_layout.addWidget(intro) - items: list[str] = [] - for path, from_env in self._collect_search_paths(): - url = QC.QUrl.fromLocalFile(path).toString() - link = f'{path}' - if from_env: - link += f" ({_('from')} {DATALAB_PLUGINS_ENV_VAR})" - items.append(f"
  • {link}
  • ") - if items: - html = ( - "
      " + "".join(items) + "
    " - ) - else: - html = "" + _("No plugin search path is currently active.") + "" + fixed_title = QW.QLabel(_("Default plugin directories")) + fixed_font = fixed_title.font() + fixed_font.setBold(True) + fixed_title.setFont(fixed_font) + paths_layout.addWidget(fixed_title) + + fixed_layout = QW.QVBoxLayout() + fixed_layout.setContentsMargins(18, 0, 0, 0) + fixed_layout.setSpacing(4) + for path, from_env in self._collect_fixed_search_paths(): + widget = SearchPathItemWidget(path, editable=False, from_env=from_env) + self.fixed_path_widgets.append(widget) + fixed_layout.addWidget(widget) + paths_layout.addLayout(fixed_layout) + + paths_layout.addSpacing(12) + + header_layout = QW.QHBoxLayout() + extra_title = QW.QLabel(_("Additional plugin directories")) + extra_font = extra_title.font() + extra_font.setBold(True) + extra_title.setFont(extra_font) + header_layout.addWidget(extra_title) + header_layout.addStretch() + self.add_path_button = QW.QPushButton(get_icon("metadata_add.svg"), _("Add")) + self.add_path_button.clicked.connect(self._add_search_path) + header_layout.addWidget(self.add_path_button) + paths_layout.addLayout(header_layout) + + extra_intro = QW.QLabel( + _("Additional directories are saved in DataLab configuration.") + ) + extra_intro.setWordWrap(True) + apply_subdued_color(extra_intro) + paths_layout.addWidget(extra_intro) + + self.extra_paths_layout = QW.QVBoxLayout() + self.extra_paths_layout.setContentsMargins(18, 0, 0, 0) + self.extra_paths_layout.setSpacing(4) + paths_layout.addLayout(self.extra_paths_layout) - paths_label = QW.QLabel(html) - paths_label.setTextInteractionFlags(QC.Qt.TextBrowserInteraction) - paths_label.setOpenExternalLinks(True) - paths_label.setWordWrap(True) - paths_layout.addWidget(paths_label) + self.extra_paths_placeholder = QW.QLabel( + _("No additional plugin directory is configured.") + ) + self.extra_paths_placeholder.setWordWrap(True) + apply_subdued_color(self.extra_paths_placeholder) + self.extra_paths_layout.addWidget(self.extra_paths_placeholder) + self._refresh_extra_path_widgets() hint = QW.QLabel( _( - "Additional directories can be added via the " + "Directories provided via the " "%s environment variable " - "(multiple paths separated by '%s'). " + "(multiple paths separated by '%s') " + "also appear above as read-only entries. " "Changes take effect at DataLab startup." ) % (DATALAB_PLUGINS_ENV_VAR, os.pathsep) @@ -642,6 +725,72 @@ def _create_search_paths_layout(self) -> QW.QVBoxLayout: paths_layout.addWidget(hint) return paths_layout + def _refresh_extra_path_widgets(self) -> None: + """Rebuild the editable extra-path widgets.""" + if self.extra_paths_layout is None: + return + + for widget in self.extra_path_widgets: + self.extra_paths_layout.removeWidget(widget) + widget.deleteLater() + self.extra_path_widgets.clear() + + if self.extra_paths_placeholder is not None: + self.extra_paths_placeholder.setVisible(not self.extra_plugin_paths) + + for path in self.extra_plugin_paths: + widget = SearchPathItemWidget(path, editable=True) + assert widget.edit_button is not None + assert widget.delete_button is not None + widget.edit_button.clicked.connect( + lambda _checked=False, item=widget: self._edit_search_path(item) + ) + widget.delete_button.clicked.connect( + lambda _checked=False, item=widget: self._remove_search_path(item) + ) + self.extra_path_widgets.append(widget) + self.extra_paths_layout.addWidget(widget) + + def _browse_plugin_directory(self, initial_path: str | None = None) -> str | None: + """Open a directory chooser for a plugin search path.""" + basedir = initial_path or Conf.main.base_dir.get(osp.expanduser("~")) + directory = getexistingdirectory(self, _("Select plugin directory"), basedir) + normalized = normalize_plugin_paths([directory]) + return normalized[0] if normalized else None + + def _get_fixed_search_path_set(self) -> set[str]: + """Return the normalized set of fixed plugin search paths.""" + return {path for path, _from_env in self._collect_fixed_search_paths()} + + def _add_search_path(self) -> None: + """Append a new extra plugin search path.""" + path = self._browse_plugin_directory() + if not path: + return + if path in self._get_fixed_search_path_set() or path in self.extra_plugin_paths: + return + self.extra_plugin_paths.append(path) + self._refresh_extra_path_widgets() + + def _edit_search_path(self, item: SearchPathItemWidget) -> None: + """Edit an existing extra plugin search path.""" + path = self._browse_plugin_directory(item.path) + if not path: + return + other_paths = [entry for entry in self.extra_plugin_paths if entry != item.path] + if path in self._get_fixed_search_path_set() or path in other_paths: + return + index = self.extra_plugin_paths.index(item.path) + self.extra_plugin_paths[index] = path + self._refresh_extra_path_widgets() + + def _remove_search_path(self, item: SearchPathItemWidget) -> None: + """Remove an extra plugin search path.""" + self.extra_plugin_paths = [ + path for path in self.extra_plugin_paths if path != item.path + ] + self._refresh_extra_path_widgets() + def _create_controls_layout(self) -> QW.QHBoxLayout: """Create the row with master controls.""" controls_layout = QW.QHBoxLayout() @@ -774,7 +923,11 @@ def _add_plugin_widgets( def accept(self): """Apply changes and close dialog""" # Check if any changes were made - changes_made = any(widget.has_changed() for widget in self.plugin_widgets) + plugin_changes_made = any( + widget.has_changed() for widget in self.plugin_widgets + ) + path_changes_made = self.extra_plugin_paths != self.original_extra_plugin_paths + changes_made = plugin_changes_made or path_changes_made if not changes_made: super().accept() @@ -789,6 +942,7 @@ def accept(self): # Save to configuration Conf.main.plugins_enabled_list.set(enabled_plugins) + set_user_plugin_paths(self.extra_plugin_paths) # Inform user that reload is needed reply = QW.QMessageBox.question( diff --git a/datalab/gui/settings.py b/datalab/gui/settings.py index e4680426..727a1cde 100644 --- a/datalab/gui/settings.py +++ b/datalab/gui/settings.py @@ -74,15 +74,6 @@ class MainSettings(gds.DataSet): "Changes are applied without restarting DataLab" ), ) - plugins_path = gds.DirectoryItem( - _("Plugins path"), - allow_none=True, - help=_( - "Path to third-party plugins.

    " - "DataLab will discover plugins in this path, " - "as well as in your PYTHONPATH." - ), - ) v020_plugins_warning_ignore = gds.BoolItem( _("Ignore compatibility issues warning"), _("DataLab v0.20 plugins"), @@ -613,7 +604,6 @@ def datasets_to_conf(paramdict: dict[str, gds.DataSet]) -> None: ("process_isolation_enabled", _("Process isolation enable status")), ("rpc_server_enabled", _("RPC server enable status")), ("console_enabled", _("Console enable status")), - ("plugins_path", _("Third-party plugins path")), ) diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index 3deb6dd5..412989f5 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -1730,6 +1730,12 @@ msgstr "Afficher dans le dossier" msgid "Import error" msgstr "Erreur d'importation" +msgid "Edit directory" +msgstr "Modifier le répertoire" + +msgid "Remove directory" +msgstr "Supprimer le répertoire" + msgid "Plugin Configuration" msgstr "Configuration des plugins" @@ -1748,12 +1754,27 @@ msgstr "Les modifications seront appliquées après avoir cliqué sur OK et rech msgid "The following directories are scanned at startup for plugins:" msgstr "Les répertoires suivants sont analysés au démarrage pour détecter les plugins :" -msgid "No plugin search path is currently active." -msgstr "Aucun chemin de recherche de plugins n'est actuellement actif." +msgid "Default plugin directories" +msgstr "Répertoires de plugins par défaut" + +msgid "Additional plugin directories" +msgstr "Répertoires de plugins supplémentaires" + +msgid "Add" +msgstr "Ajouter" + +msgid "Additional directories are saved in DataLab configuration." +msgstr "Les répertoires supplémentaires sont enregistrés dans la configuration de DataLab." + +msgid "No additional plugin directory is configured." +msgstr "Aucun répertoire de plugins supplémentaire n'est configuré." #, python-format -msgid "Additional directories can be added via the %s environment variable (multiple paths separated by '%s'). Changes take effect at DataLab startup." -msgstr "Des répertoires supplémentaires peuvent être ajoutés via la variable d'environnement %s (plusieurs chemins séparés par '%s'). Les modifications prennent effet au démarrage de DataLab." +msgid "Directories provided via the %s environment variable (multiple paths separated by '%s') also appear above as read-only entries. Changes take effect at DataLab startup." +msgstr "Les répertoires fournis via la variable d'environnement %s (plusieurs chemins séparés par '%s') apparaissent également ci-dessus en lecture seule. Les modifications prennent effet au démarrage de DataLab." + +msgid "Select plugin directory" +msgstr "Sélectionner un répertoire de plugins" msgid "Enable all plugins" msgstr "Activer tous les plugins" @@ -2639,12 +2660,6 @@ msgstr "Plugins tiers" msgid "Enable or disable third-party plugins immediately. Changes are applied without restarting DataLab" msgstr "Activer ou désactiver immédiatement les plugins tiers. Les changements sont appliqués sans redémarrer DataLab" -msgid "Plugins path" -msgstr "Chemin des plugins" - -msgid "Path to third-party plugins.

    DataLab will discover plugins in this path, as well as in your PYTHONPATH." -msgstr "Chemin d'accès aux plugins tiers.

    DataLab découvrira les plugins dans ce chemin, ainsi que dans votre PYTHONPATH." - msgid "Ignore compatibility issues warning" msgstr "Ignorer l'avertissement de compatibilité" @@ -2944,9 +2959,6 @@ msgstr "État d'activation du serveur XML-RPC" msgid "Console enable status" msgstr "État d'activation de la console" -msgid "Third-party plugins path" -msgstr "Chemin des plugins tiers" - msgid "General" msgstr "Général" diff --git a/datalab/plugins.py b/datalab/plugins.py index e54d3ff4..a1750bdf 100644 --- a/datalab/plugins.py +++ b/datalab/plugins.py @@ -40,7 +40,13 @@ from sigima.io.image.formats import ClassicsImageFormat # noqa: F401 from sigima.io.signal.base import SignalFormatBase # noqa: F401 -from datalab.config import MOD_NAME, OTHER_PLUGINS_PATHLIST, Conf, _ +from datalab.config import ( + MOD_NAME, + OTHER_PLUGINS_PATHLIST, + Conf, + _, + get_user_plugin_paths, +) from datalab.control.proxy import LocalProxy from datalab.env import execenv @@ -399,10 +405,9 @@ def discover_plugins() -> list[type[PluginBase]]: return [] # Ensure plugin search paths are present in sys.path - for path in [ - Conf.main.plugins_path.get(), - PLUGINS_DEFAULT_PATH, - ] + OTHER_PLUGINS_PATHLIST: + for path in ( + get_user_plugin_paths() + [PLUGINS_DEFAULT_PATH] + OTHER_PLUGINS_PATHLIST + ): rpath = osp.realpath(path) if rpath not in sys.path: sys.path.append(rpath) @@ -477,10 +482,9 @@ def discover_v020_plugins() -> list[tuple[str, str]]: """ v020_plugins = [] if Conf.main.plugins_enabled.get(): - for path in [ - Conf.main.plugins_path.get(), - PLUGINS_DEFAULT_PATH, - ] + OTHER_PLUGINS_PATHLIST: + for path in ( + get_user_plugin_paths() + [PLUGINS_DEFAULT_PATH] + OTHER_PLUGINS_PATHLIST + ): rpath = osp.realpath(path) if rpath not in sys.path: sys.path.append(rpath) diff --git a/datalab/tests/features/plugins/launch_with_test_plugins.py b/datalab/tests/features/plugins/launch_with_test_plugins.py index f0b7279c..aaf5b091 100644 --- a/datalab/tests/features/plugins/launch_with_test_plugins.py +++ b/datalab/tests/features/plugins/launch_with_test_plugins.py @@ -53,11 +53,14 @@ def _get_enabled_plugins_option(conf_class): def main(): """Create manual test plugins, launch DataLab, then clean them up.""" - conf_class = import_module("datalab.config").Conf + config_module = import_module("datalab.config") + conf_class = config_module.Conf + get_user_plugin_paths = config_module.get_user_plugin_paths + set_user_plugin_paths = config_module.set_user_plugin_paths enabled_plugins_option = _get_enabled_plugins_option(conf_class) - # Save the original plugins_path before any modification - original_path = conf_class.main.plugins_path.get() + # Save the original extra plugin search paths before any modification. + original_paths = get_user_plugin_paths() original_enabled_list = None if enabled_plugins_option is not None: original_enabled_list = enabled_plugins_option.get(None) @@ -98,12 +101,12 @@ def main(): print("\nLaunching DataLab...") print("=" * 70 + "\n") - # Configure: enable all plugins, point plugins_path to the managed - # dataset under datalab/data/tests. The original path (if any) is + # Configure: enable all plugins, point plugin search paths to the managed + # dataset under datalab/data/tests. The original paths (if any) are # restored afterwards. if enabled_plugins_option is not None: enabled_plugins_option.set(None) - conf_class.main.plugins_path.set(plugin_dir) + set_user_plugin_paths([plugin_dir]) # Launch DataLab run = import_module("datalab.app").run @@ -113,7 +116,7 @@ def main(): finally: if enabled_plugins_option is not None: enabled_plugins_option.set(original_enabled_list) - conf_class.main.plugins_path.set(original_path) + set_user_plugin_paths(original_paths) clear_plugin_directory( plugin_dir, module_prefixes=MANUAL_PLUGIN_MODULE_PREFIXES, diff --git a/datalab/tests/features/plugins/pluginconfig_dialog_test.py b/datalab/tests/features/plugins/pluginconfig_dialog_test.py index 98b00e35..852c5307 100644 --- a/datalab/tests/features/plugins/pluginconfig_dialog_test.py +++ b/datalab/tests/features/plugins/pluginconfig_dialog_test.py @@ -9,7 +9,12 @@ from qtpy import QtCore as QC from qtpy import QtWidgets as QW -from datalab.config import Conf +from datalab.config import ( + OTHER_PLUGINS_PATHLIST, + Conf, + get_user_plugin_paths, + set_user_plugin_paths, +) from datalab.env import execenv from datalab.gui import pluginconfig from datalab.gui.actionhandler import ActionCategory @@ -279,6 +284,129 @@ def _format_load_marker(timestamp, now=None, locale=None): _close_dialog(dialog) +def test_plugin_search_paths_can_be_added_edited_removed_and_persisted( + monkeypatch, tmp_path +): + """Search path tab should manage persistent extra plugin directories.""" + added_dir = tmp_path / "plugins_added" + edited_dir = tmp_path / "plugins_edited" + kept_dir = tmp_path / "plugins_kept" + for directory in (added_dir, edited_dir, kept_dir): + directory.mkdir() + + original_paths = get_user_plugin_paths() + + def answer_no(*args, **kwargs): + """Decline plugin reload after saving configuration.""" + del args, kwargs + return QW.QMessageBox.No + + try: + set_user_plugin_paths([]) + with datalab_test_app_context(console=False) as win: + dialog = PluginConfigDialog(win) + _show_dialog(dialog) + + assert len(dialog.fixed_path_widgets) >= 2 + assert all( + widget.edit_button is None and widget.delete_button is None + for widget in dialog.fixed_path_widgets + ) + assert dialog.add_path_button is not None + assert not dialog.extra_path_widgets + + selected_paths = iter([str(added_dir), str(edited_dir), str(kept_dir)]) + + def browse_directory(_initial_path=None): + """Return deterministic directories for add/edit actions.""" + return next(selected_paths) + + monkeypatch.setattr(dialog, "_browse_plugin_directory", browse_directory) + monkeypatch.setattr(QW.QMessageBox, "question", answer_no) + + dialog.add_path_button.click() + QW.QApplication.processEvents() + assert [widget.path for widget in dialog.extra_path_widgets] == [ + str(added_dir) + ] + + editable_widget = dialog.extra_path_widgets[0] + assert editable_widget.edit_button is not None + assert editable_widget.delete_button is not None + + editable_widget.edit_button.click() + QW.QApplication.processEvents() + assert [widget.path for widget in dialog.extra_path_widgets] == [ + str(edited_dir) + ] + + dialog.add_path_button.click() + QW.QApplication.processEvents() + assert [widget.path for widget in dialog.extra_path_widgets] == [ + str(edited_dir), + str(kept_dir), + ] + + dialog.extra_path_widgets[0].delete_button.click() + QW.QApplication.processEvents() + assert [widget.path for widget in dialog.extra_path_widgets] == [ + str(kept_dir) + ] + + dialog.accept() + QW.QApplication.processEvents() + + assert get_user_plugin_paths() == [str(kept_dir)] + + with datalab_test_app_context(console=False) as win: + dialog = PluginConfigDialog(win) + _show_dialog(dialog) + assert [widget.path for widget in dialog.extra_path_widgets] == [ + str(kept_dir) + ] + _close_dialog(dialog) + finally: + set_user_plugin_paths(original_paths) + + +def test_env_var_plugin_paths_appear_as_fixed_read_only_entries(monkeypatch, tmp_path): + """Environment-provided plugin directories should be visible but not editable.""" + env_dir = tmp_path / "env_plugins" + env_dir.mkdir() + env_path = str(env_dir) + original_paths = get_user_plugin_paths() + + try: + set_user_plugin_paths([]) + monkeypatch.setattr(pluginconfig, "DATALAB_PLUGINS_ENV_PATHS", [env_path]) + monkeypatch.setattr( + pluginconfig, + "OTHER_PLUGINS_PATHLIST", + OTHER_PLUGINS_PATHLIST + [env_path], + ) + + with datalab_test_app_context(console=False) as win: + dialog = PluginConfigDialog(win) + _show_dialog(dialog) + + env_widget = next( + widget + for widget in dialog.fixed_path_widgets + if widget.path == env_path + ) + assert env_widget.edit_button is None + assert env_widget.delete_button is None + assert not any( + widget.path == env_path for widget in dialog.extra_path_widgets + ) + + fixed_paths = [widget.path for widget in dialog.fixed_path_widgets] + assert env_path in fixed_paths + _close_dialog(dialog) + finally: + set_user_plugin_paths(original_paths) + + def test_plugin_many_actions_menu_behavior(): """Test plugin with many actions in dropdown menu.""" main_config = Conf.to_dict().get("main", {}) From 6e96aa13a980252708c6e096c569b059841b5438 Mon Sep 17 00:00:00 2001 From: Stefano Pierini Date: Wed, 20 May 2026 12:23:49 +0200 Subject: [PATCH 06/12] Adds in-place plugin reload action Lets users save enablement and search path changes, reload plugins immediately, and keep the configuration dialog open for verification or further edits. Also refactors shared save and change-detection logic to support the new flow and adds coverage for the apply-and-reload behavior. --- datalab/gui/pluginconfig.py | 67 ++++++++++++++----- datalab/locale/fr/LC_MESSAGES/datalab.po | 3 + .../plugins/pluginconfig_dialog_test.py | 46 +++++++++++++ 3 files changed, 98 insertions(+), 18 deletions(-) diff --git a/datalab/gui/pluginconfig.py b/datalab/gui/pluginconfig.py index 5554b826..ecedefcb 100644 --- a/datalab/gui/pluginconfig.py +++ b/datalab/gui/pluginconfig.py @@ -548,6 +548,7 @@ def __init__(self, parent: DLMainWindow): self.extra_paths_layout: QW.QVBoxLayout | None = None self.extra_paths_placeholder: QW.QLabel | None = None self.add_path_button: QW.QPushButton | None = None + self.reload_button: QW.QPushButton | None = None self.setWindowTitle(_("Plugin Configuration")) self.setMinimumWidth(DIALOG_MIN_WIDTH) @@ -571,6 +572,11 @@ def __init__(self, parent: DLMainWindow): button_box = QW.QDialogButtonBox( QW.QDialogButtonBox.Ok | QW.QDialogButtonBox.Cancel ) + self.reload_button = QW.QPushButton( + get_icon("refresh-auto.svg"), _("Apply and reload plugins") + ) + button_box.addButton(self.reload_button, QW.QDialogButtonBox.ActionRole) + self.reload_button.clicked.connect(self._apply_and_reload_plugins) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) layout.addLayout(self._create_footer_layout(button_box)) @@ -842,6 +848,47 @@ def _update_load_info_label(self) -> None: latest_load = _get_latest_plugin_load_at(self.main) self.load_info_label.setText(format_last_load_text(latest_load)) + def _has_changes(self) -> bool: + """Return whether plugin enablement or search paths changed.""" + plugin_changes_made = any( + widget.has_changed() for widget in self.plugin_widgets + ) + path_changes_made = self.extra_plugin_paths != self.original_extra_plugin_paths + return plugin_changes_made or path_changes_made + + def _save_configuration(self) -> None: + """Persist current plugin enablement and search path settings.""" + enabled_plugins = [ + widget.plugin_class.PLUGIN_INFO.name + for widget in self.plugin_widgets + if widget.is_enabled() + ] + Conf.main.plugins_enabled_list.set(enabled_plugins) + set_user_plugin_paths(self.extra_plugin_paths) + + def _refresh_plugin_list(self) -> None: + """Rebuild the plugin list from the current registry state.""" + if self.plugins_layout is None: + return + + while self.plugins_layout.count(): + item = self.plugins_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + + self.plugin_widgets.clear() + self.failed_plugin_widgets.clear() + self.populate_plugins() + + def _apply_and_reload_plugins(self) -> None: + """Save configuration, reload plugins, and keep dialog open.""" + if self._has_changes(): + self._save_configuration() + self.original_extra_plugin_paths = list(self.extra_plugin_paths) + self.main.reload_plugins() + self._refresh_plugin_list() + def set_all_enabled(self, enabled: bool) -> None: """Set all plugin checkboxes to the same state.""" for widget in self.plugin_widgets: @@ -922,27 +969,11 @@ def _add_plugin_widgets( def accept(self): """Apply changes and close dialog""" - # Check if any changes were made - plugin_changes_made = any( - widget.has_changed() for widget in self.plugin_widgets - ) - path_changes_made = self.extra_plugin_paths != self.original_extra_plugin_paths - changes_made = plugin_changes_made or path_changes_made - - if not changes_made: + if not self._has_changes(): super().accept() return - # Collect enabled plugin names - enabled_plugins = [ - widget.plugin_class.PLUGIN_INFO.name - for widget in self.plugin_widgets - if widget.is_enabled() - ] - - # Save to configuration - Conf.main.plugins_enabled_list.set(enabled_plugins) - set_user_plugin_paths(self.extra_plugin_paths) + self._save_configuration() # Inform user that reload is needed reply = QW.QMessageBox.question( diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index 412989f5..a93d512f 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -1745,6 +1745,9 @@ msgstr "Activer/désactiver les plugins" msgid "Plugin search paths" msgstr "Chemins de recherche des plugins" +msgid "Apply and reload plugins" +msgstr "Appliquer et recharger les plugins" + msgid "Manage Plugins" msgstr "Gérer les plugins" diff --git a/datalab/tests/features/plugins/pluginconfig_dialog_test.py b/datalab/tests/features/plugins/pluginconfig_dialog_test.py index 852c5307..0defa40e 100644 --- a/datalab/tests/features/plugins/pluginconfig_dialog_test.py +++ b/datalab/tests/features/plugins/pluginconfig_dialog_test.py @@ -407,6 +407,52 @@ def test_env_var_plugin_paths_appear_as_fixed_read_only_entries(monkeypatch, tmp set_user_plugin_paths(original_paths) +def test_apply_and_reload_button_keeps_dialog_open_and_saves_changes( + monkeypatch, tmp_path +): + """Apply/reload button should save changes, reload plugins, and keep dialog open.""" + added_dir = tmp_path / "plugins_added" + added_dir.mkdir() + original_paths = get_user_plugin_paths() + + try: + set_user_plugin_paths([]) + with datalab_test_app_context(console=False) as win: + dialog = PluginConfigDialog(win) + _show_dialog(dialog) + + assert dialog.reload_button is not None + assert dialog.reload_button.text() == "Apply and reload plugins" + + monkeypatch.setattr( + dialog, + "_browse_plugin_directory", + lambda _initial_path=None: str(added_dir), + ) + + reload_calls: list[bool] = [] + + def fake_reload_plugins() -> None: + """Record reload requests without closing the dialog.""" + reload_calls.append(True) + win.plugins_last_load_at = datetime(2026, 5, 20, 14, 30) + + monkeypatch.setattr(win, "reload_plugins", fake_reload_plugins) + + dialog.add_path_button.click() + QW.QApplication.processEvents() + dialog.reload_button.click() + QW.QApplication.processEvents() + + assert reload_calls == [True] + assert dialog.isVisible() + assert get_user_plugin_paths() == [str(added_dir)] + + _close_dialog(dialog) + finally: + set_user_plugin_paths(original_paths) + + def test_plugin_many_actions_menu_behavior(): """Test plugin with many actions in dropdown menu.""" main_config = Conf.to_dict().get("main", {}) From 3078beee5ab8ac9144f657ab68b4fad15f3fc864 Mon Sep 17 00:00:00 2001 From: Stefano Pierini Date: Wed, 20 May 2026 14:54:47 +0200 Subject: [PATCH 07/12] Removes redundant plugin dialog heading. --- datalab/gui/pluginconfig.py | 16 ---------------- datalab/locale/fr/LC_MESSAGES/datalab.po | 3 --- 2 files changed, 19 deletions(-) diff --git a/datalab/gui/pluginconfig.py b/datalab/gui/pluginconfig.py index ecedefcb..592e776e 100644 --- a/datalab/gui/pluginconfig.py +++ b/datalab/gui/pluginconfig.py @@ -558,8 +558,6 @@ def __init__(self, parent: DLMainWindow): layout = QW.QVBoxLayout() self.setLayout(layout) - layout.addWidget(self._create_title_label()) - tabs = QW.QTabWidget() tabs.addTab(self._create_plugins_tab(), _("Enable/disable plugins")) tabs.addTab(self._create_search_paths_tab(), _("Plugin search paths")) @@ -582,20 +580,6 @@ def __init__(self, parent: DLMainWindow): layout.addLayout(self._create_footer_layout(button_box)) self._update_load_info_label() - @staticmethod - def _create_title_label() -> QW.QLabel: - """Create the dialog title label.""" - title_label = QW.QLabel(_("Manage Plugins")) - title_font = title_label.font() - title_font.setPointSize(title_font.pointSize() + TITLE_FONT_SIZE_DELTA) - title_font.setBold(True) - title_label.setFont(title_font) - apply_palette_color( - title_label, - QW.QApplication.instance().palette().color(QG.QPalette.WindowText), - ) - return title_label - def _create_plugins_tab(self) -> QW.QWidget: """Create the 'Enable/disable plugins' tab content.""" tab = QW.QWidget() diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index a93d512f..c8354e20 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -1748,9 +1748,6 @@ msgstr "Chemins de recherche des plugins" msgid "Apply and reload plugins" msgstr "Appliquer et recharger les plugins" -msgid "Manage Plugins" -msgstr "Gérer les plugins" - msgid "Changes will be applied after clicking OK and reloading plugins." msgstr "Les modifications seront appliquées après avoir cliqué sur OK et rechargé les plugins." From 23a9aa71bd3134ffe656751ef0cd83d3c23c3442 Mon Sep 17 00:00:00 2001 From: Stefano Pierini Date: Wed, 20 May 2026 16:28:12 +0200 Subject: [PATCH 08/12] Improves plugin disable workflow. Keeps plugin configuration reachable even when third-party plugins are turned off, so users can review settings and re-enable them without going through the main settings dialog. Moves the global enable switch and compatibility warning preference into the plugin configuration UI, leaves the menu entry available, and disables only actions that cannot run while plugins are off. Preserves discovered plugins and search paths in the dialog when plugins are disabled, with clearer disabled-state messaging and safer non-clickable path links. --- datalab/gui/main.py | 43 ++- datalab/gui/pluginconfig.py | 251 ++++++++++++++++-- datalab/gui/settings.py | 18 +- datalab/locale/fr/LC_MESSAGES/datalab.po | 21 ++ .../plugins/pluginconfig_dialog_test.py | 182 +++++++++++++ 5 files changed, 455 insertions(+), 60 deletions(-) diff --git a/datalab/gui/main.py b/datalab/gui/main.py index 6f5d17ac..c4fdb87e 100644 --- a/datalab/gui/main.py +++ b/datalab/gui/main.py @@ -1042,6 +1042,10 @@ def __register_plugins(self) -> None: # None = all plugins enabled (default), [] = no plugins, list = specific plugins enabled_list = Conf.main.plugins_enabled_list.get(None) + if not Conf.main.plugins_enabled.get(): + self.plugins_last_load_at = datetime.now().astimezone() + return + for plugin_class in PluginRegistry.get_plugin_classes(): try: # Check if plugin is enabled before instantiation @@ -1122,6 +1126,11 @@ def __configure_plugins(self) -> None: dialog = PluginConfigDialog(self) dialog.exec() + def set_plugins_enabled(self, enabled: bool) -> None: + """Apply the global third-party plugin enabled state.""" + Conf.main.plugins_enabled.set(enabled) + self.__apply_plugins_enabled_setting() + def reload_plugins(self) -> None: """Reload third-party plugins at runtime. @@ -1136,8 +1145,9 @@ def reload_plugins(self) -> None: self, _("Plugins"), _( - "Third-party plugins are disabled. Enable them in the " - "Settings dialog to use this feature." + "Third-party plugins are disabled. Enable them again " + "from the plugin configuration dialog to use this " + "feature." ), ) return @@ -1249,12 +1259,11 @@ def __update_plugins_availability(self) -> None: """Update plugin-related UI according to third-party plugin setting.""" plugins_enabled = Conf.main.plugins_enabled.get() - if self.plugins_menu is not None: - self.plugins_menu.setEnabled(plugins_enabled) + if self.reload_plugins_action is not None: + self.reload_plugins_action.setEnabled(plugins_enabled) - for action in (self.reload_plugins_action, self.configure_plugins_action): - if action is not None: - action.setEnabled(plugins_enabled) + if self.configure_plugins_action is not None: + self.configure_plugins_action.setEnabled(True) if hasattr(self, "pluginstatus") and self.pluginstatus is not None: self.pluginstatus.update_status() @@ -1271,11 +1280,6 @@ def __apply_plugins_enabled_setting(self) -> None: for panel in (self.signalpanel, self.imagepanel): panel.acthandler.clear_plugin_actions() - PluginRegistry.clear_plugin_classes() - PluginRegistry.clear_failed_plugins() - PluginRegistry.clear_discovery_errors() - self._startup_errors.clear() - self.__update_actions(update_other_data_panel=True) self.__update_plugins_availability() @@ -1861,8 +1865,6 @@ def __update_actions(self, update_other_data_panel: bool = False) -> None: panel.selection_changed() self.signalpanel_toolbar.setVisible(is_signal) self.imagepanel_toolbar.setVisible(not is_signal) - if self.plugins_menu is not None: - self.plugins_menu.setEnabled(Conf.main.plugins_enabled.get()) def __tab_index_changed(self, index: int) -> None: """Switch from signal to image mode, or vice-versa""" @@ -1892,12 +1894,11 @@ def __update_generic_menu(self, menu: QW.QMenu | None = None) -> None: # no plugin has registered actions yet (so that new plugins can be # discovered after they are added on disk). if menu is self.plugins_menu: - if Conf.main.plugins_enabled.get(): - actions = list(actions) + [ - None, - self.configure_plugins_action, - self.reload_plugins_action, - ] + actions = list(actions) + [ + None, + self.configure_plugins_action, + self.reload_plugins_action, + ] add_actions(menu, actions) def __update_file_menu(self) -> None: @@ -2506,8 +2507,6 @@ def __edit_settings(self) -> None: # pylint: disable=too-many-branches,too-many self.__update_color_mode() if option == "show_console_on_error": self.__update_console_show_mode() - if option == "plugins_enabled": - self.__apply_plugins_enabled_setting() if option == "plot_toolbar_position": for dock in self.docks.values(): widget = dock.widget() diff --git a/datalab/gui/pluginconfig.py b/datalab/gui/pluginconfig.py index 592e776e..c447bf49 100644 --- a/datalab/gui/pluginconfig.py +++ b/datalab/gui/pluginconfig.py @@ -16,6 +16,7 @@ import subprocess import sys from datetime import datetime, timedelta +from html import escape from typing import TYPE_CHECKING from guidata.configtools import get_icon @@ -490,6 +491,8 @@ def __init__( ) -> None: super().__init__(parent) self.path = path + self.from_env = from_env + self.path_label: QW.QLabel | None = None self.edit_button: QW.QToolButton | None = None self.delete_button: QW.QToolButton | None = None @@ -499,14 +502,9 @@ def __init__( self.setLayout(layout) path_label = QW.QLabel() - path_label.setTextInteractionFlags(QC.Qt.TextBrowserInteraction) - path_label.setOpenExternalLinks(True) path_label.setWordWrap(True) - url = QC.QUrl.fromLocalFile(path).toString() - text = f'{path}' - if from_env: - text += f" ({_('from')} {DATALAB_PLUGINS_ENV_VAR})" - path_label.setText(text) + self.path_label = path_label + self.set_links_enabled(True) layout.addWidget(path_label, 1) if editable: @@ -522,6 +520,44 @@ def __init__( self.delete_button.setAutoRaise(True) layout.addWidget(self.delete_button) + def set_links_enabled(self, enabled: bool) -> None: + """Update link interactivity and appearance for enabled/disabled states.""" + if self.path_label is None: + return + + self.path_label.setText(self._build_path_label_text(enabled)) + if enabled: + self.path_label.setTextInteractionFlags(QC.Qt.TextBrowserInteraction) + self.path_label.setOpenExternalLinks(True) + self.path_label.setStyleSheet("") + return + + disabled_color = QW.QApplication.palette().color( + QG.QPalette.Disabled, QG.QPalette.WindowText + ) + disabled_color_name = disabled_color.name() + self.path_label.setTextInteractionFlags(QC.Qt.TextSelectableByMouse) + self.path_label.setOpenExternalLinks(False) + self.path_label.setStyleSheet(f"QLabel {{ color: {disabled_color_name}; }}") + + def _build_path_label_text(self, enabled: bool) -> str: + """Return rich text for the path label according to enabled state.""" + url = escape(QC.QUrl.fromLocalFile(self.path).toString(), quote=True) + path = escape(self.path) + if enabled: + link_text = f'{path}' + else: + disabled_color = ( + QW.QApplication.palette() + .color(QG.QPalette.Disabled, QG.QPalette.WindowText) + .name() + ) + link_text = f'{path}' + if self.from_env: + env_var = escape(DATALAB_PLUGINS_ENV_VAR) + link_text += f" ({_('from')} {env_var})" + return link_text + class PluginConfigDialog(QW.QDialog): """Dialog for configuring plugins""" @@ -539,16 +575,29 @@ def __init__(self, parent: DLMainWindow): self.failed_plugin_widgets: list[FailedPluginInfoWidget] = [] self.fixed_path_widgets: list[SearchPathItemWidget] = [] self.extra_path_widgets: list[SearchPathItemWidget] = [] + self.original_plugins_enabled = Conf.main.plugins_enabled.get(True) + self.plugins_enabled = self.original_plugins_enabled + self.original_v020_plugins_warning_ignore = ( + Conf.main.v020_plugins_warning_ignore.get(False) + ) + self.v020_plugins_warning_ignore = self.original_v020_plugins_warning_ignore self.original_extra_plugin_paths = get_user_plugin_paths() self.extra_plugin_paths = list(self.original_extra_plugin_paths) + self.tabs: QW.QTabWidget | None = None self.toggle_all_checkbox: QW.QCheckBox | None = None self.filter_combo: QW.QComboBox | None = None self.load_info_label: QW.QLabel | None = None self.plugins_layout: QW.QVBoxLayout | None = None + self.plugins_content: QW.QWidget | None = None + self.plugins_disabled_label: QW.QLabel | None = None self.extra_paths_layout: QW.QVBoxLayout | None = None self.extra_paths_placeholder: QW.QLabel | None = None + self.settings_scroll: QW.QScrollArea | None = None + self.settings_disabled_label: QW.QLabel | None = None self.add_path_button: QW.QPushButton | None = None self.reload_button: QW.QPushButton | None = None + self.global_toggle_button: QW.QPushButton | None = None + self.v020_warning_checkbox: QW.QCheckBox | None = None self.setWindowTitle(_("Plugin Configuration")) self.setMinimumWidth(DIALOG_MIN_WIDTH) @@ -558,10 +607,11 @@ def __init__(self, parent: DLMainWindow): layout = QW.QVBoxLayout() self.setLayout(layout) - tabs = QW.QTabWidget() - tabs.addTab(self._create_plugins_tab(), _("Enable/disable plugins")) - tabs.addTab(self._create_search_paths_tab(), _("Plugin search paths")) - layout.addWidget(tabs, 1) + self.tabs = QW.QTabWidget() + self.tabs.addTab(self._create_plugins_tab(), _("Enable/disable plugins")) + self.tabs.addTab(self._create_search_paths_tab(), _("Plugin settings")) + self.tabs.setCornerWidget(self._create_corner_widget(), QC.Qt.TopRightCorner) + layout.addWidget(self.tabs, 1) # Populate plugins self.populate_plugins() @@ -579,6 +629,28 @@ def __init__(self, parent: DLMainWindow): button_box.rejected.connect(self.reject) layout.addLayout(self._create_footer_layout(button_box)) self._update_load_info_label() + self._update_global_plugins_ui() + + def _create_corner_widget(self) -> QW.QWidget: + """Create the low-impact global plugin toggle shown near the tab titles.""" + container = QW.QWidget() + layout = QW.QHBoxLayout() + layout.setContentsMargins(6, 2, 0, 2) + container.setLayout(layout) + + self.global_toggle_button = QW.QPushButton() + self.global_toggle_button.setAutoDefault(False) + self.global_toggle_button.setDefault(False) + self.global_toggle_button.setCursor(QG.QCursor(QC.Qt.PointingHandCursor)) + self.global_toggle_button.setSizePolicy( + QW.QSizePolicy.Fixed, QW.QSizePolicy.Fixed + ) + self.global_toggle_button.setIconSize(QC.QSize(14, 14)) + self.global_toggle_button.setMinimumHeight(24) + self.global_toggle_button.setStyleSheet("QPushButton { padding: 2px 8px; }") + self.global_toggle_button.clicked.connect(self._toggle_global_plugins_enabled) + layout.addWidget(self.global_toggle_button) + return container def _create_plugins_tab(self) -> QW.QWidget: """Create the 'Enable/disable plugins' tab content.""" @@ -586,27 +658,51 @@ def _create_plugins_tab(self) -> QW.QWidget: tab_layout = QW.QVBoxLayout() tab.setLayout(tab_layout) tab_layout.addWidget(self._create_info_label()) - tab_layout.addLayout(self._create_controls_layout()) - tab_layout.addWidget(self._create_scroll_area(), 1) + + self.plugins_disabled_label = QW.QLabel( + _("Third-party plugins are globally disabled.") + ) + self.plugins_disabled_label.setWordWrap(True) + apply_subdued_color(self.plugins_disabled_label) + self.plugins_disabled_label.hide() + tab_layout.addWidget(self.plugins_disabled_label) + + self.plugins_content = QW.QWidget() + content_layout = QW.QVBoxLayout() + content_layout.setContentsMargins(0, 0, 0, 0) + self.plugins_content.setLayout(content_layout) + content_layout.addLayout(self._create_controls_layout()) + content_layout.addWidget(self._create_scroll_area(), 1) + tab_layout.addWidget(self.plugins_content, 1) return tab def _create_search_paths_tab(self) -> QW.QWidget: - """Create the 'Plugin search paths' tab content (scrollable).""" + """Create the 'Plugin settings' tab content (scrollable).""" tab = QW.QWidget() tab_layout = QW.QVBoxLayout() tab_layout.setContentsMargins(0, 0, 0, 0) tab.setLayout(tab_layout) - scroll = QW.QScrollArea() - scroll.setWidgetResizable(True) - scroll.setHorizontalScrollBarPolicy(QC.Qt.ScrollBarAlwaysOff) + self.settings_disabled_label = QW.QLabel( + _("Third-party plugins are globally disabled.") + ) + self.settings_disabled_label.setWordWrap(True) + apply_subdued_color(self.settings_disabled_label) + self.settings_disabled_label.hide() + tab_layout.addWidget(self.settings_disabled_label) + + self.settings_scroll = QW.QScrollArea() + self.settings_scroll.setWidgetResizable(True) + self.settings_scroll.setHorizontalScrollBarPolicy(QC.Qt.ScrollBarAlwaysOff) container = QW.QWidget() container_layout = QW.QVBoxLayout() container.setLayout(container_layout) container_layout.addLayout(self._create_search_paths_layout()) + container_layout.addSpacing(18) + container_layout.addLayout(self._create_warning_settings_layout()) container_layout.addStretch() - scroll.setWidget(container) - tab_layout.addWidget(scroll) + self.settings_scroll.setWidget(container) + tab_layout.addWidget(self.settings_scroll) return tab @staticmethod @@ -715,6 +811,36 @@ def _create_search_paths_layout(self) -> QW.QVBoxLayout: paths_layout.addWidget(hint) return paths_layout + def _create_warning_settings_layout(self) -> QW.QVBoxLayout: + """Create the layout for plugin compatibility warning options.""" + warnings_layout = QW.QVBoxLayout() + + title = QW.QLabel(_("Compatibility warnings")) + title_font = title.font() + title_font.setBold(True) + title.setFont(title_font) + warnings_layout.addWidget(title) + + self.v020_warning_checkbox = QW.QCheckBox( + _("Hide warnings for incompatible DataLab v0.20 plugins") + ) + self.v020_warning_checkbox.setChecked(self.v020_plugins_warning_ignore) + self.v020_warning_checkbox.toggled.connect( + self._set_v020_plugins_warning_ignore + ) + warnings_layout.addWidget(self.v020_warning_checkbox) + + hint = QW.QLabel( + _( + "If enabled, DataLab will not warn you about v0.20 plugins " + "that are no longer compatible with v1.0." + ) + ) + hint.setWordWrap(True) + apply_subdued_color(hint) + warnings_layout.addWidget(hint) + return warnings_layout + def _refresh_extra_path_widgets(self) -> None: """Rebuild the editable extra-path widgets.""" if self.extra_paths_layout is None: @@ -832,16 +958,74 @@ def _update_load_info_label(self) -> None: latest_load = _get_latest_plugin_load_at(self.main) self.load_info_label.setText(format_last_load_text(latest_load)) + def _update_global_plugins_ui(self) -> None: + """Refresh button texts and enabled state for global plugin controls.""" + enabled = self.plugins_enabled + if self.global_toggle_button is not None: + self.global_toggle_button.setText( + _("Disable plugins globally") + if enabled + else _("Enable plugins globally") + ) + self.global_toggle_button.setIcon( + get_icon("uncheck_all.svg") if enabled else get_icon("check_all.svg") + ) + if self.reload_button is not None: + self.reload_button.setEnabled(enabled) + if self.plugins_content is not None: + self.plugins_content.setEnabled(enabled) + if self.plugins_disabled_label is not None: + self.plugins_disabled_label.setVisible(not enabled) + if self.settings_scroll is not None: + self.settings_scroll.setEnabled(enabled) + if self.settings_disabled_label is not None: + self.settings_disabled_label.setVisible(not enabled) + for widget in self.fixed_path_widgets + self.extra_path_widgets: + widget.set_links_enabled(enabled) + + def _toggle_global_plugins_enabled(self) -> None: + """Toggle the local global third-party plugin enabled state.""" + self.plugins_enabled = not self.plugins_enabled + self._update_global_plugins_ui() + + def _set_v020_plugins_warning_ignore(self, state: bool) -> None: + """Store the local compatibility-warning preference.""" + self.v020_plugins_warning_ignore = state + def _has_changes(self) -> bool: """Return whether plugin enablement or search paths changed.""" plugin_changes_made = any( widget.has_changed() for widget in self.plugin_widgets ) path_changes_made = self.extra_plugin_paths != self.original_extra_plugin_paths + plugins_enabled_changed = self.plugins_enabled != self.original_plugins_enabled + warning_changed = ( + self.v020_plugins_warning_ignore + != self.original_v020_plugins_warning_ignore + ) + return ( + plugin_changes_made + or path_changes_made + or plugins_enabled_changed + or warning_changed + ) + + def _has_reloadable_changes(self) -> bool: + """Return whether changes require plugin reload while plugins are enabled.""" + plugin_changes_made = any( + widget.has_changed() for widget in self.plugin_widgets + ) + path_changes_made = self.extra_plugin_paths != self.original_extra_plugin_paths return plugin_changes_made or path_changes_made + def _has_global_plugins_enabled_change(self) -> bool: + """Return whether the global third-party plugin state changed.""" + return self.plugins_enabled != self.original_plugins_enabled + def _save_configuration(self) -> None: """Persist current plugin enablement and search path settings.""" + Conf.main.plugins_enabled.set(self.plugins_enabled) + Conf.main.v020_plugins_warning_ignore.set(self.v020_plugins_warning_ignore) enabled_plugins = [ widget.plugin_class.PLUGIN_INFO.name for widget in self.plugin_widgets @@ -850,6 +1034,12 @@ def _save_configuration(self) -> None: Conf.main.plugins_enabled_list.set(enabled_plugins) set_user_plugin_paths(self.extra_plugin_paths) + def _mark_current_state_as_saved(self) -> None: + """Synchronize original values with the current dialog state.""" + self.original_plugins_enabled = self.plugins_enabled + self.original_v020_plugins_warning_ignore = self.v020_plugins_warning_ignore + self.original_extra_plugin_paths = list(self.extra_plugin_paths) + def _refresh_plugin_list(self) -> None: """Rebuild the plugin list from the current registry state.""" if self.plugins_layout is None: @@ -867,11 +1057,19 @@ def _refresh_plugin_list(self) -> None: def _apply_and_reload_plugins(self) -> None: """Save configuration, reload plugins, and keep dialog open.""" + if not self.plugins_enabled: + return + global_plugins_enabled_changed = self._has_global_plugins_enabled_change() if self._has_changes(): self._save_configuration() - self.original_extra_plugin_paths = list(self.extra_plugin_paths) - self.main.reload_plugins() + if global_plugins_enabled_changed: + self.main.set_plugins_enabled(self.plugins_enabled) + else: + self.main.reload_plugins() + self._mark_current_state_as_saved() self._refresh_plugin_list() + self._update_load_info_label() + self._update_global_plugins_ui() def set_all_enabled(self, enabled: bool) -> None: """Set all plugin checkboxes to the same state.""" @@ -957,8 +1155,19 @@ def accept(self): super().accept() return + global_plugins_enabled_changed = self._has_global_plugins_enabled_change() + reloadable_changes = self._has_reloadable_changes() self._save_configuration() + if global_plugins_enabled_changed: + self.main.set_plugins_enabled(self.plugins_enabled) + super().accept() + return + + if not reloadable_changes: + super().accept() + return + # Inform user that reload is needed reply = QW.QMessageBox.question( self, diff --git a/datalab/gui/settings.py b/datalab/gui/settings.py index 727a1cde..489cae93 100644 --- a/datalab/gui/settings.py +++ b/datalab/gui/settings.py @@ -66,22 +66,6 @@ class MainSettings(gds.DataSet): "before loading any new data" ), ) - plugins_enabled = gds.BoolItem( - "", - _("Third-party plugins"), - help=_( - "Enable or disable third-party plugins immediately. " - "Changes are applied without restarting DataLab" - ), - ) - v020_plugins_warning_ignore = gds.BoolItem( - _("Ignore compatibility issues warning"), - _("DataLab v0.20 plugins"), - help=_( - "If enabled, DataLab will not warn you about v0.20 plugins that are " - "no longer compatible with v1.0." - ), - ) _g0 = gds.EndGroup("") @@ -257,7 +241,7 @@ class ImageDefaultSettings(BaseImageParam): def edit_default_image_settings( dataset: gds.DataSet, item: gds.DataItem, value: Any, parent: QW.QWidget ) -> bool: - """Edit default image settings + """Edit default image settings. Args: dataset: dataset diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index c8354e20..a3f7a8e7 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -1800,6 +1800,27 @@ msgstr "Recharger les plugins" msgid "Plugin configuration has been saved. Do you want to reload plugins now to apply changes?" msgstr "La configuration des plugins a été enregistrée. Voulez-vous recharger les plugins maintenant pour appliquer les modifications ?" +msgid "Plugin settings" +msgstr "Paramètres des plugins" + +msgid "Third-party plugins are globally disabled." +msgstr "Les plugins tiers sont désactivés globalement." + +msgid "Compatibility warnings" +msgstr "Avertissements de compatibilité" + +msgid "Hide warnings for incompatible DataLab v0.20 plugins" +msgstr "Masquer les avertissements pour les plugins DataLab v0.20 incompatibles" + +msgid "Disable plugins globally" +msgstr "Désactiver globalement les plugins" + +msgid "Enable plugins globally" +msgstr "Activer globalement les plugins" + +msgid "Enable them again from the plugin configuration dialog to use this feature." +msgstr "Réactivez-les depuis la boîte de dialogue de configuration des plugins pour utiliser cette fonctionnalité." + msgid "Failed to deserialize processing parameters from JSON." msgstr "Échec de la désérialisation des paramètres de traitement depuis le format JSON." diff --git a/datalab/tests/features/plugins/pluginconfig_dialog_test.py b/datalab/tests/features/plugins/pluginconfig_dialog_test.py index 0defa40e..2c449dcd 100644 --- a/datalab/tests/features/plugins/pluginconfig_dialog_test.py +++ b/datalab/tests/features/plugins/pluginconfig_dialog_test.py @@ -6,6 +6,7 @@ from datetime import datetime +import pytest from qtpy import QtCore as QC from qtpy import QtWidgets as QW @@ -85,6 +86,23 @@ def _create_plugin_description_widget( return widget +@pytest.fixture(autouse=True) +def restore_plugin_global_settings(): + """Keep plugin-global settings isolated between tests. + + Most tests in this module assume third-party plugins are enabled. + """ + original_plugins_enabled = Conf.main.plugins_enabled.get(True) + original_warning_ignore = Conf.main.v020_plugins_warning_ignore.get(False) + Conf.main.plugins_enabled.set(True) + Conf.main.v020_plugins_warning_ignore.set(False) + try: + yield + finally: + Conf.main.plugins_enabled.set(original_plugins_enabled) + Conf.main.v020_plugins_warning_ignore.set(original_warning_ignore) + + def test_plugin_enable_disable_config(): """Test plugin enable/disable filtering and configuration dialog.""" plugin_1_name = "Test Plugin 1" @@ -453,6 +471,170 @@ def fake_reload_plugins() -> None: set_user_plugin_paths(original_paths) +def test_plugins_menu_stays_available_when_plugins_are_globally_disabled(): + """Plugins menu should remain usable to reopen configuration when disabled.""" + original_plugins_enabled = Conf.main.plugins_enabled.get(True) + + try: + Conf.main.plugins_enabled.set(False) + + with datalab_test_app_context(console=False) as win: + QW.QApplication.processEvents() + + assert win.plugins_menu is not None + assert win.plugins_menu.isEnabled() + assert win.configure_plugins_action is not None + assert win.configure_plugins_action.isEnabled() + assert win.reload_plugins_action is not None + assert not win.reload_plugins_action.isEnabled() + + win.plugins_menu.aboutToShow.emit() + QW.QApplication.processEvents() + + plugin_menu_texts = [action.text() for action in win.plugins_menu.actions()] + assert "Configure plugins..." in plugin_menu_texts + assert "Reload plugins" in plugin_menu_texts + finally: + Conf.main.plugins_enabled.set(original_plugins_enabled) + + +def test_disabled_plugins_still_appear_in_configuration_dialog(): + """Globally disabled plugins should remain listed but inactive in the dialog.""" + plugin_name = "Disabled Visible Plugin" + main_config = Conf.to_dict().get("main", {}) + had_config = "plugins_enabled_list" in main_config + original_enabled_list = Conf.main.plugins_enabled_list.get(None) + + try: + with temporary_plugin_dir() as plugin_dir: + create_plugin_file( + plugin_dir, + "datalab_test_plugin_visible_when_disabled.py", + "DisabledVisiblePlugin", + plugin_name, + "Action Visible", + "action_visible", + ) + Conf.main.plugins_enabled_list.set(None) + + with datalab_test_app_context(console=False) as win: + dialog = PluginConfigDialog(win) + _show_dialog(dialog) + + assert dialog.global_toggle_button is not None + dialog.global_toggle_button.click() + QW.QApplication.processEvents() + dialog.accept() + QW.QApplication.processEvents() + + dialog2 = PluginConfigDialog(win) + _show_dialog(dialog2) + + widget_names = [ + widget.plugin_class.PLUGIN_INFO.name + for widget in dialog2.plugin_widgets + ] + assert plugin_name in widget_names + assert dialog2.plugins_content is not None + assert not dialog2.plugins_content.isEnabled() + assert dialog2.global_toggle_button is not None + assert dialog2.global_toggle_button.text() == "Enable plugins globally" + + _close_dialog(dialog2) + finally: + if had_config: + Conf.main.plugins_enabled_list.set(original_enabled_list) + else: + Conf.main.plugins_enabled_list.remove() + + +def test_plugin_settings_tab_exposes_global_toggle_and_warning_option(): + """Plugin settings tab should host the global toggle and warning option.""" + original_plugins_enabled = Conf.main.plugins_enabled.get(True) + original_warning_ignore = Conf.main.v020_plugins_warning_ignore.get(False) + + try: + Conf.main.plugins_enabled.set(True) + Conf.main.v020_plugins_warning_ignore.set(False) + + with datalab_test_app_context(console=False) as win: + dialog = PluginConfigDialog(win) + _show_dialog(dialog) + + assert dialog.tabs is not None + assert dialog.tabs.tabText(1) == "Plugin settings" + assert dialog.global_toggle_button is not None + assert dialog.global_toggle_button.text() == "Disable plugins globally" + assert dialog.global_toggle_button.minimumHeight() == 24 + assert not dialog.global_toggle_button.icon().isNull() + assert dialog.v020_warning_checkbox is not None + assert dialog.v020_warning_checkbox.isChecked() is False + assert dialog.reload_button is not None + assert dialog.reload_button.isEnabled() + + dialog.global_toggle_button.click() + QW.QApplication.processEvents() + + assert dialog.global_toggle_button.text() == "Enable plugins globally" + assert not dialog.global_toggle_button.icon().isNull() + assert dialog.plugins_content is not None + assert dialog.settings_scroll is not None + assert dialog.plugins_disabled_label is not None + assert dialog.settings_disabled_label is not None + assert not dialog.plugins_content.isEnabled() + assert not dialog.settings_scroll.isEnabled() + assert dialog.plugins_disabled_label.isVisible() + dialog.tabs.setCurrentIndex(1) + QW.QApplication.processEvents() + assert dialog.settings_disabled_label.isVisible() + assert not dialog.reload_button.isEnabled() + assert dialog.fixed_path_widgets + fixed_path_widget = dialog.fixed_path_widgets[0] + assert fixed_path_widget.path_label is not None + assert fixed_path_widget.path_label.openExternalLinks() is False + assert " Date: Thu, 21 May 2026 11:32:37 +0200 Subject: [PATCH 09/12] Adds show-in-folder action for configuration file. Improves the configuration viewer so the user can quickly locate the active settings file from the interface. Uses platform-appropriate folder reveal behavior when available and falls back to opening the containing directory, which makes the action more reliable across systems. Adds test to verify the action is exposed and points to the expected file. --- .../utilities/installconf_unit_test.py | 32 +++++++- datalab/widgets/instconfviewer.py | 73 ++++++++++++++++++- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/datalab/tests/features/utilities/installconf_unit_test.py b/datalab/tests/features/utilities/installconf_unit_test.py index 2538f20a..6937c90a 100644 --- a/datalab/tests/features/utilities/installconf_unit_test.py +++ b/datalab/tests/features/utilities/installconf_unit_test.py @@ -6,9 +6,17 @@ # guitest: show +import os + +import pytest from guidata.qthelpers import qt_app_context -from datalab.widgets.instconfviewer import exec_datalab_installconfig_dialog +from datalab.config import Conf +from datalab.widgets import instconfviewer +from datalab.widgets.instconfviewer import ( + InstallConfigViewerWindow, + exec_datalab_installconfig_dialog, +) def test_dep_viewer(): @@ -17,5 +25,27 @@ def test_dep_viewer(): exec_datalab_installconfig_dialog() +def test_user_config_tab_can_show_config_in_folder(monkeypatch: pytest.MonkeyPatch): + """User configuration tab exposes a show-in-folder action.""" + calls: list[str] = [] + + def _show_in_folder(path: str) -> bool: + calls.append(path) + return True + + monkeypatch.setattr(instconfviewer, "_show_in_folder", _show_in_folder) + + with qt_app_context(): + window = InstallConfigViewerWindow() + widget = window.tabs.widget(1) + + assert widget.show_in_folder_button is not None + assert widget.show_in_folder_button.text() == "Show in folder" + + widget.show_in_folder_button.click() + + assert calls == [os.path.abspath(Conf.get_filename())] + + if __name__ == "__main__": test_dep_viewer() diff --git a/datalab/widgets/instconfviewer.py b/datalab/widgets/instconfviewer.py index 0ee43da6..bd766412 100644 --- a/datalab/widgets/instconfviewer.py +++ b/datalab/widgets/instconfviewer.py @@ -10,12 +10,16 @@ import locale import os import platform +import shutil +import subprocess import sys from importlib.metadata import distributions from pathlib import Path from guidata.configtools import get_icon from guidata.qthelpers import exec_dialog +from qtpy import QtCore as QC +from qtpy import QtGui as QG from qtpy import QtWidgets as QW from sigima.io.image import ImageIORegistry from sigima.io.signal import SignalIORegistry @@ -126,6 +130,69 @@ def get_install_info() -> str: return info +def _open_local_path(path: str) -> bool: + """Open a local path with the desktop handler.""" + return QG.QDesktopServices.openUrl(QC.QUrl.fromLocalFile(path)) + + +def _show_in_folder(path: str) -> bool: + """Show a file in its containing folder, selecting it when supported.""" + filepath = os.path.abspath(path) + directory = os.path.dirname(filepath) + + if sys.platform.startswith("win"): + commands = [["explorer", f"/select,{os.path.normpath(filepath)}"]] + elif sys.platform == "darwin": + commands = [["open", "-R", filepath]] + else: + commands = [] + if shutil.which("nautilus"): + commands.append(["nautilus", "--select", filepath]) + if shutil.which("dolphin"): + commands.append(["dolphin", "--select", filepath]) + if shutil.which("nemo"): + commands.append(["nemo", filepath]) + if shutil.which("caja"): + commands.append(["caja", "--select", filepath]) + + for command in commands: + try: + subprocess.Popen( # pylint: disable=consider-using-with + command, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return True + except OSError: + continue + return _open_local_path(directory) + + +class ConfigFileViewerWidget(FileViewerWidget): + """File viewer with actions for the displayed configuration file.""" + + def __init__(self, filepath: str, parent: QW.QWidget | None = None) -> None: + super().__init__(parent=parent) + self.filepath = os.path.abspath(filepath) + self.show_in_folder_button = QW.QPushButton( + get_icon("show_in_folder.svg"), _("Show in folder") + ) + self.show_in_folder_button.clicked.connect(self.show_file_in_folder) + + header_layout = QW.QHBoxLayout() + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(self.show_in_folder_button) + header_layout.addWidget(self.label, 1) + + layout = self.layout() + layout.removeWidget(self.label) + layout.insertLayout(0, header_layout) + + def show_file_in_folder(self) -> None: + """Open the folder containing the displayed configuration file.""" + _show_in_folder(self.filepath) + + class InstallConfigViewerWindow(QW.QDialog): """Installation configuration window""" @@ -160,7 +227,11 @@ def __init__(self, parent: QW.QWidget | None = None) -> None: _("Plugins and I/O features"), ), ): - viewer = FileViewerWidget() + viewer = ( + ConfigFileViewerWidget(Conf.get_filename()) + if tab_title == _("User configuration") + else FileViewerWidget() + ) viewer.set_data(title, contents) self.tabs.addTab(viewer, tab_icon, tab_title) layout = QW.QVBoxLayout() From 6bc8a3eb146e0ff289c9d665e66d6d4c2327e625 Mon Sep 17 00:00:00 2001 From: Stefano Pierini Date: Thu, 21 May 2026 11:36:33 +0200 Subject: [PATCH 10/12] Centralizes file reveal helpers and reduce duplicated code. Removes duplicated cross-platform path-opening logic by reusing shared GUI helpers, which keeps folder reveal behavior consistent and easier to maintain. Improves the configuration viewer header layout so the title stays readable while the folder action remains neatly aligned. --- datalab/gui/pluginconfig.py | 47 ++++--------------------------- datalab/utils/qthelpers.py | 39 +++++++++++++++++++++++++ datalab/widgets/instconfviewer.py | 46 ++---------------------------- 3 files changed, 48 insertions(+), 84 deletions(-) diff --git a/datalab/gui/pluginconfig.py b/datalab/gui/pluginconfig.py index c447bf49..455c4db2 100644 --- a/datalab/gui/pluginconfig.py +++ b/datalab/gui/pluginconfig.py @@ -12,9 +12,6 @@ import inspect import os import os.path as osp -import shutil -import subprocess -import sys from datetime import datetime, timedelta from html import escape from typing import TYPE_CHECKING @@ -39,6 +36,12 @@ set_user_plugin_paths, ) from datalab.plugins import PLUGINS_DEFAULT_PATH, PluginRegistry +from datalab.utils.qthelpers import ( + open_local_path as _open_local_path, +) +from datalab.utils.qthelpers import ( + show_in_folder as _show_in_folder, +) from datalab.widgets.expandabletext import ( ExpandableTextWidget, apply_palette_color, @@ -97,44 +100,6 @@ def _create_status_label(text: str, color: QG.QColor | None = None) -> QW.QLabel return status_label -def _open_local_path(path: str) -> bool: - """Open a local path with the desktop handler.""" - return QG.QDesktopServices.openUrl(QC.QUrl.fromLocalFile(path)) - - -def _show_in_folder(path: str) -> bool: - """Show a file in its containing folder, selecting it when supported.""" - filepath = osp.abspath(path) - directory = osp.dirname(filepath) - - if sys.platform.startswith("win"): - commands = [["explorer", f"/select,{osp.normpath(filepath)}"]] - elif sys.platform == "darwin": - commands = [["open", "-R", filepath]] - else: - commands = [] - if shutil.which("nautilus"): - commands.append(["nautilus", "--select", filepath]) - if shutil.which("dolphin"): - commands.append(["dolphin", "--select", filepath]) - if shutil.which("nemo"): - commands.append(["nemo", filepath]) - if shutil.which("caja"): - commands.append(["caja", "--select", filepath]) - - for command in commands: - try: - subprocess.Popen( # pylint: disable=consider-using-with - command, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - return True - except OSError: - continue - return _open_local_path(directory) - - def _get_latest_plugin_load_at(main: DLMainWindow) -> datetime: """Return the most recent relevant plugin load timestamp.""" timestamps = [ diff --git a/datalab/utils/qthelpers.py b/datalab/utils/qthelpers.py index 8528aaa0..5be569c2 100644 --- a/datalab/utils/qthelpers.py +++ b/datalab/utils/qthelpers.py @@ -13,6 +13,7 @@ import os import os.path as osp import shutil +import subprocess import sys import time import traceback @@ -86,6 +87,44 @@ def remove_empty_log_file(fname: str) -> None: pass +def open_local_path(path: str) -> bool: + """Open a local path with the desktop handler.""" + return QG.QDesktopServices.openUrl(QC.QUrl.fromLocalFile(path)) + + +def show_in_folder(path: str) -> bool: + """Show a file in its containing folder, selecting it when supported.""" + filepath = osp.abspath(path) + directory = osp.dirname(filepath) + + if sys.platform.startswith("win"): + commands = [["explorer", f"/select,{osp.normpath(filepath)}"]] + elif sys.platform == "darwin": + commands = [["open", "-R", filepath]] + else: + commands = [] + if shutil.which("nautilus"): + commands.append(["nautilus", "--select", filepath]) + if shutil.which("dolphin"): + commands.append(["dolphin", "--select", filepath]) + if shutil.which("nemo"): + commands.append(["nemo", filepath]) + if shutil.which("caja"): + commands.append(["caja", "--select", filepath]) + + for command in commands: + try: + subprocess.Popen( # pylint: disable=consider-using-with + command, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return True + except OSError: + continue + return open_local_path(directory) + + @contextmanager def datalab_app_context( exec_loop=False, enable_logs=True diff --git a/datalab/widgets/instconfviewer.py b/datalab/widgets/instconfviewer.py index bd766412..95b19f0d 100644 --- a/datalab/widgets/instconfviewer.py +++ b/datalab/widgets/instconfviewer.py @@ -10,16 +10,12 @@ import locale import os import platform -import shutil -import subprocess import sys from importlib.metadata import distributions from pathlib import Path from guidata.configtools import get_icon from guidata.qthelpers import exec_dialog -from qtpy import QtCore as QC -from qtpy import QtGui as QG from qtpy import QtWidgets as QW from sigima.io.image import ImageIORegistry from sigima.io.signal import SignalIORegistry @@ -27,6 +23,7 @@ import datalab from datalab.config import APP_NAME, IS_FROZEN, Conf, _ from datalab.plugins import PluginRegistry +from datalab.utils.qthelpers import show_in_folder as _show_in_folder from datalab.widgets.fileviewer import FileViewerWidget, get_title_contents @@ -130,44 +127,6 @@ def get_install_info() -> str: return info -def _open_local_path(path: str) -> bool: - """Open a local path with the desktop handler.""" - return QG.QDesktopServices.openUrl(QC.QUrl.fromLocalFile(path)) - - -def _show_in_folder(path: str) -> bool: - """Show a file in its containing folder, selecting it when supported.""" - filepath = os.path.abspath(path) - directory = os.path.dirname(filepath) - - if sys.platform.startswith("win"): - commands = [["explorer", f"/select,{os.path.normpath(filepath)}"]] - elif sys.platform == "darwin": - commands = [["open", "-R", filepath]] - else: - commands = [] - if shutil.which("nautilus"): - commands.append(["nautilus", "--select", filepath]) - if shutil.which("dolphin"): - commands.append(["dolphin", "--select", filepath]) - if shutil.which("nemo"): - commands.append(["nemo", filepath]) - if shutil.which("caja"): - commands.append(["caja", "--select", filepath]) - - for command in commands: - try: - subprocess.Popen( # pylint: disable=consider-using-with - command, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - return True - except OSError: - continue - return _open_local_path(directory) - - class ConfigFileViewerWidget(FileViewerWidget): """File viewer with actions for the displayed configuration file.""" @@ -181,8 +140,9 @@ def __init__(self, filepath: str, parent: QW.QWidget | None = None) -> None: header_layout = QW.QHBoxLayout() header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(self.label) + header_layout.addStretch(1) header_layout.addWidget(self.show_in_folder_button) - header_layout.addWidget(self.label, 1) layout = self.layout() layout.removeWidget(self.label) From 7f423ecbc5ee84ca51255c4b08f1ce51ac0658de Mon Sep 17 00:00:00 2001 From: Stefano Pierini Date: Fri, 22 May 2026 17:35:16 +0200 Subject: [PATCH 11/12] Fix: remove residual of intermediate state --- datalab/config.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/datalab/config.py b/datalab/config.py index 304146c2..62979f90 100644 --- a/datalab/config.py +++ b/datalab/config.py @@ -538,14 +538,11 @@ def normalize_plugin_paths(paths: list[str] | tuple[str, ...] | None) -> list[st def get_user_plugin_paths() -> list[str]: """Return user-configured extra plugin directories. - ``plugins_path`` accepts both the legacy single-directory string and the - newer list-of-directories form. For backward compatibility, this helper also - migrates any stale ``plugins_extra_paths`` value that may already exist in - the configuration file. + ``plugins_path`` accepts both a single-directory string and the + list-of-directories form. """ fixed_default = osp.normpath(Conf.get_path("plugins")) configured_paths = Conf.main.plugins_path.get([]) - legacy_extra_paths = conf.CONF.get("main", "plugins_extra_paths", []) if isinstance(configured_paths, str): candidates = [configured_paths] @@ -554,12 +551,7 @@ def get_user_plugin_paths() -> list[str]: else: candidates = list(configured_paths) - if isinstance(legacy_extra_paths, str): - legacy_extra_paths = [legacy_extra_paths] - elif legacy_extra_paths is None: - legacy_extra_paths = [] - - normalized = normalize_plugin_paths(candidates + list(legacy_extra_paths)) + normalized = normalize_plugin_paths(candidates) return [path for path in normalized if path != fixed_default] @@ -567,7 +559,6 @@ def set_user_plugin_paths(paths: list[str] | tuple[str, ...]) -> None: """Persist user-configured extra plugin directories in ``plugins_path``.""" normalized = normalize_plugin_paths(list(paths)) Conf.main.plugins_path.set(normalized) - conf.CONF.remove_option("main", "plugins_extra_paths") def get_old_log_fname(fname): From 43fd4af812431cd2e460cc1b4e4a800d43f02b64 Mon Sep 17 00:00:00 2001 From: Stefano Pierini Date: Tue, 26 May 2026 12:11:05 +0200 Subject: [PATCH 12/12] Change multi-directory plugin path support Moves extra plugin directory storage to a list-based setting so multiple locations can be configured reliably. Preserves backward compatibility by reading the legacy single-path value, migrating it into the new list when needed, and leaving the old value intact for older versions. --- datalab/config.py | 41 ++++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/datalab/config.py b/datalab/config.py index 62979f90..012440cf 100644 --- a/datalab/config.py +++ b/datalab/config.py @@ -206,7 +206,9 @@ class MainSection(conf.Section, metaclass=conf.SectionMeta): current_tab = conf.Option() plugins_enabled = conf.Option() plugins_enabled_list = conf.Option() # List of enabled plugin names - plugins_path = conf.Option() + plugins_path = conf.Option() # Deprecated: single-directory string, kept for + # backward compatibility. Use plugins_path_list instead. + plugins_path_list = conf.Option() # List of extra plugin directories tour_enabled = conf.Option() v020_plugins_warning_ignore = conf.Option() # True: do not warn, False: warn @@ -538,27 +540,39 @@ def normalize_plugin_paths(paths: list[str] | tuple[str, ...] | None) -> list[st def get_user_plugin_paths() -> list[str]: """Return user-configured extra plugin directories. - ``plugins_path`` accepts both a single-directory string and the - list-of-directories form. + Reads from ``plugins_path_list`` (list of directories). For backward + compatibility, the deprecated ``plugins_path`` single-directory string is + also merged into ``plugins_path_list`` if this is empty. """ fixed_default = osp.normpath(Conf.get_path("plugins")) - configured_paths = Conf.main.plugins_path.get([]) - if isinstance(configured_paths, str): - candidates = [configured_paths] - elif configured_paths is None: - candidates = [] - else: - candidates = list(configured_paths) + # New list-based option (primary) + path_list = Conf.main.plugins_path_list.get([]) + if path_list is None: + path_list = [] + candidates = list(path_list) + + # Migrate deprecated single-directory option into the list + legacy_path = Conf.main.plugins_path.get("") + if legacy_path and isinstance(legacy_path, str): + norm_legacy = osp.normpath(osp.abspath(osp.expanduser(legacy_path))) + if not candidates and norm_legacy != fixed_default: + candidates.append(legacy_path) + Conf.main.plugins_path_list.set(candidates) normalized = normalize_plugin_paths(candidates) return [path for path in normalized if path != fixed_default] def set_user_plugin_paths(paths: list[str] | tuple[str, ...]) -> None: - """Persist user-configured extra plugin directories in ``plugins_path``.""" + """Persist user-configured extra plugin directories. + + Writes to ``plugins_path_list``. The deprecated ``plugins_path`` is left + untouched so that older DataLab versions can still find at least one + user-configured directory. + """ normalized = normalize_plugin_paths(list(paths)) - Conf.main.plugins_path.set(normalized) + Conf.main.plugins_path_list.set(normalized) def get_old_log_fname(fname): @@ -591,7 +605,8 @@ def initialize(): Conf.main.plugins_enabled_list.get( None ) # None = all enabled, [] = none, list = specific - Conf.main.plugins_path.get([]) + Conf.main.plugins_path.get("") # Deprecated: kept for backward compat + Conf.main.plugins_path_list.get([]) Conf.main.tour_enabled.get(True) Conf.main.v020_plugins_warning_ignore.get(False) # Console section