diff --git a/datalab/config.py b/datalab/config.py index 67d123b2..c754cd6b 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 @@ -525,6 +527,59 @@ 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. + + 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")) + + # 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. + + 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_list.set(normalized) + + def get_old_log_fname(fname): """Return old log fname from current log fname""" return osp.splitext(fname)[0] + ".1.log" @@ -555,7 +610,8 @@ 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("") # 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 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/main.py b/datalab/gui/main.py index f1556a84..83980abd 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 @@ -158,6 +159,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 @@ -1051,6 +1054,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 @@ -1092,6 +1099,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. @@ -1129,6 +1138,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. @@ -1143,8 +1157,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 @@ -1220,6 +1235,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 @@ -1255,12 +1271,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() @@ -1277,11 +1292,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() @@ -1867,8 +1877,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""" @@ -1898,12 +1906,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: @@ -2513,8 +2520,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 76d2c8ac..455c4db2 100644 --- a/datalab/gui/pluginconfig.py +++ b/datalab/gui/pluginconfig.py @@ -9,14 +9,19 @@ from __future__ import annotations +import inspect import os import os.path as osp +from datetime import datetime, timedelta +from html import escape 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 from qtpy import QtWidgets as QW +from qtpy.compat import getexistingdirectory from datalab.config import ( DATALAB_PLUGINS_ENV_PATHS, @@ -26,8 +31,17 @@ 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.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, @@ -86,6 +100,49 @@ def _create_status_label(text: str, color: QG.QColor | None = None) -> QW.QLabel return status_label +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""" @@ -117,6 +174,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 +186,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 +243,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 +343,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 +357,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 +401,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()) @@ -297,6 +443,87 @@ 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.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 + + layout = QW.QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + self.setLayout(layout) + + path_label = QW.QLabel() + path_label.setWordWrap(True) + self.path_label = path_label + self.set_links_enabled(True) + 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) + + 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""" @@ -311,9 +538,31 @@ 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_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) @@ -323,12 +572,11 @@ 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")) - 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() @@ -337,23 +585,37 @@ 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.addWidget(button_box) + layout.addLayout(self._create_footer_layout(button_box)) + self._update_load_info_label() + self._update_global_plugins_ui() - @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), + 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 ) - return title_label + 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.""" @@ -361,27 +623,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 @@ -395,8 +681,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. @@ -404,11 +690,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: @@ -421,7 +703,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( @@ -430,31 +712,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 = ( - "" - ) - 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) - 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_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) + + 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) @@ -464,6 +776,102 @@ 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: + 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() @@ -498,6 +906,136 @@ 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 _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 + if widget.is_enabled() + ] + 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: + 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 not self.plugins_enabled: + return + global_plugins_enabled_changed = self._has_global_plugins_enabled_change() + if self._has_changes(): + self._save_configuration() + 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.""" for widget in self.plugin_widgets: @@ -540,6 +1078,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) @@ -577,22 +1116,22 @@ 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) - - 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() - ] + global_plugins_enabled_changed = self._has_global_plugins_enabled_change() + reloadable_changes = self._has_reloadable_changes() + self._save_configuration() - # Save to configuration - Conf.main.plugins_enabled_list.set(enabled_plugins) + 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( diff --git a/datalab/gui/settings.py b/datalab/gui/settings.py index 660caa91..19f4cb58 100644 --- a/datalab/gui/settings.py +++ b/datalab/gui/settings.py @@ -66,31 +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" - ), - ) - 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"), - help=_( - "If enabled, DataLab will not warn you about v0.20 plugins that are " - "no longer compatible with v1.0." - ), - ) _g0 = gds.EndGroup("") @@ -266,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 @@ -745,7 +720,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 29991dbb..371ffaae 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -1872,6 +1872,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" @@ -1881,9 +1891,21 @@ 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" +msgid "Edit directory" +msgstr "Modifier le répertoire" + +msgid "Remove directory" +msgstr "Supprimer le répertoire" + msgid "Plugin Configuration" msgstr "Configuration des plugins" @@ -1893,8 +1915,8 @@ msgstr "Activer/désactiver les plugins" msgid "Plugin search paths" msgstr "Chemins de recherche des plugins" -msgid "Manage Plugins" -msgstr "Gérer les plugins" +msgid "Apply and reload plugins" +msgstr "Appliquer et recharger 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." @@ -1902,15 +1924,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 "from" -msgstr "de" +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 plugin search path is currently active." -msgstr "Aucun chemin de recherche de plugins n'est actuellement actif." +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" @@ -1936,6 +1970,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." @@ -2796,12 +2851,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é" @@ -3128,9 +3177,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 024b01a4..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 @@ -364,6 +370,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 @@ -387,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) @@ -406,6 +423,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 @@ -464,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 2e4e363b..2c449dcd 100644 --- a/datalab/tests/features/plugins/pluginconfig_dialog_test.py +++ b/datalab/tests/features/plugins/pluginconfig_dialog_test.py @@ -4,11 +4,20 @@ from __future__ import annotations +from datetime import datetime + +import pytest 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 from datalab.gui.pluginconfig import ( ExpandableTextWidget, @@ -26,7 +35,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 +51,8 @@ class DummyPlugin: "icon": None, }, )() + if filepath is not None: + DummyPlugin.__plugin_filepath__ = filepath return DummyPlugin @@ -75,28 +86,49 @@ 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" + 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) + 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", - "Test Plugin 1", + plugin_1_name, "Action One", "action_1", ) - create_plugin_file( + plugin_2_path = create_plugin_file( plugin_dir, "datalab_test_plugin_2.py", "TestPluginTwo", - "Test Plugin 2", + plugin_2_name, "Action Two", "action_2", ) @@ -110,8 +142,24 @@ 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 == plugin_1_name + ) + plugin_2_widget = next( + widget + for widget in dialog.plugin_widgets + 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 + 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) @@ -123,7 +171,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() @@ -134,7 +182,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 ) @@ -146,8 +194,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() @@ -156,13 +204,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() @@ -171,7 +219,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() @@ -180,7 +228,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() @@ -195,55 +243,458 @@ def test_plugin_enable_disable_config(): Conf.main.plugins_enabled_list.remove() -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", - }, - ): +def test_last_load_text_uses_today_yesterday_or_date(): + """Last load label should use relative day words when applicable.""" + + 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 = type("DummyLocale", (), {"toString": staticmethod(locale_to_string)})() + 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_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() - win.tabwidget.setCurrentWidget(win.signalpanel) + 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() - win.plugins_menu.aboutToShow.emit() - assert "menu-scrollable" in win.plugins_menu.styleSheet() - plugin_actions = win.signalpanel.get_category_actions( - ActionCategory.PLUGINS + 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 ) - test_menu = next( - item - for item in plugin_actions - if isinstance(item, QW.QMenu) - and item.title() == "Test Menu with Many Actions" + 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_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), ) - 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" + 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_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", ) - assert action_3.isEnabled() + 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 " 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) @@ -385,3 +878,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() 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/utils/qthelpers.py b/datalab/utils/qthelpers.py index 7bcba767..a0e37c85 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 0ee43da6..95b19f0d 100644 --- a/datalab/widgets/instconfviewer.py +++ b/datalab/widgets/instconfviewer.py @@ -23,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 @@ -126,6 +127,32 @@ def get_install_info() -> str: return info +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.label) + header_layout.addStretch(1) + header_layout.addWidget(self.show_in_folder_button) + + 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 +187,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()