From c3e2e8864ec77034a98d155c730f637e9630a137 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:38:15 +0200 Subject: [PATCH 1/3] feat(plugins): install and uninstall plugins from the configuration dialog Assisted-by: Claude Opus 5.5 --- datalab/gui/plugins/config.py | 6 + datalab/gui/plugins/install.py | 348 ++++++++++++++++++ datalab/locale/fr/LC_MESSAGES/datalab.po | 95 ++++- datalab/plugins/base.py | 2 +- datalab/plugins/store.py | 59 ++- .../plugins/plugin_store_unit_test.py | 2 +- .../plugins/plugin_install_dialog_test.py | 159 ++++++++ doc/features/advanced/plugins.rst | 21 +- doc/release_notes/release_1.04.md | 6 + 9 files changed, 671 insertions(+), 27 deletions(-) create mode 100644 datalab/gui/plugins/install.py create mode 100644 datalab/tests/features/plugins/plugin_install_dialog_test.py diff --git a/datalab/gui/plugins/config.py b/datalab/gui/plugins/config.py index db1fb68cf..47194721b 100644 --- a/datalab/gui/plugins/config.py +++ b/datalab/gui/plugins/config.py @@ -36,6 +36,7 @@ set_user_plugin_paths, ) from datalab.gui.plugins.applications import get_plugin_icon +from datalab.gui.plugins.install import InstalledPluginsWidget from datalab.plugins import ( PLUGINS_DEFAULT_PATH, PluginCapability, @@ -615,6 +616,9 @@ def __init__(self, parent: DLMainWindow): self.reload_button: QW.QPushButton | None = None self.global_toggle_button: QW.QPushButton | None = None self.v020_warning_checkbox: QW.QCheckBox | None = None + self.installed_plugins_widget = InstalledPluginsWidget( + self._apply_and_reload_plugins + ) self.setWindowTitle(_("Plugin Configuration")) self.setMinimumWidth(DIALOG_MIN_WIDTH) @@ -627,6 +631,7 @@ def __init__(self, parent: DLMainWindow): 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.addTab(self.installed_plugins_widget, _("Install plugins")) self.tabs.setCornerWidget(self._create_corner_widget(), QC.Qt.TopRightCorner) layout.addWidget(self.tabs, 1) @@ -997,6 +1002,7 @@ def _update_global_plugins_ui(self) -> None: self.settings_scroll.setEnabled(enabled) if self.settings_disabled_label is not None: self.settings_disabled_label.setVisible(not enabled) + self.installed_plugins_widget.setEnabled(enabled) for widget in self.fixed_path_widgets + self.extra_path_widgets: widget.set_links_enabled(enabled) diff --git a/datalab/gui/plugins/install.py b/datalab/gui/plugins/install.py new file mode 100644 index 000000000..4a16a477e --- /dev/null +++ b/datalab/gui/plugins/install.py @@ -0,0 +1,348 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +Plugin installation +------------------- + +Install plugins from a wheel or module file, with the user's consent, and list +the plugins installed this way. +""" + +from __future__ import annotations + +import os.path as osp +from collections.abc import Callable +from datetime import datetime +from html import escape + +from guidata.configtools import get_icon +from guidata.qthelpers import win32_fix_title_bar_background +from qtpy import QtCore as QC +from qtpy import QtWidgets as QW +from qtpy.compat import getopenfilename + +from datalab.config import Conf, _ +from datalab.plugins import PluginRegistry +from datalab.plugins.base import get_installed_plugin_store +from datalab.plugins.store import ( + WHEEL_KIND, + InstalledPlugin, + InstalledPluginStore, + InstallResult, + PluginInstallError, +) +from datalab.utils.qthelpers import show_in_folder as _show_in_folder +from datalab.widgets.expandabletext import apply_subdued_color + + +def _format_size(size_bytes: int) -> str: + return QC.QLocale.system().formattedDataSize(size_bytes) + + +def _format_date(timestamp: str) -> str: + try: + moment = datetime.fromisoformat(timestamp).astimezone() + except ValueError: + return timestamp + return QC.QLocale.system().toString( + QC.QDate(moment.year, moment.month, moment.day), QC.QLocale.ShortFormat + ) + + +def _plugin_title(plugin: InstalledPlugin) -> str: + if plugin.kind == WHEEL_KIND: + return f"{plugin.name} {plugin.version}" + return plugin.filename + + +class PluginConsentDialog(QW.QDialog): + """Dialog asking the user to confirm the installation of a plugin file. + + Args: + manifest: Wheel or module manifest returned by the installed plugin store + parent: Parent widget + """ + + def __init__(self, manifest: dict, parent: QW.QWidget | None = None) -> None: + super().__init__(parent) + win32_fix_title_bar_background(self) + self.setWindowTitle(_("Install plugin")) + layout = QW.QVBoxLayout() + self.setLayout(layout) + + form = QW.QFormLayout() + if "distribution" in manifest: + form.addRow(_("Plugin:"), QW.QLabel(manifest["distribution"])) + form.addRow(_("Version:"), QW.QLabel(manifest["version"])) + if manifest["summary"]: + summary = QW.QLabel(manifest["summary"]) + summary.setWordWrap(True) + form.addRow(_("Description:"), summary) + entry_points = "
".join( + escape(f"{entry['module']}:{entry['attribute']}") + for entry in manifest["entry_points"] + ) + form.addRow(_("Plugin classes:"), QW.QLabel(entry_points)) + dependencies = "
".join( + escape( + f"{dependency['requirement']} " + f"({dependency['installed_version'] or _('not applicable')})" + ) + for dependency in manifest["dependencies"] + ) + form.addRow(_("Dependencies:"), QW.QLabel(dependencies or _("None"))) + else: + form.addRow(_("Plugin module:"), QW.QLabel(manifest["name"])) + form.addRow(_("File:"), QW.QLabel(manifest["filename"])) + form.addRow(_("Size:"), QW.QLabel(_format_size(manifest["size_bytes"]))) + digest = QW.QLabel(manifest["sha256"]) + digest.setTextInteractionFlags(QC.Qt.TextSelectableByMouse) + form.addRow(_("SHA-256:"), digest) + layout.addLayout(form) + + warning = QW.QLabel( + _( + "A plugin runs with the same rights as DataLab: it may read and " + "modify your files. Install only plugins from authors you trust." + ) + ) + warning.setWordWrap(True) + layout.addWidget(warning) + + button_box = QW.QDialogButtonBox(QW.QDialogButtonBox.Cancel) + self.install_button = button_box.addButton( + _("Install"), QW.QDialogButtonBox.AcceptRole + ) + self.install_button.setAutoDefault(False) + button_box.button(QW.QDialogButtonBox.Cancel).setDefault(True) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + +class InstalledPluginItemWidget(QW.QWidget): + """Row describing a plugin installed from a file. + + Args: + plugin: Installed plugin + path: Installed plugin file path + parent: Parent widget + """ + + def __init__( + self, plugin: InstalledPlugin, path: str, parent: QW.QWidget | None = None + ) -> None: + super().__init__(parent) + self.plugin = plugin + layout = QW.QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(layout) + + label = QW.QLabel( + f"{escape(_plugin_title(plugin))}
" + + escape( + _("Installed on %s from %s") + % (_format_date(plugin.installed_at), plugin.filename) + ) + ) + label.setWordWrap(True) + layout.addWidget(label, 1) + + self.show_in_folder_button = QW.QToolButton() + self.show_in_folder_button.setIcon(get_icon("show_in_folder.svg")) + self.show_in_folder_button.setToolTip(_("Show in folder")) + self.show_in_folder_button.setAutoRaise(True) + self.show_in_folder_button.clicked.connect(lambda: _show_in_folder(path)) + layout.addWidget(self.show_in_folder_button) + + self.uninstall_button = QW.QPushButton(_("Uninstall")) + layout.addWidget(self.uninstall_button) + + +class InstalledPluginsWidget(QW.QWidget): + """Tab installing plugins from a file and listing the installed ones. + + Args: + reload_plugins: Callback applying the configuration and reloading plugins + parent: Parent widget + """ + + def __init__( + self, reload_plugins: Callable[[], None], parent: QW.QWidget | None = None + ) -> None: + super().__init__(parent) + self._reload_plugins = reload_plugins + self.item_widgets: list[InstalledPluginItemWidget] = [] + layout = QW.QVBoxLayout() + self.setLayout(layout) + + intro = QW.QLabel( + _( + "Install a plugin shared as a wheel file (.whl) or as " + "a Python module (datalab_*.py). Wheels must be pure " + "Python and rely only on packages already provided by DataLab." + ) + ) + intro.setWordWrap(True) + layout.addWidget(intro) + + install_layout = QW.QHBoxLayout() + self.install_button = QW.QPushButton( + get_icon("metadata_add.svg"), _("Install from file...") + ) + self.install_button.clicked.connect(self.install_from_file) + install_layout.addWidget(self.install_button) + install_layout.addStretch() + layout.addLayout(install_layout) + layout.addSpacing(12) + + title = QW.QLabel(_("Plugins installed from a file")) + title_font = title.font() + title_font.setBold(True) + title.setFont(title_font) + layout.addWidget(title) + + self.items_layout = QW.QVBoxLayout() + self.items_layout.setContentsMargins(18, 0, 0, 0) + layout.addLayout(self.items_layout) + self.placeholder = QW.QLabel(_("No plugin has been installed from a file.")) + self.placeholder.setWordWrap(True) + apply_subdued_color(self.placeholder) + self.items_layout.addWidget(self.placeholder) + layout.addStretch() + self.refresh() + + @property + def store(self) -> InstalledPluginStore: + """Return the installed plugin store.""" + return get_installed_plugin_store() + + def refresh(self) -> None: + """Rebuild the list of installed plugins.""" + for widget in self.item_widgets: + self.items_layout.removeWidget(widget) + widget.deleteLater() + self.item_widgets.clear() + try: + plugins = self.store.get_plugins() + except PluginInstallError as exc: + self.placeholder.setText(str(exc)) + self.placeholder.show() + return + self.placeholder.setText(_("No plugin has been installed from a file.")) + self.placeholder.setVisible(not plugins) + for plugin in plugins: + widget = InstalledPluginItemWidget(plugin, self.store.get_path(plugin)) + widget.uninstall_button.clicked.connect( + lambda _checked=False, item=plugin: self.uninstall(item) + ) + self.item_widgets.append(widget) + self.items_layout.addWidget(widget) + + def select_file(self) -> str | None: + """Ask the user for the plugin file to install.""" + filename, _filter = getopenfilename( + self, + _("Install plugin from file"), + Conf.base_dir.get(osp.expanduser("~")), + _("DataLab plugins") + " (*.whl *.py)", + ) + return filename or None + + def confirm_installation(self, manifest: dict) -> bool: + """Return True if the user agrees to install the inspected file.""" + return bool(PluginConsentDialog(manifest, self).exec()) + + def install_from_file(self) -> InstallResult | None: + """Install a plugin file chosen by the user. + + Returns: + Installation result, or None if cancelled or refused + """ + path = self.select_file() + if not path: + return None + is_wheel = path.lower().endswith(".whl") + try: + if is_wheel: + manifest = self.store.inspect_wheel_file(path) + else: + manifest = self.store.inspect_module_file(path) + if not self.confirm_installation(manifest): + return None + install = ( + self.store.install_wheel if is_wheel else self.store.install_module + ) + result = install(path, expected_sha256=manifest["sha256"]) + except (OSError, ValueError) as exc: + QW.QMessageBox.warning( + self, + _("Install plugin"), + _("This file cannot be installed as a plugin:") + f"\n\n{exc}", + ) + return None + self.refresh() + title = _plugin_title(result.plugin) + if result.restart_required: + QW.QMessageBox.information( + self, + _("Install plugin"), + _("%s will be loaded at the next start of DataLab.") % title, + ) + elif self._ask_reload(_("%s has been installed.") % title): + self._reload_plugins() + if self._enable_plugins_from(result.plugin): + self._reload_plugins() + return result + + def uninstall(self, plugin: InstalledPlugin) -> None: + """Uninstall a plugin after confirmation.""" + title = _plugin_title(plugin) + reply = QW.QMessageBox.question( + self, + _("Uninstall plugin"), + _("Do you want to uninstall %s?") % title, + QW.QMessageBox.Yes | QW.QMessageBox.No, + QW.QMessageBox.No, + ) + if reply != QW.QMessageBox.Yes: + return + try: + self.store.uninstall(plugin) + except (OSError, ValueError) as exc: + QW.QMessageBox.warning(self, _("Uninstall plugin"), str(exc)) + return + self.refresh() + if self._ask_reload(_("%s has been uninstalled.") % title): + self._reload_plugins() + + def _ask_reload(self, message: str) -> bool: + reply = QW.QMessageBox.question( + self, + _("Reload Plugins"), + message + "\n\n" + _("Do you want to reload plugins now?"), + QW.QMessageBox.Yes | QW.QMessageBox.No, + QW.QMessageBox.Yes, + ) + return reply == QW.QMessageBox.Yes + + def _enable_plugins_from(self, plugin: InstalledPlugin) -> bool: + """Enable the classes of a newly installed plugin in an explicit list. + + Returns: + True if the list of enabled plugins changed + """ + enabled_ids = Conf.plugins_enabled_list.get(None) + if enabled_ids is None: + return False + new_ids = [ + plugin_class.get_plugin_id() + for plugin_class in PluginRegistry.get_plugin_classes() + if getattr(plugin_class, "__plugin_filepath__", None) + and self.store.find_plugin(plugin_class.__plugin_filepath__) == plugin + and plugin_class.get_plugin_id() not in enabled_ids + ] + if not new_ids: + return False + Conf.plugins_enabled_list.set(list(enabled_ids) + new_ids) + return True diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index 1f9ea2d01..c9b49549e 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -2111,6 +2111,9 @@ msgstr "Activer/désactiver les plugins" msgid "Plugin settings" msgstr "Paramètres des plugins" +msgid "Install plugins" +msgstr "Installer des plugins" + msgid "Apply and reload plugins" msgstr "Appliquer et recharger les plugins" @@ -2184,6 +2187,95 @@ 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 "Install plugin" +msgstr "Installer un plugin" + +msgid "Plugin:" +msgstr "Plugin :" + +msgid "Version:" +msgstr "Version :" + +msgid "Description:" +msgstr "Description :" + +msgid "Plugin classes:" +msgstr "Classes de plugin :" + +msgid "not applicable" +msgstr "non applicable" + +msgid "Dependencies:" +msgstr "Dépendances :" + +msgid "Plugin module:" +msgstr "Module de plugin :" + +msgid "File:" +msgstr "Fichier :" + +msgid "Size:" +msgstr "Taille :" + +msgid "SHA-256:" +msgstr "SHA-256 :" + +msgid "A plugin runs with the same rights as DataLab: it may read and modify your files. Install only plugins from authors you trust." +msgstr "Un plugin s'exécute avec les mêmes droits que DataLab : il peut lire et modifier vos fichiers. N'installez que des plugins provenant d'auteurs de confiance." + +msgid "Install" +msgstr "Installer" + +#, python-format +msgid "Installed on %s from %s" +msgstr "Installé le %s depuis %s" + +msgid "Uninstall" +msgstr "Désinstaller" + +msgid "Install a plugin shared as a wheel file (.whl) or as a Python module (datalab_*.py). Wheels must be pure Python and rely only on packages already provided by DataLab." +msgstr "Installez un plugin partagé sous forme de fichier wheel (.whl) ou de module Python (datalab_*.py). Les wheels doivent être en Python pur et ne dépendre que de paquets déjà fournis par DataLab." + +msgid "Install from file..." +msgstr "Installer depuis un fichier..." + +msgid "Plugins installed from a file" +msgstr "Plugins installés depuis un fichier" + +msgid "No plugin has been installed from a file." +msgstr "Aucun plugin n'a été installé depuis un fichier." + +msgid "Install plugin from file" +msgstr "Installer un plugin depuis un fichier" + +msgid "DataLab plugins" +msgstr "Plugins DataLab" + +msgid "This file cannot be installed as a plugin:" +msgstr "Ce fichier ne peut pas être installé comme plugin :" + +#, python-format +msgid "%s will be loaded at the next start of DataLab." +msgstr "%s sera chargé au prochain démarrage de DataLab." + +#, python-format +msgid "%s has been installed." +msgstr "%s a été installé." + +msgid "Uninstall plugin" +msgstr "Désinstaller le plugin" + +#, python-format +msgid "Do you want to uninstall %s?" +msgstr "Voulez-vous désinstaller %s ?" + +#, python-format +msgid "%s has been uninstalled." +msgstr "%s a été désinstallé." + +msgid "Do you want to reload plugins now?" +msgstr "Voulez-vous recharger les plugins maintenant ?" + msgid "Failed to deserialize processing parameters from JSON." msgstr "Échec de la désérialisation des paramètres de traitement depuis le format JSON." @@ -3746,9 +3838,6 @@ msgstr "Information" msgid "Question" msgstr "Question" -msgid "Installed plugins" -msgstr "Plugins installés" - msgid "Test data" msgstr "Données de test" diff --git a/datalab/plugins/base.py b/datalab/plugins/base.py index 4bf52afdf..086e0bbc3 100644 --- a/datalab/plugins/base.py +++ b/datalab/plugins/base.py @@ -1293,7 +1293,7 @@ def discover_plugins() -> list[ModuleType]: get_installed_plugin_store().activate() except (OSError, ValueError): _record_plugin_discovery_failure( - _("Installed plugins"), + _("Plugins installed from a file"), f"installed plugin store {INSTALLED_PLUGINS_PATH!r}", traceback.format_exc(), INSTALLED_PLUGINS_PATH, diff --git a/datalab/plugins/store.py b/datalab/plugins/store.py index 3b5c6432c..364c16b66 100644 --- a/datalab/plugins/store.py +++ b/datalab/plugins/store.py @@ -299,6 +299,20 @@ def install_wheel( ) return InstallResult(plugin, replaced, plugin.is_imported()) + def inspect_module_file(self, path: str) -> dict: + """Return the manifest of a ``datalab_*.py`` plugin module file. + + Raises: + PluginInstallError: Invalid file name or conflicting module name + """ + name, data = self._read_module(path) + return { + "filename": osp.basename(path), + "name": name, + "sha256": hashlib.sha256(data).hexdigest(), + "size_bytes": len(data), + } + def install_module( self, path: str, *, expected_sha256: str | None = None ) -> InstallResult: @@ -311,28 +325,9 @@ def install_module( Raises: PluginInstallError: Invalid file name or conflicting module name """ - name, extension = osp.splitext(osp.basename(path)) - if extension != ".py" or not _is_module_name(name): - raise PluginInstallError( - "A plugin module file name must look like datalab_.py" - ) - with open(path, "rb") as file: - data = file.read(MAX_MODULE_BYTES + 1) - if len(data) > MAX_MODULE_BYTES: - raise PluginInstallError( - f"Plugin module exceeds the {MAX_MODULE_BYTES} byte size limit" - ) + name, data = self._read_module(path) sha256 = hashlib.sha256(data).hexdigest() self._check_expected_digest(sha256, expected_sha256) - modules_dir = _normalized(self.modules_dir) - search_path = [ - entry for entry in sys.path if entry and _normalized(entry) != modules_dir - ] - spec = PathFinder.find_spec(name, search_path) - if spec is not None: - raise PluginInstallError( - f"A module named {name!r} already exists: {spec.origin}" - ) plugin = InstalledPlugin( kind=MODULE_KIND, name=name, @@ -413,6 +408,30 @@ def _check_expected_digest(sha256: str, expected_sha256: str | None) -> None: if expected_sha256 is not None and sha256 != expected_sha256: raise PluginInstallError("The file changed since it was inspected") + def _read_module(self, path: str) -> tuple[str, bytes]: + """Return the name and content of a valid plugin module file.""" + name, extension = osp.splitext(osp.basename(path)) + if extension != ".py" or not _is_module_name(name): + raise PluginInstallError( + "A plugin module file name must look like datalab_.py" + ) + with open(path, "rb") as file: + data = file.read(MAX_MODULE_BYTES + 1) + if len(data) > MAX_MODULE_BYTES: + raise PluginInstallError( + f"Plugin module exceeds the {MAX_MODULE_BYTES} byte size limit" + ) + modules_dir = _normalized(self.modules_dir) + search_path = [ + entry for entry in sys.path if entry and _normalized(entry) != modules_dir + ] + spec = PathFinder.find_spec(name, search_path) + if spec is not None: + raise PluginInstallError( + f"A module named {name!r} already exists: {spec.origin}" + ) + return name, data + def _add( self, plugin: InstalledPlugin, data: bytes, same_plugin ) -> InstalledPlugin | None: diff --git a/datalab/tests/backbone/plugins/plugin_store_unit_test.py b/datalab/tests/backbone/plugins/plugin_store_unit_test.py index 0cde7f8c3..13a908de6 100644 --- a/datalab/tests/backbone/plugins/plugin_store_unit_test.py +++ b/datalab/tests/backbone/plugins/plugin_store_unit_test.py @@ -160,7 +160,7 @@ def test_corrupt_index_is_reported_without_aborting_discovery( discover_plugins() (failed,) = PluginRegistry.get_failed_plugins() - assert failed.name == "Installed plugins" + assert failed.name == "Plugins installed from a file" assert "PluginInstallError" in failed.traceback diff --git a/datalab/tests/features/plugins/plugin_install_dialog_test.py b/datalab/tests/features/plugins/plugin_install_dialog_test.py new file mode 100644 index 000000000..1c6eab963 --- /dev/null +++ b/datalab/tests/features/plugins/plugin_install_dialog_test.py @@ -0,0 +1,159 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Installing and uninstalling plugins from a file in the configuration dialog.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +from qtpy import QtWidgets as QW + +from datalab import __version__ +from datalab.config import Conf +from datalab.config.persistence import has_persisted_option, remove_persisted_option +from datalab.gui.plugins.config import PluginConfigDialog, PluginState +from datalab.gui.plugins.install import InstalledPluginsWidget, PluginConsentDialog +from datalab.plugins import PluginRegistry +from datalab.plugins import base as plugin_base +from datalab.plugins.store import InstalledPluginStore +from datalab.tests import datalab_test_app_context +from datalab.tests.backbone.plugins.wheel_factory import ( + make_plugin_wheel, + wheel_filename, +) + +PACKAGE = "datalab_install_ui_probe" +PLUGIN_ID = "org.example.install-ui-probe" + + +@pytest.fixture(name="store") +def fixture_store( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> InstalledPluginStore: + """Use an empty installed plugin store and restore the import state.""" + store = InstalledPluginStore( + str(tmp_path / "installed_plugins"), + host_distributions={"datalab-platform": __version__}, + ) + monkeypatch.setattr(plugin_base, "_INSTALLED_PLUGIN_STORE", store) + monkeypatch.setattr(sys, "path", list(sys.path)) + yield store + for name in [name for name in sys.modules if name.startswith(PACKAGE)]: + del sys.modules[name] + + +def write_wheel(directory: Path) -> str: + """Write the probe plugin wheel and return its path.""" + distribution = "datalab-install-ui-probe" + path = directory / wheel_filename(distribution, "1.0.0") + path.write_bytes( + make_plugin_wheel( + distribution=distribution, + version="1.0.0", + package=PACKAGE, + plugin_id=PLUGIN_ID, + requires_dist=("datalab-platform>=1.0",), + ) + ) + return str(path) + + +def test_installed_wheel_is_enabled_loaded_and_uninstalled( + store: InstalledPluginStore, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An installed plugin is enabled even when the enabled list is explicit.""" + path = write_wheel(tmp_path) + had_config = has_persisted_option(Conf, "plugins_enabled_list") + original_enabled_list = Conf.plugins_enabled_list.get(None) + consented: list[dict] = [] + monkeypatch.setattr( + QW.QMessageBox, "question", lambda *_args, **_kwargs: QW.QMessageBox.Yes + ) + try: + with datalab_test_app_context(console=False) as win: + Conf.plugins_enabled_list.set( + [plugin.plugin_id for plugin in PluginRegistry.get_plugins()] + ) + dialog = PluginConfigDialog(win) + widget = dialog.installed_plugins_widget + assert dialog.tabs.indexOf(widget) == 2 + monkeypatch.setattr(widget, "select_file", lambda: path) + monkeypatch.setattr( + widget, + "confirm_installation", + lambda manifest: consented.append(manifest) or True, + ) + + widget.install_button.click() + QW.QApplication.processEvents() + + assert [manifest["distribution"] for manifest in consented] == [ + "datalab-install-ui-probe" + ] + assert PluginRegistry.get_plugin(PLUGIN_ID) is not None + assert PLUGIN_ID in Conf.plugins_enabled_list.get(None) + (row,) = [ + row + for row in dialog.plugin_widgets + if row.plugin_class.get_plugin_id() == PLUGIN_ID + ] + assert row.state == PluginState.ENABLED + (item,) = widget.item_widgets + assert item.plugin == store.get_plugins()[0] + + item.uninstall_button.click() + QW.QApplication.processEvents() + + assert not store.get_plugins() + assert not widget.item_widgets + assert PluginRegistry.get_plugin(PLUGIN_ID) is None + assert PLUGIN_ID not in [ + row.plugin_class.get_plugin_id() for row in dialog.plugin_widgets + ] + dialog.close() + dialog.deleteLater() + finally: + if had_config: + Conf.plugins_enabled_list.set(original_enabled_list) + else: + remove_persisted_option(Conf, "plugins_enabled_list") + + +def test_refused_or_invalid_files_are_not_installed( + store: InstalledPluginStore, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Nothing is installed without consent or from an invalid file.""" + _app = QW.QApplication.instance() or QW.QApplication([]) + reloads: list[bool] = [] + warnings: list[str] = [] + widget = InstalledPluginsWidget(lambda: reloads.append(True)) + monkeypatch.setattr( + QW.QMessageBox, + "warning", + lambda _parent, _title, text, *_args: warnings.append(text), + ) + path = write_wheel(tmp_path) + monkeypatch.setattr(widget, "select_file", lambda: path) + monkeypatch.setattr(widget, "confirm_installation", lambda _manifest: False) + + assert widget.install_from_file() is None + + invalid = tmp_path / "datalab_bad-name.py" + invalid.write_text("", encoding="utf-8") + monkeypatch.setattr(widget, "select_file", lambda: str(invalid)) + + assert widget.install_from_file() is None + assert len(warnings) == 1 and "datalab_.py" in warnings[0] + assert not store.get_plugins() and not reloads + assert widget.placeholder.isVisibleTo(widget) + + dialog = PluginConsentDialog(store.inspect_wheel_file(path), widget) + assert dialog.windowTitle() == "Install plugin" + assert not dialog.install_button.isDefault() + widget.deleteLater() + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/doc/features/advanced/plugins.rst b/doc/features/advanced/plugins.rst index cb7e5b46e..f1cbffcb3 100644 --- a/doc/features/advanced/plugins.rst +++ b/doc/features/advanced/plugins.rst @@ -117,6 +117,9 @@ Plugins are automatically discovered at startup from multiple locations: - The standalone distribution directory: If using a frozen (standalone) build, the `plugins` folder located next to the executable is scanned. +- Plugins installed from a file: + Wheels and modules installed from **Plugins > Configure plugins...**, in the **Install plugins** tab (see :ref:`plugin_install_from_file`). + - The internal `datalab/plugins/builtin` folder (not recommended for user plugins): This location is reserved for built-in or bundled plugins and should not be modified manually. @@ -145,8 +148,8 @@ Managing plugins in DataLab The **Plugins** menu provides two dedicated actions: - **Configure plugins...** - Opens the plugin configuration dialog, organized in two tabs: - **Enable/disable plugins** and **Plugin settings**. + Opens the plugin configuration dialog, organized in three tabs: + **Enable/disable plugins**, **Plugin settings** and **Install plugins**. - **Reload plugins** Reloads plugin modules from disk without restarting DataLab. @@ -187,6 +190,20 @@ This tab lists every directory scanned at startup, in two groups: The tab also holds a **Compatibility warnings** option to hide warnings for incompatible DataLab v0.20 plugins. +.. _plugin_install_from_file: + +Install plugins +~~~~~~~~~~~~~~~ + +This tab installs a plugin shared as a file, without any Python tool: this is the way to add plugins to the standalone version of DataLab. **Install from file...** accepts two kinds of files: + +- a wheel (``.whl``) built from a plugin project, for example with ``python -m build``: it must be pure Python (``py3-none-any``), declare its plugin class in the ``datalab.plugins`` entry-point group, and depend only on packages already provided by DataLab; +- a single Python module named ``datalab_.py``. + +Before installing, DataLab checks the file without running it and shows its name, version, plugin classes, dependencies and SHA-256 digest. A plugin runs with the same rights as DataLab: install only plugins from authors you trust. + +Installed plugins are kept in the ``installed_plugins`` folder of the DataLab configuration directory (e.g. ``~/.DataLab_v1/installed_plugins``) and listed in the tab, where **Uninstall** removes them. After installing or uninstalling, DataLab offers to reload plugins; a newly installed plugin is enabled. A new version of a plugin already loaded is only used at the next start of DataLab. + When reloading plugins, DataLab performs the following steps: 1. Unregister currently active plugins and their owned computations, diff --git a/doc/release_notes/release_1.04.md b/doc/release_notes/release_1.04.md index ffc8d0959..3065a9b3c 100644 --- a/doc/release_notes/release_1.04.md +++ b/doc/release_notes/release_1.04.md @@ -41,6 +41,12 @@ * The welcome page stays tidy when many applications are installed: tiles are limited to a configurable number of rows (two by default), with a last tile opening the catalog for the others. Applications may be pinned to the top or hidden, and recently used ones come first; the catalog offers a search field and the same welcome page options * Plugins may declare an icon, shown in the **Applications** catalog, on their welcome page tile and in the **Configure plugins...** dialog +**Plugin installation:** + +* Plugins can now be installed from a file, without any Python tool, including in the standalone version of DataLab: the new **Install plugins** tab of **Plugins > Configure plugins...** accepts a pure-Python wheel (`.whl`) or a single `datalab_.py` module +* Before installing, DataLab checks the file without running it and shows its name, version, plugin classes, dependencies and SHA-256 digest; a wheel requiring a package that DataLab does not provide is refused +* Installed plugins are listed in the same tab, where they can be uninstalled; a newly installed plugin is enabled and loaded after a plugin reload, and a new version of an already loaded plugin is used at the next start + **Plugin project generator:** * Hardened ``datalab-plugin create`` from the Camera pilot feedback: generated projects now separate host-independent ``core`` code, headless ``workflow`` orchestration, and Desktop/Web ``adapters`` from the first commit From dd808f9015201036d788a7d3f79f77b01b11252c Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:59:52 +0200 Subject: [PATCH 2/3] docs(i18n): translate plugin installation documentation Assisted-by: Claude Opus 5.5 --- .../LC_MESSAGES/features/advanced/plugins.po | 25 +++++++++++++++++-- .../LC_MESSAGES/release_notes/release_1.04.po | 12 +++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po index d5dcbc0c6..c31c4ad89 100644 --- a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po +++ b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po @@ -97,6 +97,9 @@ msgstr "Répertoires de plugins supplémentaires : Tout nombre de répertoires s msgid "The standalone distribution directory: If using a frozen (standalone) build, the `plugins` folder located next to the executable is scanned." msgstr "Le répertoire de distribution autonome : Si vous utilisez une version gelée (autonome), le dossier `plugins` situé à côté de l'exécutable est analysé." +msgid "Plugins installed from a file: Wheels and modules installed from **Plugins > Configure plugins...**, in the **Install plugins** tab (see :ref:`plugin_install_from_file`)." +msgstr "Les plugins installés depuis un fichier : Wheels et modules installés depuis **Plugins > Configurer les plugins...**, dans l'onglet **Installer des plugins** (voir :ref:`plugin_install_from_file`)." + msgid "The internal `datalab/plugins/builtin` folder (not recommended for user plugins): This location is reserved for built-in or bundled plugins and should not be modified manually." msgstr "Le dossier interne `datalab/plugins/builtin` (non recommandé pour les plugins utilisateur) : Cet emplacement est réservé aux plugins intégrés ou fournis et ne doit pas être modifié manuellement." @@ -112,8 +115,8 @@ msgstr "Gestion des plugins dans DataLab" msgid "The **Plugins** menu provides two dedicated actions:" msgstr "Le menu **Plugins** propose deux actions dédiées :" -msgid "**Configure plugins...** Opens the plugin configuration dialog, organized in two tabs: **Enable/disable plugins** and **Plugin settings**." -msgstr "**Configurer les plugins...** Ouvre la boîte de dialogue de configuration des plugins, organisée en deux onglets : **Activer/désactiver les plugins** et **Paramètres des plugins**." +msgid "**Configure plugins...** Opens the plugin configuration dialog, organized in three tabs: **Enable/disable plugins**, **Plugin settings** and **Install plugins**." +msgstr "**Configurer les plugins...** Ouvre la boîte de dialogue de configuration des plugins, organisée en trois onglets : **Activer/désactiver les plugins**, **Paramètres des plugins** et **Installer des plugins**." msgid "**Reload plugins** Reloads plugin modules from disk without restarting DataLab." msgstr "**Recharger les plugins** Recharge les modules de plugins depuis le disque sans redémarrer DataLab." @@ -151,6 +154,24 @@ msgstr "**Répertoires de plugins supplémentaires** : vos propres répertoires, msgid "The tab also holds a **Compatibility warnings** option to hide warnings for incompatible DataLab v0.20 plugins." msgstr "L'onglet contient également une option **Avertissements de compatibilité** pour masquer les avertissements concernant les plugins DataLab v0.20 incompatibles." +msgid "Install plugins" +msgstr "Installer des plugins" + +msgid "This tab installs a plugin shared as a file, without any Python tool: this is the way to add plugins to the standalone version of DataLab. **Install from file...** accepts two kinds of files:" +msgstr "Cet onglet installe un plugin partagé sous forme de fichier, sans aucun outil Python : c'est ainsi que l'on ajoute des plugins à la version autonome de DataLab. **Installer depuis un fichier...** accepte deux types de fichiers :" + +msgid "a wheel (``.whl``) built from a plugin project, for example with ``python -m build``: it must be pure Python (``py3-none-any``), declare its plugin class in the ``datalab.plugins`` entry-point group, and depend only on packages already provided by DataLab;" +msgstr "un wheel (``.whl``) construit à partir d'un projet de plugin, par exemple avec ``python -m build`` : il doit être en Python pur (``py3-none-any``), déclarer sa classe de plugin dans le groupe de points d'entrée ``datalab.plugins`` et ne dépendre que de paquets déjà fournis par DataLab ;" + +msgid "a single Python module named ``datalab_.py``." +msgstr "un module Python unique nommé ``datalab_.py``." + +msgid "Before installing, DataLab checks the file without running it and shows its name, version, plugin classes, dependencies and SHA-256 digest. A plugin runs with the same rights as DataLab: install only plugins from authors you trust." +msgstr "Avant l'installation, DataLab vérifie le fichier sans l'exécuter et affiche son nom, sa version, ses classes de plugin, ses dépendances et son empreinte SHA-256. Un plugin s'exécute avec les mêmes droits que DataLab : n'installez que des plugins provenant d'auteurs de confiance." + +msgid "Installed plugins are kept in the ``installed_plugins`` folder of the DataLab configuration directory (e.g. ``~/.DataLab_v1/installed_plugins``) and listed in the tab, where **Uninstall** removes them. After installing or uninstalling, DataLab offers to reload plugins; a newly installed plugin is enabled. A new version of a plugin already loaded is only used at the next start of DataLab." +msgstr "Les plugins installés sont conservés dans le dossier ``installed_plugins`` du répertoire de configuration de DataLab (par exemple ``~/.DataLab_v1/installed_plugins``) et listés dans l'onglet, où **Désinstaller** les supprime. Après une installation ou une désinstallation, DataLab propose de recharger les plugins ; un plugin nouvellement installé est activé. Une nouvelle version d'un plugin déjà chargé n'est utilisée qu'au prochain démarrage de DataLab." + msgid "When reloading plugins, DataLab performs the following steps:" msgstr "Lors du rechargement des plugins, DataLab effectue les étapes suivantes :" diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po index 0bbfe2c84..b8b37eae1 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po @@ -108,6 +108,18 @@ msgstr "La page d'accueil reste lisible lorsque de nombreuses applications sont msgid "Plugins may declare an icon, shown in the **Applications** catalog, on their welcome page tile and in the **Configure plugins...** dialog" msgstr "Les plugins peuvent déclarer une icône, affichée dans le catalogue **Applications**, sur leur tuile de la page d'accueil et dans la boîte de dialogue **Configurer les plugins...**" +msgid "**Plugin installation:**" +msgstr "**Installation de plugins :**" + +msgid "Plugins can now be installed from a file, without any Python tool, including in the standalone version of DataLab: the new **Install plugins** tab of **Plugins > Configure plugins...** accepts a pure-Python wheel (`.whl`) or a single `datalab_.py` module" +msgstr "Les plugins peuvent désormais être installés depuis un fichier, sans aucun outil Python, y compris dans la version autonome de DataLab : le nouvel onglet **Installer des plugins** de **Plugins > Configurer les plugins...** accepte un wheel en Python pur (`.whl`) ou un module unique `datalab_.py`" + +msgid "Before installing, DataLab checks the file without running it and shows its name, version, plugin classes, dependencies and SHA-256 digest; a wheel requiring a package that DataLab does not provide is refused" +msgstr "Avant l'installation, DataLab vérifie le fichier sans l'exécuter et affiche son nom, sa version, ses classes de plugin, ses dépendances et son empreinte SHA-256 ; un wheel nécessitant un paquet que DataLab ne fournit pas est refusé" + +msgid "Installed plugins are listed in the same tab, where they can be uninstalled; a newly installed plugin is enabled and loaded after a plugin reload, and a new version of an already loaded plugin is used at the next start" +msgstr "Les plugins installés sont listés dans le même onglet, où ils peuvent être désinstallés ; un plugin nouvellement installé est activé et chargé après un rechargement des plugins, et une nouvelle version d'un plugin déjà chargé est utilisée au prochain démarrage" + msgid "**Plugin project generator:**" msgstr "**Générateur de projets de plugins :**" From 467b1631e560891af9ee7867d10099525199c8b9 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:59:58 +0200 Subject: [PATCH 3/3] ci: check that the executable loads a plugin installed from a file Assisted-by: Claude Opus 5.5 --- .github/workflows/_build.yml | 12 +++ DataLab.spec | 2 + scripts/frozen_plugin_probe.py | 169 +++++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 scripts/frozen_plugin_probe.py diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 0b4ee6567..54723af28 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -421,6 +421,18 @@ jobs: # CPython 3.11 needs this Wine-built DLL to run on Windows 7. Copy-Item resources/api-ms-win-core-path-l1-1-0.dll dist/DataLab/_internal/ -Force + - name: Check that the executable loads a plugin installed from a file + shell: pwsh + run: | + # Isolated profile: DataLab reads its configuration from USERPROFILE + $env:USERPROFILE = Join-Path $env:RUNNER_TEMP "datalab-profile" + New-Item -ItemType Directory -Force $env:USERPROFILE | Out-Null + $env:DATALAB_PLUGIN_PROBE_MARKER = Join-Path $env:RUNNER_TEMP "plugin-probe.json" + python scripts/frozen_plugin_probe.py install + $process = Start-Process dist/DataLab/DataLab.exe -ArgumentList "--unattended", "--delay", "5000" -PassThru + if (-not $process.WaitForExit(300000)) { $process.Kill(); throw "DataLab did not quit" } + python scripts/frozen_plugin_probe.py verify + - name: Package executable as zip (internal, required by MSI workflow) shell: pwsh run: | diff --git a/DataLab.spec b/DataLab.spec index ec213dfb9..3d58f6ec4 100644 --- a/DataLab.spec +++ b/DataLab.spec @@ -5,6 +5,8 @@ from PyInstaller.utils.hooks import collect_submodules, collect_data_files, copy_metadata all_hidden_imports = collect_submodules('datalab') +# Plugins installed from a file may import any Sigima module +all_hidden_imports += collect_submodules('sigima', filter=lambda name: '.tests' not in name) datas = collect_data_files('datalab') + [('datalab\\plugins\\builtin', 'datalab\\plugins\\builtin')] # Macro templates are read with ``pkgutil.get_data`` and must exist on disk, # not only inside the PYZ archive (``collect_data_files`` skips ``.py`` files): diff --git a/scripts/frozen_plugin_probe.py b/scripts/frozen_plugin_probe.py new file mode 100644 index 000000000..da7586168 --- /dev/null +++ b/scripts/frozen_plugin_probe.py @@ -0,0 +1,169 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +Check that the DataLab executable loads a plugin installed from a file + +The probe plugin, installed like a user plugin wheel, records which modules a +plugin can import and which distribution metadata are available, so that the +frozen build is checked from a plugin's point of view. + +Usage (with ``DATALAB_PLUGIN_PROBE_MARKER`` set to the result file path):: + + python scripts/frozen_plugin_probe.py install + DataLab.exe --unattended --delay 5000 + python scripts/frozen_plugin_probe.py verify +""" + +from __future__ import annotations + +import io +import json +import os +import sys +import tempfile +import zipfile + +MARKER_ENV = "DATALAB_PLUGIN_PROBE_MARKER" + +#: Modules commonly imported by plugins +MODULES = ( + "numpy", + "numpy.fft", + "scipy.interpolate", + "scipy.ndimage", + "scipy.optimize", + "scipy.signal", + "scipy.special", + "scipy.stats", + "skimage.filters", + "skimage.measure", + "skimage.morphology", + "pandas", + "h5py", + "guidata.dataset", + "sigima.objects", + "sigima.params", + "sigima.proc.image", + "sigima.proc.signal", + "sigima.tools.image", + "sigima.tools.signal", + "datalab.plugins.examples", + "datalab.plugins.instruments", + "datalab.plugins.recipes", + "datalab.plugins.tiles", + "datalab.plugins.tools", +) + +#: Distributions plugin wheels may declare as requirements +DISTRIBUTIONS = ("guidata", "numpy", "plotpy", "scipy", "sigima") + +PLUGIN_SOURCE = f'''\ +import importlib +import json +import os + +from packaging.utils import canonicalize_name + +from datalab.plugins import PluginBase, PluginInfo +from datalab.plugins.base import get_installed_plugin_store + + +class FrozenProbe(PluginBase): + PLUGIN_INFO = PluginInfo( + id="org.datalab.frozen-probe", name="Frozen build probe", version="1.0.0" + ) + + def create_actions(self): + imports = {{}} + for name in {MODULES!r}: + try: + importlib.import_module(name) + imports[name] = "ok" + except Exception as exc: # pylint: disable=broad-except + imports[name] = f"{{type(exc).__name__}}: {{exc}}" + available = {{ + canonicalize_name(name): version + for name, version in get_installed_plugin_store() + .get_available_distributions() + .items() + }} + result = {{ + "imports": imports, + "distributions": {{ + name: available.get(name) for name in {DISTRIBUTIONS!r} + }}, + }} + with open(os.environ["{MARKER_ENV}"], "w", encoding="utf-8") as file: + json.dump(result, file, indent=2) +''' + + +def make_probe_wheel() -> tuple[str, bytes]: + """Return the file name and content of the probe plugin wheel.""" + dist_info = "datalab_frozen_probe-1.0.0.dist-info" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("datalab_frozen_probe/__init__.py", "") + archive.writestr("datalab_frozen_probe/plugin.py", PLUGIN_SOURCE) + archive.writestr( + f"{dist_info}/METADATA", + "Metadata-Version: 2.1\nName: datalab-frozen-probe\nVersion: 1.0.0\n" + "Requires-Dist: datalab-platform\n", + ) + archive.writestr( + f"{dist_info}/WHEEL", + "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + ) + archive.writestr( + f"{dist_info}/entry_points.txt", + "[datalab.plugins]\nprobe = datalab_frozen_probe.plugin:FrozenProbe\n", + ) + archive.writestr(f"{dist_info}/top_level.txt", "datalab_frozen_probe\n") + return "datalab_frozen_probe-1.0.0-py3-none-any.whl", buffer.getvalue() + + +def install() -> int: + """Install the probe wheel in the installed plugin store of the profile.""" + # pylint: disable=import-outside-toplevel + from datalab.plugins.base import get_installed_plugin_store + + filename, data = make_probe_wheel() + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, filename) + with open(path, "wb") as file: + file.write(data) + store = get_installed_plugin_store() + store.install_wheel(path) + print(f"Probe plugin installed in {store.root}") + return 0 + + +def verify() -> int: + """Report the probe results and fail if the plugin was not fully usable.""" + marker = os.environ[MARKER_ENV] + if not os.path.isfile(marker): + print("The probe plugin was not loaded by DataLab", file=sys.stderr) + return 1 + with open(marker, encoding="utf-8") as file: + result = json.load(file) + failures = [ + f"import {name}: {status}" + for name, status in result["imports"].items() + if status != "ok" + ] + failures.extend( + f"no metadata for {name}" + for name, version in result["distributions"].items() + if version is None + ) + print(json.dumps(result, indent=2)) + for failure in failures: + print(failure, file=sys.stderr) + return 1 if failures else 0 + + +if __name__ == "__main__": + COMMANDS = {"install": install, "verify": verify} + if len(sys.argv) != 2 or sys.argv[1] not in COMMANDS: + sys.exit(f"Usage: {sys.argv[0]} {{{'|'.join(COMMANDS)}}}") + sys.exit(COMMANDS[sys.argv[1]]())