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]]())