From a34129e0ea59783fe05ed0cd155bc75ab3038542 Mon Sep 17 00:00:00 2001
From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com>
Date: Thu, 22 Oct 2026 13:29:59 +0200
Subject: [PATCH 1/2] Add offline plugin dependency installation
Resolve approved wheels with pip in a child process without changing existing package versions. Support mirrored and local wheelhouses, explicit consent, transactional shared storage, frozen builds and native Windows path limits. Prepare DataLab 1.4.0.
Assisted-by: GPT-6.1 Sol
---
.github/workflows/_build.yml | 12 +-
DataLab.spec | 4 +
datalab/__init__.py | 2 +-
datalab/gui/plugins/available.py | 80 ++-
datalab/gui/plugins/install.py | 202 +++++++-
datalab/locale/fr/LC_MESSAGES/datalab.po | 34 +-
datalab/plugins/base.py | 49 +-
datalab/plugins/catalog.py | 44 +-
datalab/plugins/catalogtool.py | 110 ++++-
datalab/plugins/dependencies.py | 463 ++++++++++++++++++
datalab/plugins/generator.py | 24 +-
datalab/plugins/store.py | 264 +++++++++-
datalab/plugins/wheels.py | 246 +++++++---
datalab/start.pyw | 6 +-
.../plugins/plugin_catalog_unit_test.py | 43 +-
.../plugins/plugin_dependencies_unit_test.py | 348 +++++++++++++
.../tests/backbone/plugins/wheel_factory.py | 8 +
.../plugins/plugin_install_dialog_test.py | 76 +++
doc/features/advanced/plugins.rst | 42 +-
doc/release_notes/release_1.04.md | 7 +-
pyproject.toml | 1 +
requirements.txt | 1 +
scripts/frozen_plugin_probe.py | 45 +-
23 files changed, 1934 insertions(+), 177 deletions(-)
create mode 100644 datalab/plugins/dependencies.py
create mode 100644 datalab/tests/backbone/plugins/plugin_dependencies_unit_test.py
diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml
index 2aa4edadb..5b563a58a 100644
--- a/.github/workflows/_build.yml
+++ b/.github/workflows/_build.yml
@@ -428,9 +428,19 @@ jobs:
$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
+ if (Test-Path -LiteralPath $env:DATALAB_PLUGIN_PROBE_MARKER) { Remove-Item -LiteralPath $env:DATALAB_PLUGIN_PROBE_MARKER }
+ $env:DATALAB_PLUGIN_PROBE_WHEELHOUSE = Join-Path $env:RUNNER_TEMP "plugin-probe-catalog\dependencies"
+ python scripts/frozen_plugin_probe.py prepare
+ if ($LASTEXITCODE -ne 0) { throw "Cannot prepare the dependency wheelhouse" }
+ $probe = (Resolve-Path scripts/frozen_plugin_probe.py).Path
+ $log = Join-Path $env:RUNNER_TEMP "plugin-install.log"
+ $code = "import runpy,sys; sys.stdout = sys.stderr = open(r'$log', 'w', encoding='utf-8'); sys.argv = [r'$probe', 'install']; runpy.run_path(r'$probe', run_name='__main__')"
+ $installer = Start-Process dist\DataLab\DataLab.exe -ArgumentList "-c", ('"' + $code + '"') -PassThru
+ if (-not $installer.WaitForExit(300000)) { Stop-Process -Id $installer.Id; throw "Plugin installation did not finish" }
+ if ($installer.ExitCode -ne 0) { Get-Content $log; throw "Offline plugin installation failed" }
$process = Start-Process dist/DataLab/DataLab.exe -ArgumentList "--unattended", "--delay", "5000" -PassThru
if (-not $process.WaitForExit(300000)) { $process.Kill(); throw "DataLab did not quit" }
+ if ($process.ExitCode -ne 0) { throw "Frozen DataLab exited with code $($process.ExitCode)" }
python scripts/frozen_plugin_probe.py verify
- name: Package executable as zip (internal, required by MSI workflow)
diff --git a/DataLab.spec b/DataLab.spec
index e1cbd2781..410652ed4 100644
--- a/DataLab.spec
+++ b/DataLab.spec
@@ -7,7 +7,9 @@ from PyInstaller.utils.hooks import collect_submodules, collect_data_files, copy
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)
+all_hidden_imports += collect_submodules('pip')
datas = collect_data_files('datalab') + [('datalab\\plugins\\builtin', 'datalab\\plugins\\builtin')]
+datas += collect_data_files('pip') + copy_metadata('pip')
# 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):
datas += [('datalab\\gui\\macros_templates', 'datalab\\gui\\macros_templates')]
@@ -37,6 +39,8 @@ a = Analysis(
runtime_hooks=[],
excludes=[],
noarchive=False,
+ # distlib's resource finder requires a standard file-system loader.
+ module_collection_mode={'pip': 'py'},
)
pyz = PYZ(a.pure)
diff --git a/datalab/__init__.py b/datalab/__init__.py
index 0b2247185..97102b631 100644
--- a/datalab/__init__.py
+++ b/datalab/__init__.py
@@ -24,7 +24,7 @@
# this module is imported more than once, e.g. when running tests)
pass
-__version__ = "1.3.0"
+__version__ = "1.4.0"
__docurl__ = __homeurl__ = "https://datalab-platform.com/"
__supporturl__ = "https://github.com/DataLab-Platform/DataLab/issues/new/choose"
diff --git a/datalab/gui/plugins/available.py b/datalab/gui/plugins/available.py
index 8b5553eb1..492863205 100644
--- a/datalab/gui/plugins/available.py
+++ b/datalab/gui/plugins/available.py
@@ -10,7 +10,7 @@
from __future__ import annotations
-import shutil
+import os.path as osp
import sys
import tempfile
from collections.abc import Callable, Sequence
@@ -32,42 +32,21 @@
get_plugin_catalog_paths,
normalize_plugin_paths,
)
-from datalab.gui.plugins.install import InstalledPluginsWidget
+from datalab.gui.plugins.install import InstalledPluginsWidget, run_plugin_task
from datalab.plugins.catalog import (
CatalogPlugin,
CatalogRelease,
CatalogsContent,
+ download_dependency_wheels,
download_release,
load_catalogs,
)
+from datalab.plugins.dependencies import DEPENDENCIES_DIRECTORY
from datalab.plugins.store import WHEEL_KIND
from datalab.widgets.expandabletext import apply_palette_color, apply_subdued_color
CATALOG_PAGE_URL = "https://datalab-platform.com/plugins/"
-#: Running tasks, kept alive until their thread finishes
-_RUNNING_TASKS: set[_Task] = set()
-
-
-class _Task(QC.QThread):
- """Run a function in a background thread."""
-
- succeeded = QC.Signal(object)
- failed = QC.Signal(str)
-
- def __init__(self, function: Callable[[], object]) -> None:
- super().__init__()
- self._function = function
-
- def run(self) -> None: # pragma: no cover - thread entry point
- """Call the function and emit its result or error message."""
- try:
- result = self._function()
- except (OSError, ValueError) as exc:
- self.failed.emit(str(exc))
- return
- self.succeeded.emit(result)
-
def _is_newer(version: str, installed: str) -> bool:
try:
@@ -199,8 +178,11 @@ def __init__(
self._installer = installer
self.plugins: list[CatalogPlugin] | None = None
self.item_widgets: list[AvailablePluginItemWidget] = []
- self._download: tuple[CatalogPlugin, str] | None = None
+ self._download: tuple[CatalogPlugin, tempfile.TemporaryDirectory] | None = None
self._busy = False
+ self._installer.busy_changed.connect(
+ lambda busy: self._set_busy(_("Installing plugin...") if busy else None)
+ )
self._directories = merge_catalog_directories(get_plugin_catalog_paths())
#: Catalog URL and local folders of the last loading
self._loaded_sources: tuple[str, tuple[str, ...]] | None = None
@@ -286,12 +268,7 @@ def run_task(
on_failure: Callable[[str], None],
) -> None:
"""Run a function in a background thread and report its outcome."""
- task = _Task(function)
- _RUNNING_TASKS.add(task)
- task.succeeded.connect(on_success)
- task.failed.connect(on_failure)
- task.finished.connect(lambda: _RUNNING_TASKS.discard(task))
- task.start()
+ run_plugin_task(function, on_success, on_failure)
def refresh(self) -> None:
"""Load the catalogs again."""
@@ -315,15 +292,24 @@ def install(self, plugin: CatalogPlugin) -> None:
release = plugin.get_installable_release()
if release is None or self._busy:
return
- directory = tempfile.mkdtemp(prefix="datalab-plugin-")
+ directory = tempfile.TemporaryDirectory(prefix="datalab-plugin-")
self._download = (plugin, directory)
self._set_busy(_("Downloading %s...") % release.filename)
self.run_task(
- lambda: download_release(release, directory),
+ lambda: self._download_release(release, directory.name),
self._on_downloaded,
self._on_download_failed,
)
+ @staticmethod
+ def _download_release(release: CatalogRelease, directory: str) -> str:
+ path = download_release(release, directory)
+ if release.dependency_wheels:
+ download_dependency_wheels(
+ release, str(osp.join(directory, DEPENDENCIES_DIRECTORY))
+ )
+ return path
+
def _set_busy(self, message: str | None) -> None:
self._busy = message is not None
self.refresh_button.setEnabled(not self._busy)
@@ -364,20 +350,26 @@ def _on_downloaded(self, path: str) -> None:
self._download = None
self._set_busy(None)
self.status_label.setText("")
- try:
- if plugin.source:
- origin = _("Local plugin catalog (%s)") % plugin.source
- else:
- origin = _("DataLab plugin catalog (%s)") % _tier_label(plugin)
- self._installer.install_path(path, origin=origin)
- finally:
- shutil.rmtree(directory, ignore_errors=True)
- self._populate()
+ if plugin.source:
+ origin = _("Local plugin catalog (%s)") % plugin.source
+ else:
+ origin = _("DataLab plugin catalog (%s)") % _tier_label(plugin)
+ wheelhouse = osp.join(directory.name, DEPENDENCIES_DIRECTORY)
+ if plugin.source and not osp.isdir(wheelhouse):
+ wheelhouse = osp.join(plugin.source, DEPENDENCIES_DIRECTORY)
+
+ def finished() -> None:
+ directory.cleanup()
+ self._populate()
+
+ self._installer.install_path(
+ path, origin=origin, wheelhouse=wheelhouse, on_finished=finished
+ )
def _on_download_failed(self, message: str) -> None:
_plugin, directory = self._download
self._download = None
- shutil.rmtree(directory, ignore_errors=True)
+ directory.cleanup()
self._set_busy(None)
self.status_label.setText("")
QW.QMessageBox.warning(self, _("Install plugin"), message)
diff --git a/datalab/gui/plugins/install.py b/datalab/gui/plugins/install.py
index d2809b94b..5099ccdaf 100644
--- a/datalab/gui/plugins/install.py
+++ b/datalab/gui/plugins/install.py
@@ -14,6 +14,7 @@
from collections.abc import Callable
from datetime import datetime
from html import escape
+from typing import TypeVar
from guidata.configtools import get_icon
from guidata.qthelpers import win32_fix_title_bar_background
@@ -22,8 +23,11 @@
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.base import (
+ enable_installed_plugin_classes,
+ get_installed_plugin_store,
+)
+from datalab.plugins.dependencies import DependencyPlan
from datalab.plugins.store import (
WHEEL_KIND,
InstalledPlugin,
@@ -34,6 +38,43 @@
from datalab.utils.qthelpers import show_in_folder as _show_in_folder
from datalab.widgets.expandabletext import apply_subdued_color
+_Result = TypeVar("_Result")
+_RUNNING_TASKS: set[_Task] = set()
+
+
+class _Task(QC.QThread):
+ """Run file and dependency operations outside the GUI thread."""
+
+ succeeded = QC.Signal(object)
+ failed = QC.Signal(str)
+
+ def __init__(self, function: Callable[[], object]) -> None:
+ super().__init__()
+ self._function = function
+
+ def run(self) -> None:
+ """Call the operation and report expected file or validation errors."""
+ try:
+ result = self._function()
+ except (OSError, ValueError) as exc:
+ self.failed.emit(str(exc))
+ return
+ self.succeeded.emit(result)
+
+
+def run_plugin_task(
+ function: Callable[[], _Result],
+ on_success: Callable[[_Result], None],
+ on_failure: Callable[[str], None],
+) -> None:
+ """Keep an asynchronous plugin operation alive until its thread finishes."""
+ task = _Task(function)
+ _RUNNING_TASKS.add(task)
+ task.succeeded.connect(on_success)
+ task.failed.connect(on_failure)
+ task.finished.connect(lambda: _RUNNING_TASKS.discard(task))
+ task.start()
+
def _format_size(size_bytes: int) -> str:
return QC.QLocale.system().formattedDataSize(size_bytes)
@@ -91,14 +132,36 @@ def __init__(
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"]
+ dependencies = []
+ for dependency in manifest["dependencies"]:
+ version = dependency["installed_version"]
+ if version is None:
+ version = (
+ _("not installed")
+ if dependency["applies"]
+ else _("not applicable")
+ )
+ dependencies.append(escape(f"{dependency['requirement']} ({version})"))
+ form.addRow(
+ _("Dependencies:"), QW.QLabel("
".join(dependencies) or _("None"))
)
- form.addRow(_("Dependencies:"), QW.QLabel(dependencies or _("None")))
+ additional = manifest.get("additional_dependencies", [])
+ if additional:
+ details = "
".join(
+ escape(
+ f"{wheel['distribution']} {wheel['version']} "
+ f"({_format_size(wheel['size_bytes'])})"
+ )
+ + f"
SHA-256: {escape(wheel['sha256'])}"
+ for wheel in additional
+ )
+ label = QW.QLabel(details)
+ label.setWordWrap(True)
+ label.setTextInteractionFlags(QC.Qt.TextSelectableByMouse)
+ form.addRow(_("Additional packages from the local wheelhouse:"), label)
+ source = QW.QLabel(escape(manifest["dependency_source"]))
+ source.setWordWrap(True)
+ form.addRow(_("Dependency source:"), source)
else:
form.addRow(_("Plugin module:"), QW.QLabel(manifest["name"]))
form.addRow(_("File:"), QW.QLabel(manifest["filename"]))
@@ -175,11 +238,17 @@ class InstalledPluginsWidget(QW.QWidget):
parent: Parent widget
"""
+ busy_changed = QC.Signal(bool)
+
def __init__(
self, reload_plugins: Callable[[], None], parent: QW.QWidget | None = None
) -> None:
super().__init__(parent)
self._reload_plugins = reload_plugins
+ self._pending_install: tuple[str, str | None] | None = None
+ self._pending_finish: Callable[[], None] | None = None
+ self._dependency_plan: DependencyPlan | None = None
+ self._manifest: dict | None = None
self.item_widgets: list[InstalledPluginItemWidget] = []
layout = QW.QVBoxLayout()
self.setLayout(layout)
@@ -188,7 +257,9 @@ def __init__(
_(
"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."
+ "Python. Missing dependencies may be supplied as wheels in an "
+ "adjacent dependencies folder, without Internet "
+ "access or changing packages already installed."
)
)
intro.setWordWrap(True)
@@ -203,6 +274,10 @@ def __init__(
install_layout.addStretch()
layout.addLayout(install_layout)
layout.addSpacing(12)
+ self.status_label = QW.QLabel()
+ self.status_label.setWordWrap(True)
+ apply_subdued_color(self.status_label)
+ layout.addWidget(self.status_label)
title = QW.QLabel(_("Plugins installed from a file"))
title_font = title.font()
@@ -273,7 +348,12 @@ def install_from_file(self) -> InstallResult | None:
return self.install_path(path)
def install_path(
- self, path: str, origin: str | None = None
+ self,
+ path: str,
+ origin: str | None = None,
+ *,
+ wheelhouse: str | None = None,
+ on_finished: Callable[[], None] | None = None,
) -> InstallResult | None:
"""Install a plugin file after the user's consent.
@@ -285,9 +365,29 @@ def install_path(
Installation result, or None if refused
"""
is_wheel = path.lower().endswith(".whl")
+ asynchronous = False
try:
+ if self._pending_install is not None:
+ raise PluginInstallError("Another plugin installation is in progress")
if is_wheel:
- manifest = self.store.inspect_wheel_file(path)
+ manifest = self.store.inspect_wheel_file(
+ path, allow_missing_dependencies=True
+ )
+ if any(
+ item["applies"] and not item["compatible"]
+ for item in manifest["dependencies"]
+ ):
+ self._pending_install = path, origin
+ self._pending_finish = on_finished
+ self._manifest = manifest
+ self._set_busy(True, _("Checking the local dependency wheels..."))
+ run_plugin_task(
+ lambda: self.store.prepare_wheel_file(path, wheelhouse),
+ self._on_prepared,
+ self._on_install_failed,
+ )
+ asynchronous = True
+ return None
else:
manifest = self.store.inspect_module_file(path)
if not self.confirm_installation(manifest, origin=origin):
@@ -303,6 +403,13 @@ def install_path(
_("This file cannot be installed as a plugin:") + f"\n\n{exc}",
)
return None
+ finally:
+ if not asynchronous and on_finished is not None:
+ on_finished()
+ self._present_result(result)
+ return result
+
+ def _present_result(self, result: InstallResult) -> None:
self.refresh()
title = _plugin_title(result.plugin)
if result.restart_required:
@@ -315,7 +422,58 @@ def install_path(
self._reload_plugins()
if self._enable_plugins_from(result.plugin):
self._reload_plugins()
- return result
+
+ def _set_busy(self, busy: bool, text: str = "") -> None:
+ self.install_button.setEnabled(not busy)
+ for item in self.item_widgets:
+ item.uninstall_button.setEnabled(not busy)
+ self.status_label.setText(text)
+ self.busy_changed.emit(busy)
+
+ def _on_prepared(self, prepared: tuple[dict, DependencyPlan | None]) -> None:
+ manifest, self._dependency_plan = prepared
+ assert self._pending_install is not None
+ path, origin = self._pending_install
+ if self._manifest is None or manifest["sha256"] != self._manifest["sha256"]:
+ self._on_install_failed(
+ _("The plugin changed while checking dependencies.")
+ )
+ return
+ if not self.confirm_installation(manifest, origin=origin):
+ self._finish_installation()
+ return
+ self._set_busy(True, _("Installing the plugin and its dependencies..."))
+ run_plugin_task(
+ lambda: self.store.install_wheel(
+ path,
+ expected_sha256=manifest["sha256"],
+ dependency_plan=self._dependency_plan,
+ ),
+ self._on_installed,
+ self._on_install_failed,
+ )
+
+ def _on_installed(self, result: InstallResult) -> None:
+ try:
+ self._present_result(result)
+ finally:
+ self._finish_installation()
+
+ def _on_install_failed(self, message: str) -> None:
+ self._finish_installation()
+ QW.QMessageBox.warning(self, _("Install plugin"), message)
+
+ def _finish_installation(self) -> None:
+ if self._dependency_plan is not None:
+ self._dependency_plan.close()
+ self._dependency_plan = None
+ self._pending_install = None
+ self._manifest = None
+ self._set_busy(False)
+ callback = self._pending_finish
+ self._pending_finish = None
+ if callback is not None:
+ callback()
def uninstall(self, plugin: InstalledPlugin) -> None:
"""Uninstall a plugin after confirmation."""
@@ -354,17 +512,7 @@ def _enable_plugins_from(self, plugin: InstalledPlugin) -> bool:
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
+ changed, _recognized = enable_installed_plugin_classes(
+ self.store, [plugin.name]
+ )
+ return changed
diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po
index 90c4230fb..28de12a0a 100644
--- a/datalab/locale/fr/LC_MESSAGES/datalab.po
+++ b/datalab/locale/fr/LC_MESSAGES/datalab.po
@@ -10,6 +10,39 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
+msgid "Additional packages from the local wheelhouse:"
+msgstr "Paquets supplémentaires du dépôt local :"
+
+msgid "Dependency source:"
+msgstr "Source des dépendances :"
+
+msgid "not installed"
+msgstr "non installé"
+
+msgid "Checking the local dependency wheels..."
+msgstr "Vérification des paquets de dépendances locaux..."
+
+msgid "The plugin changed while checking dependencies."
+msgstr "Le plugin a été modifié pendant la vérification des dépendances."
+
+msgid "Installing the plugin and its dependencies..."
+msgstr "Installation du plugin et de ses dépendances..."
+
+msgid "Installing plugin..."
+msgstr "Installation du plugin..."
+
+msgid "Prepare, build or check a local plugin catalog folder."
+msgstr "Préparer, construire ou vérifier le dossier d'un catalogue local de plugins."
+
+msgid "Download dependency wheels for this host and build the local catalog."
+msgstr "Télécharger les paquets de dépendances pour cet environnement et construire le catalogue local."
+
+msgid "Dependency artifacts for the public catalog:"
+msgstr "Fichiers de dépendances pour le catalogue public :"
+
+msgid "Install a plugin shared as a wheel file (.whl) or as a Python module (datalab_*.py). Wheels must be pure Python. Missing dependencies may be supplied as wheels in an adjacent dependencies folder, without Internet access or changing packages already installed."
+msgstr "Installer un plugin distribué sous forme de paquet wheel (.whl) ou de module Python (datalab_*.py). Les plugins au format wheel doivent être en Python pur. Les dépendances manquantes peuvent être fournies sous forme de paquets wheel dans un dossier dependencies adjacent, sans accès à Internet ni modification des paquets déjà installés."
+
msgid "Failed to deserialize analysis parameters from JSON."
msgstr "Échec de la désérialisation des paramètres d'analyse depuis le format JSON."
@@ -5644,4 +5677,3 @@ msgstr "Aucun contour n'a été trouvé pour la plage de niveaux sélectionnée.
msgid "Show contour plot..."
msgstr "Afficher le tracé de contours..."
-
diff --git a/datalab/plugins/base.py b/datalab/plugins/base.py
index 5e9b28d36..b726441fd 100644
--- a/datalab/plugins/base.py
+++ b/datalab/plugins/base.py
@@ -27,6 +27,7 @@
from typing import TYPE_CHECKING
from urllib.parse import urlparse
+from packaging.utils import canonicalize_name
from qtpy import QtWidgets as QW
# pylint: disable=unused-import
@@ -1091,6 +1092,33 @@ def _set_plugin_class_filepaths(module) -> None:
plugin_class.__plugin_filepath__ = filepath
+def enable_installed_plugin_classes(
+ store: InstalledPluginStore, names: Sequence[str]
+) -> tuple[bool, set[str]]:
+ """Enable discovered classes from selected installed distributions."""
+ requested = {canonicalize_name(name) for name in names}
+ recognized: set[str] = set()
+ enabled = Conf.plugins_enabled_list.get(None)
+ changed = False
+ for plugin_class in PluginRegistry.get_plugin_classes():
+ filepath = getattr(plugin_class, "__plugin_filepath__", None)
+ if not filepath:
+ continue
+ installed = store.find_plugin(filepath)
+ if installed is None:
+ continue
+ name = canonicalize_name(installed.name)
+ if name not in requested:
+ continue
+ recognized.add(name)
+ if enabled is not None and plugin_class.get_plugin_id() not in enabled:
+ enabled = [*enabled, plugin_class.get_plugin_id()]
+ changed = True
+ if changed:
+ Conf.plugins_enabled_list.set(enabled)
+ return changed, recognized
+
+
def _add_plugin_discovery_source(plugin_class: type[PluginBase], source: str) -> None:
"""Attach a discovery source to a plugin class without duplicates."""
sources = getattr(plugin_class, "__plugin_discovery_sources__", ())
@@ -1371,8 +1399,11 @@ def discover_plugins() -> list[ModuleType]:
return []
_extend_sys_path_with_plugin_paths()
+ installed_store = None
try:
- get_installed_plugin_store().activate()
+ store = get_installed_plugin_store()
+ store.activate()
+ installed_store = store
except (OSError, ValueError):
_record_plugin_discovery_failure(
_("Plugins installed from a file"),
@@ -1443,6 +1474,22 @@ def discover_plugins() -> list[ModuleType]:
filepath = osp.join(finder.path, name)
PluginRegistry.add_failed_plugin(module_name, filepath, tb_text, source)
_normalize_discovered_plugin_classes()
+ if installed_store is not None:
+ try:
+ pending = installed_store.get_pending_enabled_names()
+ if pending:
+ _changed, recognized = enable_installed_plugin_classes(
+ installed_store, pending
+ )
+ if recognized:
+ installed_store.clear_pending_enabled_names(list(recognized))
+ except (OSError, ValueError):
+ _record_plugin_discovery_failure(
+ _("Plugins installed from a file"),
+ f"installed plugin store {INSTALLED_PLUGINS_PATH!r}",
+ traceback.format_exc(),
+ INSTALLED_PLUGINS_PATH,
+ )
return modules
diff --git a/datalab/plugins/catalog.py b/datalab/plugins/catalog.py
index 2689e3f63..b2064899a 100644
--- a/datalab/plugins/catalog.py
+++ b/datalab/plugins/catalog.py
@@ -32,6 +32,7 @@
from email.parser import BytesParser
from email.policy import default as email_policy
+from packaging.tags import sys_tags
from packaging.utils import canonicalize_name, parse_wheel_filename
from packaging.version import InvalidVersion, Version
@@ -61,6 +62,16 @@ class CatalogError(ValueError):
"""Raised when the catalog or a release cannot be read or verified."""
+@dataclasses.dataclass(frozen=True)
+class CatalogWheel:
+ """A dependency artifact distributed with a plugin release."""
+
+ filename: str
+ url: str
+ sha256: str
+ size: int
+
+
@dataclasses.dataclass(frozen=True)
class CatalogRelease:
"""Release of a catalog plugin.
@@ -85,6 +96,7 @@ class CatalogRelease:
targets: tuple[str, ...] = ()
yanked: str = ""
requires_python: str | None = None
+ dependency_wheels: tuple[CatalogWheel, ...] = ()
@dataclasses.dataclass(frozen=True)
@@ -204,6 +216,18 @@ def _parse_release(item: dict, resolve: Callable[[str], str]) -> CatalogRelease:
if not _SHA256_PATTERN.fullmatch(item["sha256"]):
raise ValueError(f"Invalid SHA-256 digest: {item['sha256']!r}")
url = item.get("url", "")
+ dependencies: list[CatalogWheel] = []
+ for artifact in item.get("dependency_wheels", ()):
+ if not _SHA256_PATTERN.fullmatch(artifact["sha256"]):
+ raise ValueError("Invalid dependency SHA-256 digest")
+ dependencies.append(
+ CatalogWheel(
+ filename=osp.basename(artifact["filename"]),
+ url=resolve(artifact["url"]),
+ sha256=artifact["sha256"],
+ size=int(artifact["size"]),
+ )
+ )
return CatalogRelease(
version=str(item["version"]),
sha256=item["sha256"],
@@ -213,6 +237,7 @@ def _parse_release(item: dict, resolve: Callable[[str], str]) -> CatalogRelease:
targets=tuple(item.get("targets", ())),
yanked=item.get("yanked", ""),
requires_python=item.get("requires_python"),
+ dependency_wheels=tuple(dependencies),
)
@@ -551,7 +576,7 @@ def load_catalogs(url: str, directories: Sequence[str] = ()) -> CatalogsContent:
return CatalogsContent(tuple(plugins), tuple(errors))
-def download_release(release: CatalogRelease, directory: str) -> str:
+def download_release(release: CatalogRelease | CatalogWheel, directory: str) -> str:
"""Download a release wheel into a directory after checking it.
Returns:
@@ -575,6 +600,21 @@ def download_release(release: CatalogRelease, directory: str) -> str:
return path
+def download_dependency_wheels(release: CatalogRelease, directory: str) -> None:
+ """Copy compatible, verified dependency artifacts into a wheelhouse."""
+ tags = frozenset(sys_tags())
+ os.makedirs(directory, exist_ok=True)
+ for artifact in release.dependency_wheels:
+ try:
+ compatible = bool(parse_wheel_filename(artifact.filename)[3] & tags)
+ except ValueError as exc:
+ raise CatalogError(
+ f"Invalid dependency wheel filename: {artifact.filename}"
+ ) from exc
+ if compatible:
+ download_release(artifact, directory)
+
+
__all__ = [
"CATALOG_FILENAME",
"DEFAULT_CATALOG_URL",
@@ -582,7 +622,9 @@ def download_release(release: CatalogRelease, directory: str) -> str:
"CatalogError",
"CatalogPlugin",
"CatalogRelease",
+ "CatalogWheel",
"CatalogsContent",
+ "download_dependency_wheels",
"download_release",
"fetch_catalog",
"list_folder_wheels",
diff --git a/datalab/plugins/catalogtool.py b/datalab/plugins/catalogtool.py
index 6417b3b05..26fad7f23 100644
--- a/datalab/plugins/catalogtool.py
+++ b/datalab/plugins/catalogtool.py
@@ -16,7 +16,8 @@
import os
import os.path as osp
import tempfile
-from collections.abc import Mapping
+from collections.abc import Mapping, Sequence
+from urllib.parse import quote
from packaging.utils import canonicalize_name
@@ -30,11 +31,14 @@
read_folder_catalog,
scan_wheel_folder,
)
+from datalab.plugins.dependencies import DEPENDENCIES_DIRECTORY, download_dependencies
from datalab.plugins.store import get_available_distributions
from datalab.plugins.wheels import (
DESKTOP_ENTRY_POINT_GROUP,
MAX_WHEEL_BYTES,
WheelInspectionError,
+ get_reference_distributions,
+ inspect_dependency_wheel,
inspect_wheel,
)
@@ -72,6 +76,7 @@ class CatalogReport:
errors: list[str] = dataclasses.field(default_factory=list)
warnings: list[str] = dataclasses.field(default_factory=list)
written: bool = False
+ dependency_wheels: list[dict] = dataclasses.field(default_factory=list)
def _get_root(directory: str) -> str:
@@ -81,7 +86,11 @@ def _get_root(directory: str) -> str:
return root
-def inspect_plugin_wheel(path: str, available: Mapping[str, str]) -> dict:
+def inspect_plugin_wheel(
+ path: str,
+ available: Mapping[str, str],
+ additional_distributions: Mapping[str, Sequence[str]] | None = None,
+) -> dict:
"""Check a plugin wheel as DataLab does before installing it.
The wheel must install on one of the Python versions supported by DataLab.
@@ -106,6 +115,7 @@ def inspect_plugin_wheel(path: str, available: Mapping[str, str]) -> dict:
filename=osp.basename(path),
entry_point_group=DESKTOP_ENTRY_POINT_GROUP,
available_distributions=available,
+ additional_distributions=additional_distributions,
python_version=python_version,
marker_environment={
"python_version": python_version,
@@ -117,6 +127,61 @@ def inspect_plugin_wheel(path: str, available: Mapping[str, str]) -> dict:
raise error
+def _dependency_records(directory: str) -> tuple[list[dict], dict[str, list[str]]]:
+ folder = osp.join(directory, DEPENDENCIES_DIRECTORY)
+ if not osp.isdir(folder):
+ return [], {}
+ records: list[dict] = []
+ versions: dict[str, list[str]] = {}
+ for path in list_folder_wheels(folder):
+ manifest = inspect_dependency_wheel(path, filename=osp.basename(path))
+ records.append(
+ {
+ "filename": manifest["filename"],
+ "url": f"{DEPENDENCIES_DIRECTORY}/{quote(manifest['filename'])}",
+ "size": manifest["size_bytes"],
+ "sha256": manifest["sha256"],
+ }
+ )
+ name = canonicalize_name(manifest["distribution"])
+ versions.setdefault(name, []).append(manifest["version"])
+ return records, versions
+
+
+def prepare_local_catalog(directory: str) -> CatalogReport:
+ """Download missing dependency wheels for this host, then build the catalog."""
+ root = _get_root(directory)
+ report = CatalogReport(osp.join(root, CATALOG_FILENAME))
+ available = get_reference_distributions()
+ artifacts: dict[str, dict] = {}
+ for path in list_folder_wheels(root):
+ try:
+ manifest = inspect_wheel(
+ path,
+ filename=osp.basename(path),
+ entry_point_group=DESKTOP_ENTRY_POINT_GROUP,
+ available_distributions=available,
+ allow_missing_dependencies=True,
+ )
+ missing = [
+ item["requirement"]
+ for item in manifest["dependencies"]
+ if item["applies"] and not item["compatible"]
+ ]
+ if missing:
+ for artifact in download_dependencies(
+ missing, osp.join(root, DEPENDENCIES_DIRECTORY), available
+ ):
+ artifacts[artifact["sha256"]] = artifact
+ except (OSError, ValueError) as exc:
+ report.errors.append(f"{path}: {exc}")
+ if report.errors:
+ return report
+ report = build_local_catalog(root)
+ report.dependency_wheels = list(artifacts.values())
+ return report
+
+
def _read_existing_plugins(path: str) -> list[dict]:
"""Return the plugins of an existing ``catalog.json``, if any."""
if not osp.isfile(path):
@@ -195,19 +260,29 @@ def build_local_catalog(directory: str, *, write: bool = True) -> CatalogReport:
payload, ignored = scan_wheel_folder(root)
report.errors.extend(ignored)
available = get_available_distributions({"datalab-platform": __version__})
+ try:
+ dependency_records, dependency_versions = _dependency_records(root)
+ if dependency_records:
+ available = get_reference_distributions()
+ except (OSError, ValueError) as exc:
+ report.errors.append(f"Dependency wheelhouse refused: {exc}")
+ dependency_records = []
+ dependency_versions = {}
plugins: list[dict] = []
for plugin in payload["plugins"]:
releases = []
for release in plugin["releases"]:
path = osp.join(root, release["filename"])
try:
- manifest = inspect_plugin_wheel(path, available)
+ manifest = inspect_plugin_wheel(path, available, dependency_versions)
except (OSError, WheelInspectionError) as exc:
report.errors.append(f"{path} refused: {exc}")
continue
release["requires_dist"] = [
item["requirement"] for item in manifest["dependencies"]
]
+ if dependency_records:
+ release["dependency_wheels"] = dependency_records
releases.append(release)
if releases:
plugin["releases"] = releases
@@ -277,7 +352,33 @@ def check_local_catalog(directory: str) -> CatalogReport:
label = f"{plugin.name} {release.version}"
listed.add(osp.normcase(release.url))
try:
- manifest = inspect_plugin_wheel(release.url, available)
+ release_available = dict(available)
+ additional: dict[str, list[str]] = {}
+ if release.dependency_wheels:
+ release_available = get_reference_distributions()
+ for artifact in release.dependency_wheels:
+ if not osp.isabs(artifact.url):
+ report.warnings.append(
+ f"{label}: dependency is not stored locally: {artifact.url}"
+ )
+ continue
+ dependency = inspect_dependency_wheel(
+ artifact.url, filename=artifact.filename
+ )
+ if (
+ dependency["size_bytes"] != artifact.size
+ or dependency["sha256"] != artifact.sha256
+ ):
+ raise WheelInspectionError(
+ f"{artifact.filename} does not match the size and digest "
+ "of the catalog"
+ )
+ additional.setdefault(
+ canonicalize_name(dependency["distribution"]), []
+ ).append(dependency["version"])
+ manifest = inspect_plugin_wheel(
+ release.url, release_available, additional
+ )
except OSError as exc:
report.errors.append(f"{label}: cannot read {release.url}: {exc}")
continue
@@ -306,4 +407,5 @@ def check_local_catalog(directory: str) -> CatalogReport:
"build_local_catalog",
"check_local_catalog",
"inspect_plugin_wheel",
+ "prepare_local_catalog",
]
diff --git a/datalab/plugins/dependencies.py b/datalab/plugins/dependencies.py
new file mode 100644
index 000000000..502622bbe
--- /dev/null
+++ b/datalab/plugins/dependencies.py
@@ -0,0 +1,463 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Offline installation of additional plugin dependencies.
+
+pip runs only in a child process. Resolution sees the host's installed metadata;
+installation writes an approved, hashed graph into a new directory.
+"""
+
+from __future__ import annotations
+
+import dataclasses
+import hashlib
+import json
+import os
+import os.path as osp
+import runpy
+import shutil
+import subprocess
+import sys
+import tempfile
+import weakref
+from collections.abc import Mapping, Sequence
+from importlib import metadata
+from pathlib import Path
+from urllib.parse import urlparse
+
+from packaging.requirements import InvalidRequirement, Requirement
+from packaging.utils import canonicalize_name, parse_wheel_filename
+from packaging.version import InvalidVersion, Version
+
+from datalab.plugins.wheels import (
+ MAX_WHEEL_BYTES,
+ inspect_dependency_wheel,
+ validate_dependencies,
+)
+
+DEPENDENCIES_DIRECTORY = "dependencies"
+PIP_WORKER_ARGUMENT = "--internal-plugin-pip"
+PIP_TIMEOUT = 180
+MAX_DEPENDENCY_WHEELS = 1000
+MAX_NATIVE_PATH = 260 if sys.platform == "win32" else None
+
+
+class DependencyError(ValueError):
+ """A dependency cannot be resolved or installed without changing the host."""
+
+
+@dataclasses.dataclass(frozen=True)
+class DependencyWheel:
+ """An inspected wheel selected by pip."""
+
+ path: str
+ distribution: str
+ version: str
+ sha256: str
+ size_bytes: int
+ packages: tuple[str, ...]
+ requires_dist: tuple[str, ...]
+
+
+@dataclasses.dataclass
+class DependencyPlan:
+ """A resolved graph and its private, snapshotted wheelhouse."""
+
+ directory: str
+ wheels: tuple[DependencyWheel, ...]
+ protected_versions: dict[str, str]
+ installed_paths: tuple[str, ...]
+ _cleanup: weakref.finalize = dataclasses.field(init=False, repr=False)
+
+ def __post_init__(self) -> None:
+ self._cleanup = weakref.finalize(self, shutil.rmtree, self.directory)
+
+ def close(self) -> None:
+ """Remove the temporary files owned by this plan."""
+ self._cleanup()
+
+
+def installed_versions(paths: Sequence[str]) -> dict[str, str]:
+ """Read distribution metadata from installed dependency directories."""
+ versions: dict[str, str] = {}
+ for path in paths:
+ if not osp.isdir(path):
+ raise DependencyError(f"Installed dependency directory is missing: {path}")
+ for distribution in metadata.distributions(path=[path]):
+ name = distribution.metadata["Name"]
+ if not name:
+ raise DependencyError(f"Dependency metadata has no Name: {path}")
+ key = canonicalize_name(name)
+ if key in versions and versions[key] != distribution.version:
+ raise DependencyError(f"Conflicting installed dependency: {name}")
+ versions[key] = distribution.version
+ return versions
+
+
+def validate_installed(paths: Sequence[str], host: Mapping[str, str]) -> dict[str, str]:
+ """Validate shared dependencies against the current, immutable host."""
+ available = {canonicalize_name(name): version for name, version in host.items()}
+ for name, version in installed_versions(paths).items():
+ if name in available and Version(available[name]) != Version(version):
+ raise DependencyError(
+ f"Installed dependency {name} {version} conflicts with "
+ f"the host version {available[name]}"
+ )
+ available.setdefault(name, version)
+ for path in paths:
+ for distribution in metadata.distributions(path=[path]):
+ validate_dependencies(
+ distribution.requires or [], available, None, "DataLab"
+ )
+ return available
+
+
+def _requirements(values: Sequence[str]) -> list[str]:
+ requirements: list[str] = []
+ for value in values:
+ try:
+ requirement = Requirement(value)
+ except InvalidRequirement as exc:
+ raise DependencyError(f"Invalid dependency: {value!r}") from exc
+ if requirement.url:
+ raise DependencyError(
+ f"Direct dependency URLs are not supported offline: {requirement}"
+ )
+ requirements.append(str(requirement))
+ return requirements
+
+
+def _pip(arguments: Sequence[str], directory: str, paths: Sequence[str] = ()) -> None:
+ request = osp.join(directory, "pip-request.json")
+ log = osp.join(directory, "pip.log")
+ host_paths = [osp.abspath(path) for path in (*sys.path, *paths)]
+ with open(request, "w", encoding="utf-8") as stream:
+ json.dump(
+ {
+ "args": [
+ "--isolated",
+ "--disable-pip-version-check",
+ "--no-input",
+ *arguments,
+ ],
+ "paths": host_paths,
+ "log": log,
+ },
+ stream,
+ )
+ if getattr(sys, "frozen", False):
+ command = [sys.executable, PIP_WORKER_ARGUMENT, request]
+ else:
+ bootstrap = (
+ "import json,runpy,sys\n"
+ "with open(sys.argv[1], encoding='utf-8') as stream:\n"
+ " sys.path[:0] = json.load(stream)['paths']\n"
+ "runpy.run_module('datalab.plugins.dependencies', run_name='__main__')"
+ )
+ command = [sys.executable, "-I", "-c", bootstrap, request]
+ try:
+ result = subprocess.run(
+ command,
+ cwd=directory,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ timeout=PIP_TIMEOUT,
+ check=False,
+ )
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ raise DependencyError(f"Cannot run the dependency installer: {exc}") from exc
+ if result.returncode:
+ try:
+ with open(log, encoding="utf-8") as stream:
+ detail = stream.read()
+ except FileNotFoundError:
+ detail = result.stdout.decode("utf-8", errors="replace")
+ raise DependencyError(
+ f"Dependency installer failed ({result.returncode}):\n{detail}"
+ )
+
+
+def run_pip_request(request_path: str) -> int:
+ """Execute pip's CLI in the dedicated normal or frozen child process."""
+ with open(request_path, encoding="utf-8") as stream:
+ request = json.load(stream)
+ sys.path[:0] = request["paths"]
+ original_streams = sys.stdin, sys.stdout, sys.stderr
+ try:
+ with open(request["log"], "w", encoding="utf-8") as log:
+ sys.stdout = sys.stderr = log
+ with open(os.devnull, encoding="utf-8") as stdin:
+ sys.stdin = stdin
+ sys.argv = ["pip", *request["args"]]
+ try:
+ runpy.run_module("pip", run_name="__main__", alter_sys=True)
+ except SystemExit as exc:
+ if exc.code is None:
+ return 0
+ return exc.code if isinstance(exc.code, int) else 1
+ finally:
+ sys.stdin, sys.stdout, sys.stderr = original_streams
+ return 0
+
+
+def _constraints(directory: str, available: Mapping[str, str]) -> str:
+ path = osp.join(directory, "constraints.txt")
+ lines: list[str] = []
+ for name, version in sorted(available.items()):
+ try:
+ Version(version)
+ except InvalidVersion as exc:
+ raise DependencyError(
+ f"Cannot protect {name}: invalid installed version {version!r}"
+ ) from exc
+ lines.append(f"{canonicalize_name(name)}=={version}\n")
+ with open(path, "w", encoding="utf-8") as stream:
+ stream.writelines(lines)
+ return path
+
+
+def _copy_wheels(
+ source: str, destination: str, available: Mapping[str, str]
+) -> dict[str, DependencyWheel]:
+ if not osp.isdir(source):
+ raise DependencyError(f"Dependency wheelhouse not found: {source}")
+ paths = sorted(Path(source).glob("*.whl"))
+ if len(paths) > MAX_DEPENDENCY_WHEELS:
+ raise DependencyError(f"Too many dependency wheels in {source}")
+ wheels: dict[str, DependencyWheel] = {}
+ protected = {canonicalize_name(name) for name in available}
+ for path in paths:
+ try:
+ name = parse_wheel_filename(path.name)[0]
+ except ValueError as exc:
+ raise DependencyError(f"Invalid dependency wheel: {path}") from exc
+ if name in protected:
+ continue
+ with path.open("rb") as stream:
+ data = stream.read(MAX_WHEEL_BYTES + 1)
+ manifest = inspect_dependency_wheel(data, filename=path.name)
+ if not manifest["compatible"]:
+ continue
+ _requirements(manifest["requires_dist"])
+ target = osp.join(destination, path.name)
+ with open(target, "wb") as stream:
+ stream.write(data)
+ wheels[path.name] = DependencyWheel(
+ target,
+ manifest["distribution"],
+ manifest["version"],
+ manifest["sha256"],
+ manifest["size_bytes"],
+ tuple(manifest["top_level_packages"]),
+ tuple(manifest["requires_dist"]),
+ )
+ return wheels
+
+
+def plan_dependencies(
+ requirements: Sequence[str],
+ wheelhouse: str,
+ available: Mapping[str, str],
+ installed_paths: Sequence[str] = (),
+) -> DependencyPlan:
+ """Resolve missing requirements from local wheels, without installing them.
+
+ The returned plan owns temporary files and must be closed by its caller.
+ """
+ values = _requirements(requirements)
+ directory = tempfile.mkdtemp(prefix="datalab-dependencies-")
+ success = False
+ try:
+ copied = osp.join(directory, "wheels")
+ os.mkdir(copied)
+ wheels = _copy_wheels(wheelhouse, copied, available)
+ report = osp.join(directory, "report.json")
+ constraints = _constraints(directory, available)
+ _pip(
+ [
+ "install",
+ "--dry-run",
+ "--report",
+ report,
+ "--no-index",
+ "--find-links",
+ copied,
+ "--only-binary=:all:",
+ "--no-cache-dir",
+ "--constraint",
+ constraints,
+ *values,
+ ],
+ directory,
+ installed_paths,
+ )
+ with open(report, encoding="utf-8") as stream:
+ payload = json.load(stream)
+ selected: list[DependencyWheel] = []
+ protected = {canonicalize_name(name) for name in available}
+ if payload.get("version") != "1":
+ raise DependencyError("Unsupported pip installation report")
+ for item in payload["install"]:
+ name = canonicalize_name(item["metadata"]["name"])
+ if name in protected:
+ raise DependencyError(f"Refusing to replace installed package: {name}")
+ digest = item["download_info"]["archive_info"]["hashes"]["sha256"]
+ wheel = next(
+ (wheel for wheel in wheels.values() if wheel.sha256 == digest), None
+ )
+ if (
+ wheel is None
+ or canonicalize_name(wheel.distribution) != name
+ or Version(wheel.version) != Version(item["metadata"]["version"])
+ ):
+ raise DependencyError(f"pip selected an unapproved dependency: {name}")
+ selected.append(wheel)
+ plan = DependencyPlan(
+ directory, tuple(selected), dict(available), tuple(installed_paths)
+ )
+ success = True
+ return plan
+ except (KeyError, TypeError, json.JSONDecodeError) as exc:
+ raise DependencyError(f"Invalid pip installation report: {exc}") from exc
+ finally:
+ if not success:
+ shutil.rmtree(directory)
+
+
+def install_dependencies(plan: DependencyPlan, target: str) -> None:
+ """Install exactly the approved wheels into a new, inactive directory."""
+ requirements = osp.join(plan.directory, "approved.txt")
+ lines: list[str] = []
+ for wheel in plan.wheels:
+ with open(wheel.path, "rb") as stream:
+ digest = hashlib.sha256(stream.read(MAX_WHEEL_BYTES + 1)).hexdigest()
+ if digest != wheel.sha256:
+ raise DependencyError(f"Dependency changed after consent: {wheel.path}")
+ lines.append(
+ f"{wheel.distribution} @ {Path(wheel.path).as_uri()} "
+ f"--hash=sha256:{wheel.sha256}\n"
+ )
+ with open(requirements, "w", encoding="utf-8") as stream:
+ stream.writelines(lines)
+ _pip(
+ [
+ "install",
+ "--no-index",
+ "--only-binary=:all:",
+ "--no-cache-dir",
+ "--no-deps",
+ "--require-hashes",
+ "--target",
+ target,
+ "--requirement",
+ requirements,
+ ],
+ plan.directory,
+ plan.installed_paths,
+ )
+ if MAX_NATIVE_PATH is not None:
+ for path in Path(target).rglob("*"):
+ if (
+ path.suffix.lower() in (".dll", ".pyd")
+ and len(str(path)) >= MAX_NATIVE_PATH
+ ):
+ raise DependencyError(
+ "Native library path exceeds the Windows DLL path limit; "
+ f"use a shorter configuration directory: {path}"
+ )
+ versions = installed_versions([target])
+ expected = {
+ canonicalize_name(wheel.distribution): wheel.version for wheel in plan.wheels
+ }
+ if versions != expected:
+ raise DependencyError("Installed dependencies differ from the approved plan")
+ validate_installed([*plan.installed_paths, target], plan.protected_versions)
+
+
+def download_dependencies(
+ requirements: Sequence[str], destination: str, available: Mapping[str, str]
+) -> list[dict]:
+ """Prepare a wheelhouse online without installing or replacing packages.
+
+ This is an explicit authoring operation, never an installer fallback.
+ """
+ values = _requirements(requirements)
+ with tempfile.TemporaryDirectory(prefix="datalab-wheelhouse-") as directory:
+ report = osp.join(directory, "report.json")
+ _pip(
+ [
+ "install",
+ "--dry-run",
+ "--ignore-installed",
+ "--report",
+ report,
+ "--only-binary=:all:",
+ "--no-cache-dir",
+ "--constraint",
+ _constraints(directory, available),
+ *values,
+ ],
+ directory,
+ )
+ with open(report, encoding="utf-8") as stream:
+ payload = json.load(stream)
+ if payload.get("version") != "1":
+ raise DependencyError("Unsupported pip installation report")
+ records: list[dict] = []
+ download = osp.join(directory, "download")
+ os.mkdir(download)
+ sources: dict[str, str] = {}
+ protected = {canonicalize_name(name) for name in available}
+ arguments = [
+ "download",
+ "--only-binary=:all:",
+ "--no-deps",
+ "--require-hashes",
+ "--dest",
+ download,
+ ]
+ for item in payload["install"]:
+ name = canonicalize_name(item["metadata"]["name"])
+ if name in protected:
+ if Version(item["metadata"]["version"]) != Version(available[name]):
+ raise DependencyError(
+ f"Refusing to replace installed package: {name}"
+ )
+ continue
+ source = item["download_info"]["url"]
+ digest = item["download_info"]["archive_info"]["hashes"]["sha256"]
+ if urlparse(source).scheme != "https":
+ raise DependencyError(f"Refusing dependency source: {source}")
+ arguments.append(f"{source}#sha256={digest}")
+ sources[digest] = source
+ if not sources:
+ return []
+ _pip(arguments, directory)
+ os.makedirs(destination, exist_ok=True)
+ for path in sorted(Path(download).glob("*.whl")):
+ manifest = inspect_dependency_wheel(str(path), filename=path.name)
+ _requirements(manifest["requires_dist"])
+ digest = manifest["sha256"]
+ if digest not in sources:
+ raise DependencyError(f"Unapproved downloaded dependency: {path.name}")
+ target = Path(destination) / path.name
+ if target.exists():
+ with target.open("rb") as stream:
+ existing = hashlib.sha256(
+ stream.read(MAX_WHEEL_BYTES + 1)
+ ).hexdigest()
+ if existing != digest:
+ raise DependencyError(f"A different wheel already exists: {target}")
+ else:
+ shutil.copyfile(path, target)
+ records.append(
+ {
+ "filename": path.name,
+ "url": sources[digest],
+ "sha256": digest,
+ }
+ )
+ return records
+
+
+if __name__ == "__main__":
+ sys.exit(run_pip_request(sys.argv[1]))
diff --git a/datalab/plugins/generator.py b/datalab/plugins/generator.py
index a695e60ae..6a96db37c 100644
--- a/datalab/plugins/generator.py
+++ b/datalab/plugins/generator.py
@@ -23,8 +23,11 @@
from datalab import __version__
from datalab.config import _, get_config_path
-from datalab.plugins.catalog import CatalogError
-from datalab.plugins.catalogtool import build_local_catalog, check_local_catalog
+from datalab.plugins.catalogtool import (
+ build_local_catalog,
+ check_local_catalog,
+ prepare_local_catalog,
+)
_PACKAGE_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")
_PLUGIN_ID_PATTERN = re.compile(r"^[a-z0-9]+(?:[.-][a-z0-9]+)+$")
@@ -996,7 +999,7 @@ def build_parser() -> argparse.ArgumentParser:
help=_("Project license (with --project)"),
)
catalog_parser = subparsers.add_parser(
- "catalog", help=_("Build or check a local plugin catalog folder.")
+ "catalog", help=_("Prepare, build or check a local plugin catalog folder.")
)
catalog_subparsers = catalog_parser.add_subparsers(
dest="catalog_command", required=True
@@ -1004,6 +1007,10 @@ def build_parser() -> argparse.ArgumentParser:
for name, text in (
("build", _("Write the catalog.json file listing the plugin wheels.")),
("check", _("Check the catalog.json file against the plugin wheels.")),
+ (
+ "prepare",
+ _("Download dependency wheels for this host and build the local catalog."),
+ ),
):
subparser = catalog_subparsers.add_parser(name, help=text)
subparser.add_argument(
@@ -1021,9 +1028,11 @@ def run_catalog_command(args: argparse.Namespace) -> int:
try:
if args.catalog_command == "build":
report = build_local_catalog(str(args.directory))
+ elif args.catalog_command == "prepare":
+ report = prepare_local_catalog(str(args.directory))
else:
report = check_local_catalog(str(args.directory))
- except CatalogError as exc:
+ except (OSError, ValueError) as exc:
print(_("Error: %s") % exc, file=sys.stderr)
return 1
for message in report.warnings:
@@ -1039,6 +1048,13 @@ def run_catalog_command(args: argparse.Namespace) -> int:
print(_("Wrote %s, listing %d plugins.") % (report.path, count))
else:
print(_("The local plugin catalog lists %d plugins.") % count)
+ if report.dependency_wheels:
+ print(_("Dependency artifacts for the public catalog:"))
+ print("dependency_wheels:")
+ for artifact in report.dependency_wheels:
+ print(f" - filename: {artifact['filename']}")
+ print(f" url: {artifact['url']}")
+ print(f" sha256: {artifact['sha256']}")
return 0
diff --git a/datalab/plugins/store.py b/datalab/plugins/store.py
index 4db775277..f5645f33c 100644
--- a/datalab/plugins/store.py
+++ b/datalab/plugins/store.py
@@ -28,14 +28,26 @@
import os
import os.path as osp
import re
+import shutil
import sys
+import tempfile
import zipimport
from collections.abc import Mapping
from importlib import metadata as importlib_metadata
from importlib.machinery import PathFinder
+from packaging.requirements import Requirement
+from packaging.tags import sys_tags
from packaging.utils import canonicalize_name
+from datalab.plugins.dependencies import (
+ DEPENDENCIES_DIRECTORY,
+ DependencyPlan,
+ install_dependencies,
+ installed_versions,
+ plan_dependencies,
+ validate_installed,
+)
from datalab.plugins.wheels import (
DESKTOP_ENTRY_POINT_GROUP,
MAX_WHEEL_BYTES,
@@ -184,14 +196,25 @@ def __init__(
self.modules_dir = osp.join(self.root, "modules")
self.index_path = osp.join(self.root, "index.json")
self._host_distributions = dict(host_distributions or {})
+ self._blocked_dependency_paths: set[str] = set()
+ self._deferred_plugin_digests: set[str] = set()
def get_plugins(self) -> list[InstalledPlugin]:
"""Return installed plugins, in installation order."""
+ data = self._read_index()
+ try:
+ return [InstalledPlugin(**item) for item in data["plugins"]]
+ except (TypeError, ValueError) as exc:
+ raise PluginInstallError(
+ f"Invalid installed plugin index: {self.index_path}"
+ ) from exc
+
+ def _read_index(self) -> dict:
try:
with open(self.index_path, encoding="utf-8") as file:
data = json.load(file)
except FileNotFoundError:
- return []
+ return {"schema_version": INDEX_SCHEMA_VERSION, "plugins": []}
except (OSError, ValueError) as exc:
raise PluginInstallError(
f"Unreadable installed plugin index: {self.index_path}"
@@ -204,12 +227,54 @@ def get_plugins(self) -> list[InstalledPlugin]:
raise PluginInstallError(
f"Unsupported installed plugin index: {self.index_path}"
)
- try:
- return [InstalledPlugin(**item) for item in data["plugins"]]
- except (TypeError, ValueError) as exc:
- raise PluginInstallError(
- f"Invalid installed plugin index: {self.index_path}"
- ) from exc
+ return data
+
+ def get_dependency_paths(self) -> list[str]:
+ """Return validated paths for this interpreter's shared dependencies."""
+ roots = self._read_index().get("dependency_roots", [])
+ if not isinstance(roots, list) or not all(
+ isinstance(root, str) for root in roots
+ ):
+ raise PluginInstallError("Invalid installed dependency roots")
+ directory = _normalized(osp.join(self.root, DEPENDENCIES_DIRECTORY))
+ base = _normalized(self._dependency_parent())
+ paths: list[str] = []
+ for relative in roots:
+ path = _normalized(osp.join(self.root, relative))
+ if not path.startswith(directory + os.sep):
+ raise PluginInstallError(
+ "Dependency directory is outside the installed plugin store: "
+ f"{path}"
+ )
+ if not path.startswith(base + os.sep):
+ logging.getLogger(__name__).warning(
+ "Dependencies for another Python runtime are not activated: %s",
+ path,
+ )
+ continue
+ paths.append(path)
+ return paths
+
+ def _dependency_parent(self) -> str:
+ return osp.join(self.root, DEPENDENCIES_DIRECTORY, str(next(sys_tags())))
+
+ def get_pending_enabled_names(self) -> list[str]:
+ """Return newly installed distributions to enable after restarting."""
+ names = self._read_index().get("pending_enabled", [])
+ if not isinstance(names, list) or not all(
+ isinstance(name, str) for name in names
+ ):
+ raise PluginInstallError("Invalid pending plugin enablement")
+ return names
+
+ def clear_pending_enabled_names(self, names: list[str]) -> None:
+ """Acknowledge distributions discovered after their installation."""
+ self._write_index(
+ self.get_plugins(),
+ pending_enabled=[
+ name for name in self.get_pending_enabled_names() if name not in names
+ ],
+ )
def get_path(self, plugin: InstalledPlugin) -> str:
"""Return the file path of an installed plugin."""
@@ -232,7 +297,10 @@ def find_plugin(self, filepath: str) -> InstalledPlugin | None:
def get_available_distributions(self) -> dict[str, str]:
"""Return distribution versions a plugin wheel may depend on."""
- return get_available_distributions(self._host_distributions)
+ return validate_installed(
+ self.get_dependency_paths(),
+ get_available_distributions(self._host_distributions),
+ )
def _get_environment_distributions(self) -> set[str]:
"""Return canonical names of distributions installed outside the store."""
@@ -250,7 +318,9 @@ def _get_environment_distributions(self) -> set[str]:
names.add(canonicalize_name(name))
return names
- def inspect_wheel_file(self, path: str) -> dict:
+ def inspect_wheel_file(
+ self, path: str, *, allow_missing_dependencies: bool = False
+ ) -> dict:
"""Return the manifest of a plugin wheel file without importing it.
Raises:
@@ -261,10 +331,40 @@ def inspect_wheel_file(self, path: str) -> dict:
filename=osp.basename(path),
entry_point_group=DESKTOP_ENTRY_POINT_GROUP,
available_distributions=self.get_available_distributions(),
+ allow_missing_dependencies=allow_missing_dependencies,
+ )
+
+ def prepare_wheel_file(
+ self, path: str, wheelhouse: str | None = None
+ ) -> tuple[dict, DependencyPlan | None]:
+ """Inspect a plugin and plan missing dependencies from local wheels."""
+ manifest = self.inspect_wheel_file(path, allow_missing_dependencies=True)
+ missing = [
+ dependency["requirement"]
+ for dependency in manifest["dependencies"]
+ if dependency["applies"] and not dependency["compatible"]
+ ]
+ if not missing:
+ return manifest, None
+ source = wheelhouse or osp.join(osp.dirname(path), DEPENDENCIES_DIRECTORY)
+ plan = plan_dependencies(
+ missing,
+ source,
+ self.get_available_distributions(),
+ self.get_dependency_paths(),
)
+ manifest["additional_dependencies"] = [
+ dataclasses.asdict(wheel) for wheel in plan.wheels
+ ]
+ manifest["dependency_source"] = source
+ return manifest, plan
def install_wheel(
- self, path: str, *, expected_sha256: str | None = None
+ self,
+ path: str,
+ *,
+ expected_sha256: str | None = None,
+ dependency_plan: DependencyPlan | None = None,
) -> InstallResult:
"""Install a plugin wheel file.
@@ -279,11 +379,22 @@ def install_wheel(
"""
with open(path, "rb") as file:
data = file.read(MAX_WHEEL_BYTES + 1)
+ available = self.get_available_distributions()
+ if dependency_plan is not None:
+ if available != dependency_plan.protected_versions:
+ raise PluginInstallError("Installed packages changed since planning")
+ for wheel in dependency_plan.wheels:
+ key = canonicalize_name(wheel.distribution)
+ if key in available:
+ raise PluginInstallError(
+ f"Refusing to replace {wheel.distribution}"
+ )
+ available[key] = wheel.version
manifest = inspect_wheel(
data,
filename=osp.basename(path),
entry_point_group=DESKTOP_ENTRY_POINT_GROUP,
- available_distributions=self.get_available_distributions(),
+ available_distributions=available,
)
self._check_expected_digest(manifest["sha256"], expected_sha256)
name = manifest["distribution"]
@@ -302,14 +413,80 @@ def install_wheel(
packages=manifest["top_level_packages"],
installed_at=_now(),
)
- replaced = self._add(
- plugin,
- data,
- lambda other: (
+ installed_directory: str | None = None
+ success = False
+ try:
+ roots = None
+ pending = None
+ if dependency_plan is not None and dependency_plan.wheels:
+ packages = set(plugin.packages)
+ for other in self.get_plugins():
+ packages.update(other.packages)
+ for wheel in dependency_plan.wheels:
+ for package in wheel.packages:
+ if (
+ package in packages
+ or PathFinder.find_spec(package) is not None
+ ):
+ raise PluginInstallError(
+ "Dependency package conflicts with an existing "
+ f"module: {package}"
+ )
+ packages.add(package)
+ parent = self._dependency_parent()
+ os.makedirs(parent, exist_ok=True)
+ # Only the index publishes this generation; keep DLL paths short.
+ installed_directory = tempfile.mkdtemp(prefix="", dir=parent)
+ install_dependencies(dependency_plan, installed_directory)
+ if (
+ self.get_available_distributions()
+ != dependency_plan.protected_versions
+ ):
+ raise PluginInstallError(
+ "Installed packages changed during dependency installation"
+ )
+ roots = [
+ *self._read_index().get("dependency_roots", []),
+ osp.relpath(installed_directory, self.root),
+ ]
+ blocked = set(self._blocked_dependency_paths)
+ if installed_directory is not None:
+ blocked.add(_normalized(installed_directory))
+ pending_dependencies = installed_versions(list(blocked))
+ restart = (
+ plugin.is_imported()
+ or installed_directory is not None
+ or any(
+ dependency["applies"]
+ and canonicalize_name(Requirement(dependency["requirement"]).name)
+ in pending_dependencies
+ for dependency in manifest["dependencies"]
+ )
+ )
+ if restart and not any(
other.kind == WHEEL_KIND and canonicalize_name(other.name) == key
- ),
- )
- return InstallResult(plugin, replaced, plugin.is_imported())
+ for other in self.get_plugins()
+ ):
+ pending = [*self.get_pending_enabled_names(), key]
+ replaced = self._add(
+ plugin,
+ data,
+ lambda other: (
+ other.kind == WHEEL_KIND and canonicalize_name(other.name) == key
+ ),
+ dependency_roots=roots,
+ pending_enabled=pending,
+ repair=installed_directory is not None,
+ )
+ if installed_directory is not None:
+ self._blocked_dependency_paths.add(_normalized(installed_directory))
+ if restart:
+ self._deferred_plugin_digests.add(plugin.sha256)
+ success = True
+ return InstallResult(plugin, replaced, restart)
+ finally:
+ if not success and installed_directory is not None:
+ shutil.rmtree(installed_directory)
def inspect_module_file(self, path: str) -> dict:
"""Return the manifest of a ``datalab_*.py`` plugin module file.
@@ -372,7 +549,14 @@ def uninstall(self, plugin: InstalledPlugin) -> None:
plugins = self.get_plugins()
if plugin not in plugins:
raise PluginInstallError(f"{plugin.name} is not installed")
- self._write_index([other for other in plugins if other != plugin])
+ self._write_index(
+ [other for other in plugins if other != plugin],
+ pending_enabled=[
+ name
+ for name in self.get_pending_enabled_names()
+ if name != canonicalize_name(plugin.name)
+ ],
+ )
path = self.get_path(plugin)
if plugin.kind == WHEEL_KIND:
_remove_from_sys_path(path)
@@ -395,8 +579,18 @@ def activate(self) -> list[str]:
plugins = self.get_plugins()
self._collect_garbage(plugins)
sys_path = {_normalized(entry) for entry in sys.path if entry}
- added: list[str] = []
+ paths = self.get_dependency_paths()
+ validate_installed(paths, get_available_distributions(self._host_distributions))
+ added: list[str] = [
+ path
+ for path in paths
+ if _normalized(path) not in sys_path
+ and path not in self._blocked_dependency_paths
+ ]
+ sys_path.update(_normalized(path) for path in added)
for plugin in plugins:
+ if plugin.sha256 in self._deferred_plugin_digests:
+ continue
path = self.get_path(plugin)
if not osp.isfile(path):
logging.getLogger(__name__).warning(
@@ -445,21 +639,43 @@ def _read_module(self, path: str) -> tuple[str, bytes]:
return name, data
def _add(
- self, plugin: InstalledPlugin, data: bytes, same_plugin
+ self,
+ plugin: InstalledPlugin,
+ data: bytes,
+ same_plugin,
+ *,
+ dependency_roots: list[str] | None = None,
+ pending_enabled: list[str] | None = None,
+ repair: bool = False,
) -> InstalledPlugin | None:
plugins = self.get_plugins()
replaced = next((other for other in plugins if same_plugin(other)), None)
- if replaced is not None and replaced.sha256 == plugin.sha256:
+ if replaced is not None and replaced.sha256 == plugin.sha256 and not repair:
raise PluginInstallError(f"{plugin.name} is already installed")
self._write_file(self.get_path(plugin), data)
- self._write_index([other for other in plugins if other != replaced] + [plugin])
+ self._write_index(
+ [other for other in plugins if other != replaced] + [plugin],
+ dependency_roots=dependency_roots,
+ pending_enabled=pending_enabled,
+ )
return replaced
- def _write_index(self, plugins: list[InstalledPlugin]) -> None:
+ def _write_index(
+ self,
+ plugins: list[InstalledPlugin],
+ *,
+ dependency_roots: list[str] | None = None,
+ pending_enabled: list[str] | None = None,
+ ) -> None:
payload = {
+ **self._read_index(),
"schema_version": INDEX_SCHEMA_VERSION,
"plugins": [dataclasses.asdict(plugin) for plugin in plugins],
}
+ if dependency_roots is not None:
+ payload["dependency_roots"] = dependency_roots
+ if pending_enabled is not None:
+ payload["pending_enabled"] = pending_enabled
self._write_file(self.index_path, json.dumps(payload, indent=2).encode())
@staticmethod
diff --git a/datalab/plugins/wheels.py b/datalab/plugins/wheels.py
index 621206c60..0b8f452aa 100644
--- a/datalab/plugins/wheels.py
+++ b/datalab/plugins/wheels.py
@@ -19,15 +19,16 @@
import sys
import sysconfig
import zipfile
-from collections.abc import Mapping
+from collections.abc import Mapping, Sequence
from email.parser import BytesParser
from email.policy import default as email_policy
+from importlib import metadata as importlib_metadata
from typing import Any
from packaging.markers import default_environment
from packaging.requirements import InvalidRequirement, Requirement
from packaging.specifiers import InvalidSpecifier, SpecifierSet
-from packaging.tags import Tag, compatible_tags, interpreter_name
+from packaging.tags import Tag, compatible_tags, interpreter_name, parse_tag, sys_tags
from packaging.utils import canonicalize_name, parse_wheel_filename
from packaging.version import InvalidVersion, Version
@@ -139,7 +140,9 @@ def _read_bytes(path_or_bytes: str | bytes | bytearray | memoryview) -> bytes:
return data
-def _safe_archive_entries(archive: zipfile.ZipFile) -> tuple[zipfile.ZipInfo, ...]:
+def _safe_archive_entries(
+ archive: zipfile.ZipFile, *, allow_native: bool = False
+) -> tuple[zipfile.ZipInfo, ...]:
entries = tuple(archive.infolist())
if len(entries) > MAX_ARCHIVE_ENTRIES:
raise WheelInspectionError("Plugin wheel contains too many archive entries")
@@ -161,7 +164,11 @@ def _safe_archive_entries(archive: zipfile.ZipFile) -> tuple[zipfile.ZipInfo, ..
raise WheelInspectionError(
"Plugin wheel exceeds the uncompressed size limit"
)
- if not entry.is_dir() and normalized.lower().endswith(_NATIVE_SUFFIXES):
+ if (
+ not allow_native
+ and not entry.is_dir()
+ and normalized.lower().endswith(_NATIVE_SUFFIXES)
+ ):
raise WheelInspectionError(
f"Native payloads are not supported in plugin wheels: {normalized!r}"
)
@@ -225,6 +232,8 @@ def _top_level_packages(
archive: zipfile.ZipFile,
entries: tuple[zipfile.ZipInfo, ...],
dist_info: str,
+ *,
+ allow_native: bool = False,
) -> list[str]:
top_level_name = f"{dist_info}/top_level.txt"
names = {entry.filename.replace("\\", "/") for entry in entries}
@@ -247,9 +256,16 @@ def _top_level_packages(
# Flat layout: a root-level module is itself a package.
if top.endswith(".py"):
packages.add(top[: -len(".py")])
+ elif allow_native and top.lower().endswith((".pyd", ".so")):
+ packages.add(top.split(".", maxsplit=1)[0])
continue
if top.endswith(".data"):
- # Wheel payload directories are not importable packages.
+ if allow_native:
+ parts = name.split("/")
+ if len(parts) > 2 and parts[1] in ("purelib", "platlib"):
+ packages.add(parts[2].split(".", maxsplit=1)[0])
+ continue
+ if allow_native and top.endswith(".libs"):
continue
packages.add(top)
for package in packages:
@@ -262,12 +278,21 @@ def _top_level_packages(
return sorted(packages)
-def _validate_dependencies(
+def validate_dependencies(
requires_dist: list[str],
available_distributions: Mapping[str, str],
marker_environment: Mapping[str, str] | None,
host_name: str,
+ *,
+ allow_missing: bool = False,
+ additional_distributions: Mapping[str, Sequence[str]] | None = None,
) -> list[dict[str, Any]]:
+ """Check requirements without changing installed distribution versions."""
+ provided_names = {canonicalize_name(name) for name in available_distributions}
+ candidates = {
+ canonicalize_name(name): tuple(Version(version) for version in versions)
+ for name, versions in (additional_distributions or {}).items()
+ }
available: dict[str, Version] = {}
for name, version in available_distributions.items():
try:
@@ -292,6 +317,10 @@ def _validate_dependencies(
not applies
or (installed is not None and installed in requirement.specifier)
)
+ if applies and installed is None and key not in provided_names:
+ compatible = any(
+ version in requirement.specifier for version in candidates.get(key, ())
+ )
dependencies.append(
{
"requirement": str(requirement),
@@ -300,7 +329,7 @@ def _validate_dependencies(
"compatible": compatible,
}
)
- if not compatible:
+ if not compatible and not (allow_missing and installed is None):
if installed is None:
detail = f"is not provided by {host_name}"
else:
@@ -309,33 +338,14 @@ def _validate_dependencies(
return dependencies
-def inspect_wheel(
+def _inspect_archive(
path_or_bytes: str | bytes | bytearray | memoryview,
*,
filename: str,
- entry_point_group: str,
- available_distributions: Mapping[str, str],
- host_name: str = "DataLab",
- python_version: str | None = None,
- marker_environment: Mapping[str, str] | None = None,
+ entry_point_group: str | None,
) -> dict[str, Any]:
- """Return a JSON-friendly plugin wheel manifest without importing code.
-
- Args:
- path_or_bytes: Wheel file path or content
- filename: Wheel file name, which carries the distribution name and tags
- entry_point_group: Entry-point group the host loads plugins from
- available_distributions: Distribution versions provided by the host
- host_name: Host name used in dependency error messages
- python_version: Host Python version (default: running interpreter)
- marker_environment: Overrides of the environment markers
-
- Returns:
- Wheel manifest: identity, digest, entry points, packages and dependencies
-
- Raises:
- WheelInspectionError: The archive cannot be accepted as a plugin wheel
- """
+ """Inspect archive structure and metadata, independently of the host."""
+ plugin = entry_point_group is not None
data = _read_bytes(path_or_bytes)
if not filename.lower().endswith(".whl"):
raise WheelInspectionError("Plugin archive filename must end with .whl")
@@ -343,35 +353,44 @@ def inspect_wheel(
wheel_name, wheel_version, _build, wheel_tags = parse_wheel_filename(filename)
except (InvalidVersion, ValueError) as exc:
raise WheelInspectionError(f"Invalid wheel filename: {filename!r}") from exc
- if any(tag.abi != "none" or tag.platform != "any" for tag in wheel_tags):
+ if plugin and any(tag.abi != "none" or tag.platform != "any" for tag in wheel_tags):
raise WheelInspectionError("Only pure-Python *-none-any wheels are supported")
- current_python = _host_python(python_version)
- if not wheel_tags & _pure_python_tags(current_python):
- interpreters = ".".join(sorted({tag.interpreter for tag in wheel_tags}))
- raise WheelInspectionError(
- f"Plugin wheel is built for {interpreters}, "
- f"host has Python {current_python}"
- )
try:
archive = zipfile.ZipFile(io.BytesIO(data))
except zipfile.BadZipFile as exc:
raise WheelInspectionError("Plugin wheel is not a valid ZIP archive") from exc
with archive:
- entries = _safe_archive_entries(archive)
+ entries = _safe_archive_entries(archive, allow_native=not plugin)
dist_info = _single_dist_info(entries)
wheel_metadata = BytesParser(policy=email_policy).parsebytes(
_read_required_file(archive, entries, f"{dist_info}/WHEEL")
)
- if wheel_metadata.get("Root-Is-Purelib", "").lower() != "true":
+ if plugin and wheel_metadata.get("Root-Is-Purelib", "").lower() != "true":
raise WheelInspectionError(
"Plugin wheel must declare Root-Is-Purelib: true"
)
declared_tags = wheel_metadata.get_all("Tag", [])
- if not declared_tags or any(
- not tag.endswith("-none-any") for tag in declared_tags
+ if not declared_tags or (
+ plugin and any(not tag.endswith("-none-any") for tag in declared_tags)
):
raise WheelInspectionError("Plugin wheel declares a non-pure Python tag")
+ if not plugin:
+ if wheel_metadata.get("Root-Is-Purelib", "").lower() not in (
+ "true",
+ "false",
+ ):
+ raise WheelInspectionError("Wheel must declare Root-Is-Purelib")
+ try:
+ metadata_tags = {
+ tag
+ for declaration in declared_tags
+ for tag in parse_tag(declaration)
+ }
+ except ValueError as exc:
+ raise WheelInspectionError("Wheel declares invalid tags") from exc
+ if not wheel_tags.intersection(metadata_tags):
+ raise WheelInspectionError("Wheel filename and WHEEL tags differ")
package_metadata = BytesParser(policy=email_policy).parsebytes(
_read_required_file(archive, entries, f"{dist_info}/METADATA")
@@ -394,27 +413,21 @@ def inspect_wheel(
required_python = package_metadata.get("Requires-Python")
if required_python:
try:
- specifier = SpecifierSet(required_python)
+ SpecifierSet(required_python)
except InvalidSpecifier as exc:
raise WheelInspectionError(
"Wheel has invalid Requires-Python metadata"
) from exc
- if current_python not in specifier:
- raise WheelInspectionError(
- f"Plugin requires Python {required_python}, "
- f"host has {current_python}"
- )
-
- entry_points = _parse_entry_points(
- _read_required_file(archive, entries, f"{dist_info}/entry_points.txt"),
- entry_point_group,
+ entry_points = (
+ _parse_entry_points(
+ _read_required_file(archive, entries, f"{dist_info}/entry_points.txt"),
+ entry_point_group,
+ )
+ if entry_point_group is not None
+ else []
)
- packages = _top_level_packages(archive, entries, dist_info)
- dependencies = _validate_dependencies(
- package_metadata.get_all("Requires-Dist", []),
- available_distributions,
- marker_environment,
- host_name,
+ packages = _top_level_packages(
+ archive, entries, dist_info, allow_native=not plugin
)
return {
@@ -428,10 +441,122 @@ def inspect_wheel(
"tags": sorted(str(tag) for tag in wheel_tags),
"entry_points": entry_points,
"top_level_packages": packages,
- "dependencies": dependencies,
+ "requires_dist": package_metadata.get_all("Requires-Dist", []),
}
+def inspect_dependency_wheel(
+ path_or_bytes: str | bytes | bytearray | memoryview, *, filename: str
+) -> dict[str, Any]:
+ """Inspect an ordinary dependency wheel, including native payloads.
+
+ No code is imported. ``compatible`` describes the running interpreter;
+ catalogs may also inspect wheels destined for other hosts.
+ """
+ manifest = _inspect_archive(
+ path_or_bytes, filename=filename, entry_point_group=None
+ )
+ tags = parse_wheel_filename(filename)[3]
+ required_python = manifest["requires_python"]
+ manifest["compatible"] = bool(tags.intersection(sys_tags())) and (
+ not required_python or _host_python(None) in SpecifierSet(required_python)
+ )
+ return manifest
+
+
+def inspect_wheel(
+ path_or_bytes: str | bytes | bytearray | memoryview,
+ *,
+ filename: str,
+ entry_point_group: str,
+ available_distributions: Mapping[str, str],
+ host_name: str = "DataLab",
+ python_version: str | None = None,
+ marker_environment: Mapping[str, str] | None = None,
+ allow_missing_dependencies: bool = False,
+ additional_distributions: Mapping[str, Sequence[str]] | None = None,
+) -> dict[str, Any]:
+ """Inspect a pure-Python plugin wheel without importing its code.
+
+ Args:
+ path_or_bytes: Wheel file path or content
+ filename: Wheel file name
+ entry_point_group: Plugin entry-point group
+ available_distributions: Distribution versions provided by the host
+ host_name: Host name used in dependency error messages
+ python_version: Host Python version (default: running interpreter)
+ marker_environment: Overrides of the environment markers
+ allow_missing_dependencies: Return missing requirements for planning;
+ incompatible installed versions are still refused
+ additional_distributions: Candidate versions in a catalog wheelhouse,
+ used for metadata checks only; pip resolves the actual installation
+
+ Returns:
+ JSON-friendly plugin manifest
+ """
+ manifest = _inspect_archive(
+ path_or_bytes, filename=filename, entry_point_group=entry_point_group
+ )
+ current_python = _host_python(python_version)
+ required_python = manifest["requires_python"]
+ if required_python and current_python not in SpecifierSet(required_python):
+ raise WheelInspectionError(
+ f"Plugin requires Python {required_python}, host has {current_python}"
+ )
+ if not is_compatible_wheel(filename, required_python, python_version):
+ tags = parse_wheel_filename(filename)[3]
+ interpreters = ".".join(sorted({tag.interpreter for tag in tags}))
+ raise WheelInspectionError(
+ f"Plugin wheel is built for {interpreters}, "
+ f"host has Python {current_python}"
+ )
+ manifest["dependencies"] = validate_dependencies(
+ manifest["requires_dist"],
+ available_distributions,
+ marker_environment,
+ host_name,
+ allow_missing=allow_missing_dependencies,
+ additional_distributions=additional_distributions,
+ )
+ return manifest
+
+
+def get_reference_distributions(root: str = "datalab-platform") -> dict[str, str]:
+ """Snapshot the installed host's declared runtime dependency closure.
+
+ Authoring tools must not mistake an already installed third-party plugin
+ or its libraries for packages distributed with DataLab.
+ """
+ distributions: dict[str, importlib_metadata.Distribution] = {}
+ for distribution in importlib_metadata.distributions():
+ name = distribution.metadata["Name"]
+ if name:
+ distributions.setdefault(canonicalize_name(name), distribution)
+ pending = [(canonicalize_name(root), frozenset())]
+ visited: dict[str, set[str]] = {}
+ versions: dict[str, str] = {}
+ while pending:
+ name, extras = pending.pop()
+ if name in visited and extras.issubset(visited[name]):
+ continue
+ if name not in distributions:
+ raise WheelInspectionError(f"Host distribution is missing: {name}")
+ distribution = distributions[name]
+ enabled = visited.setdefault(name, set())
+ enabled.update(extras)
+ versions[name] = distribution.version
+ for raw in distribution.requires or []:
+ requirement = Requirement(raw)
+ if requirement.marker is None or any(
+ requirement.marker.evaluate({"extra": extra})
+ for extra in {"", *enabled}
+ ):
+ pending.append(
+ (canonicalize_name(requirement.name), frozenset(requirement.extras))
+ )
+ return versions
+
+
__all__ = [
"DESKTOP_ENTRY_POINT_GROUP",
"MAX_ARCHIVE_ENTRIES",
@@ -439,6 +564,9 @@ def inspect_wheel(
"MAX_WHEEL_BYTES",
"WEB_ENTRY_POINT_GROUP",
"WheelInspectionError",
+ "get_reference_distributions",
+ "inspect_dependency_wheel",
"inspect_wheel",
"is_compatible_wheel",
+ "validate_dependencies",
]
diff --git a/datalab/start.pyw b/datalab/start.pyw
index 9f1b80051..816e0bad7 100644
--- a/datalab/start.pyw
+++ b/datalab/start.pyw
@@ -11,7 +11,11 @@ import sys
# --------------------------------------------------------------------------------------
# Macro command execution for the standalone version of DataLab
-if len(sys.argv) > 1 and sys.argv[1] == "-c":
+if len(sys.argv) > 1 and sys.argv[1] == "--internal-plugin-pip":
+ from datalab.plugins.dependencies import run_pip_request
+
+ sys.exit(run_pip_request(sys.argv[2]))
+elif len(sys.argv) > 1 and sys.argv[1] == "-c":
exec(sys.argv[2])
# --------------------------------------------------------------------------------------
diff --git a/datalab/tests/backbone/plugins/plugin_catalog_unit_test.py b/datalab/tests/backbone/plugins/plugin_catalog_unit_test.py
index 25e9f6b1d..4bafd2fe1 100644
--- a/datalab/tests/backbone/plugins/plugin_catalog_unit_test.py
+++ b/datalab/tests/backbone/plugins/plugin_catalog_unit_test.py
@@ -10,7 +10,12 @@
import pytest
-from datalab.plugins.catalog import CatalogError, download_release, fetch_catalog
+from datalab.plugins.catalog import (
+ CatalogError,
+ download_dependency_wheels,
+ download_release,
+ fetch_catalog,
+)
FILENAME = "example_plugin-1.0.0-py3-none-any.whl"
@@ -79,6 +84,42 @@ def test_release_is_downloaded_from_a_relative_url(tmp_path: Path) -> None:
assert Path(path).read_bytes() == b"wheel content"
+def test_dependency_artifacts_are_selected_and_verified(tmp_path: Path) -> None:
+ """Compatible dependency wheels use the same size and digest checks."""
+ filename = "offline_helper-1.0-py3-none-any.whl"
+ dependency = tmp_path / filename
+ data = b"dependency bytes"
+ dependency.write_bytes(data)
+ artifact = {
+ "filename": filename,
+ "url": filename,
+ "size": len(data),
+ "sha256": hashlib.sha256(data).hexdigest(),
+ }
+ release = publish(
+ tmp_path,
+ b"plugin",
+ dependency_wheels=[
+ artifact,
+ {
+ **artifact,
+ "filename": "foreign_helper-1.0-cp399-cp399-win_amd64.whl",
+ "url": "must-not-be-requested.whl",
+ },
+ ],
+ )
+ (plugin,) = fetch_catalog(write_catalog(tmp_path, [plugin_record([release])]))
+ latest = plugin.get_installable_release()
+ assert latest is not None
+ destination = tmp_path / "dependencies"
+ download_dependency_wheels(latest, str(destination))
+ assert [path.name for path in destination.iterdir()] == [filename]
+ assert (destination / filename).read_bytes() == data
+ dependency.write_bytes(b"changed")
+ with pytest.raises(CatalogError, match="size and digest"):
+ download_dependency_wheels(latest, str(destination))
+
+
def test_installable_release_skips_yanked_web_only_and_revoked(tmp_path: Path) -> None:
"""Only the newest desktop release that is not withdrawn is offered."""
releases = [
diff --git a/datalab/tests/backbone/plugins/plugin_dependencies_unit_test.py b/datalab/tests/backbone/plugins/plugin_dependencies_unit_test.py
new file mode 100644
index 000000000..38f26e9ac
--- /dev/null
+++ b/datalab/tests/backbone/plugins/plugin_dependencies_unit_test.py
@@ -0,0 +1,348 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Offline dependency planning and transactional installation."""
+
+from __future__ import annotations
+
+import importlib
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+from datalab.plugins import dependencies, wheels
+from datalab.plugins.dependencies import DependencyError
+from datalab.plugins.store import InstalledPluginStore
+from datalab.plugins.wheels import (
+ DESKTOP_ENTRY_POINT_GROUP,
+ WheelInspectionError,
+ get_reference_distributions,
+ inspect_dependency_wheel,
+ inspect_wheel,
+)
+from datalab.tests.backbone.plugins.wheel_factory import (
+ make_plugin_wheel,
+ wheel_filename,
+)
+
+PACKAGE = "datalab_offline_dependency_probe"
+DISTRIBUTION = "datalab-offline-dependency-probe"
+
+
+@pytest.fixture(name="store")
+def fixture_store(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> InstalledPluginStore:
+ """Keep imported test dependencies private to each test."""
+ monkeypatch.setattr(sys, "path", list(sys.path))
+ store = InstalledPluginStore(str(tmp_path / "installed"))
+ yield store
+ for name in [
+ name
+ for name in sys.modules
+ if name.startswith(
+ (PACKAGE, "datalab_offline_plugin_probe", "datalab_source_sdk_probe")
+ )
+ ]:
+ del sys.modules[name]
+ importlib.invalidate_caches()
+
+
+def write_files(
+ tmp_path: Path, *, requires: tuple[str, ...] = (), native: bool = False
+) -> Path:
+ """Write a plugin and an ordinary dependency into a local catalog."""
+ wheelhouse = tmp_path / "dependencies"
+ wheelhouse.mkdir()
+ (wheelhouse / wheel_filename(DISTRIBUTION, "1.0.0")).write_bytes(
+ make_plugin_wheel(
+ distribution=DISTRIBUTION,
+ version="1.0.0",
+ package=PACKAGE,
+ entry_points="",
+ files={f"{PACKAGE}/__init__.py": "VALUE = 42\n"},
+ extra_files={f"{PACKAGE}/library.dll": b"fixture"} if native else None,
+ requires_dist=requires,
+ record=True,
+ )
+ )
+ plugin = tmp_path / wheel_filename("datalab-offline-plugin-probe", "1.0.0")
+ plugin.write_bytes(
+ make_plugin_wheel(
+ distribution="datalab-offline-plugin-probe",
+ version="1.0.0",
+ package="datalab_offline_plugin_probe",
+ requires_dist=(f"{DISTRIBUTION}>=1",),
+ record=True,
+ )
+ )
+ return plugin
+
+
+def test_dependency_wheels_are_distinct_from_plugin_wheels() -> None:
+ """Native dependencies need no plugin entry point; plugins stay pure Python."""
+ data = make_plugin_wheel(
+ entry_points="",
+ extra_files={"example_datalab_plugin/library.dll": b"native"},
+ )
+ filename = wheel_filename("example-datalab-plugin", "1.2.0")
+ assert inspect_dependency_wheel(data, filename=filename)["compatible"]
+ with pytest.raises(WheelInspectionError, match="Native payloads"):
+ inspect_wheel(
+ data,
+ filename=filename,
+ entry_point_group=DESKTOP_ENTRY_POINT_GROUP,
+ available_distributions={"sigima": "1.3.0"},
+ )
+
+
+def test_reference_snapshot_excludes_ambient_plugins(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """An author's installed plugin must not become part of the host baseline."""
+ distributions = [
+ SimpleNamespace(
+ metadata={"Name": "datalab-platform"},
+ version="1.4.0",
+ requires=["core[fast]>=1", "test-only; extra == 'test'"],
+ ),
+ SimpleNamespace(
+ metadata={"Name": "core"},
+ version="1.0",
+ requires=["accelerator; extra == 'fast'"],
+ ),
+ SimpleNamespace(metadata={"Name": "accelerator"}, version="2.0", requires=[]),
+ SimpleNamespace(metadata={"Name": "pytango"}, version="10.3", requires=[]),
+ ]
+ monkeypatch.setattr(
+ wheels.importlib_metadata, "distributions", lambda: distributions
+ )
+ assert get_reference_distributions() == {
+ "datalab-platform": "1.4.0",
+ "core": "1.0",
+ "accelerator": "2.0",
+ }
+
+
+def test_catalog_candidates_do_not_override_installed_versions() -> None:
+ """Catalog metadata accepts any matching artifact, not only its newest one."""
+ data = make_plugin_wheel(requires_dist=("optional-helper<2",))
+ filename = wheel_filename("example-datalab-plugin", "1.2.0")
+ candidates = {"optional-helper": ["1.0", "2.0"]}
+ manifest = inspect_wheel(
+ data,
+ filename=filename,
+ entry_point_group=DESKTOP_ENTRY_POINT_GROUP,
+ available_distributions={},
+ additional_distributions=candidates,
+ )
+ assert manifest["dependencies"][0]["compatible"]
+ with pytest.raises(WheelInspectionError, match="incompatible installed version"):
+ inspect_wheel(
+ data,
+ filename=filename,
+ entry_point_group=DESKTOP_ENTRY_POINT_GROUP,
+ available_distributions={"optional-helper": "2.0"},
+ additional_distributions=candidates,
+ )
+
+
+def test_offline_installation_is_shared_deferred_and_kept_on_uninstall(
+ store: InstalledPluginStore, tmp_path: Path
+) -> None:
+ """Resolve a real pip graph without an index and activate only after restart."""
+ plugin = write_files(tmp_path)
+ protected = store.get_available_distributions()
+ manifest, plan = store.prepare_wheel_file(str(plugin))
+ assert plan is not None
+ try:
+ assert [wheel.distribution for wheel in plan.wheels] == [DISTRIBUTION]
+ result = store.install_wheel(
+ str(plugin), expected_sha256=manifest["sha256"], dependency_plan=plan
+ )
+ finally:
+ plan.close()
+ assert result.restart_required
+ assert not store.activate()
+ assert importlib.util.find_spec(PACKAGE) is None
+ assert all(
+ store.get_available_distributions()[name] == version
+ for name, version in protected.items()
+ )
+ restarted = InstalledPluginStore(store.root)
+ assert restarted.activate()
+ assert importlib.import_module(PACKAGE).VALUE == 42
+ paths = restarted.get_dependency_paths()
+ restarted.uninstall(result.plugin)
+ assert restarted.get_dependency_paths() == paths
+ assert all(Path(path).is_dir() for path in paths)
+ assert not restarted.get_plugins()
+
+
+def test_missing_transitive_wheel_leaves_the_store_unchanged(
+ store: InstalledPluginStore, tmp_path: Path
+) -> None:
+ plugin = write_files(
+ tmp_path, requires=("datalab-deliberately-unavailable-dependency>=1",)
+ )
+ with pytest.raises(DependencyError, match="Dependency installer failed"):
+ store.prepare_wheel_file(str(plugin))
+ assert not store.get_plugins()
+ assert not store.get_dependency_paths()
+
+
+def test_resolution_preserves_the_hosts_development_import_paths(
+ store: InstalledPluginStore, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """A real SDK exposed through sys.path is already provided, not downloadable."""
+ sdk = tmp_path / "sdk"
+ package = sdk / "datalab_source_sdk_probe"
+ package.mkdir(parents=True)
+ (package / "__init__.py").write_text("VALUE = 7\n", encoding="utf-8")
+ info = sdk / "datalab_source_sdk_probe-1.0.dist-info"
+ info.mkdir()
+ (info / "METADATA").write_text(
+ "Metadata-Version: 2.1\nName: datalab-source-sdk-probe\nVersion: 1.0\n",
+ encoding="utf-8",
+ )
+ monkeypatch.syspath_prepend(str(sdk))
+ assert importlib.import_module("datalab_source_sdk_probe").VALUE == 7
+ plugin = write_files(tmp_path, requires=("datalab-source-sdk-probe>=1",))
+ manifest, plan = store.prepare_wheel_file(str(plugin))
+ assert plan is not None
+ try:
+ assert [wheel.distribution for wheel in plan.wheels] == [DISTRIBUTION]
+ store.install_wheel(
+ str(plugin), expected_sha256=manifest["sha256"], dependency_plan=plan
+ )
+ finally:
+ plan.close()
+
+
+def test_restarted_plugin_is_enabled_in_an_explicit_list(
+ store: InstalledPluginStore, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Pending enablement survives the restart required by dependencies."""
+ from datalab.plugins import base as plugin_base
+
+ plugin = write_files(tmp_path)
+ manifest, plan = store.prepare_wheel_file(str(plugin))
+ assert plan is not None
+ try:
+ store.install_wheel(
+ str(plugin), expected_sha256=manifest["sha256"], dependency_plan=plan
+ )
+ finally:
+ plan.close()
+ restarted = InstalledPluginStore(store.root)
+ registry = plugin_base.PluginRegistry
+ classes = list(registry.get_plugin_classes())
+ errors = list(registry.get_discovery_errors())
+ failed = list(registry.get_failed_plugins())
+ traceback_available = plugin_base.Conf.traceback_log_available.get()
+ enabled: list[str] = []
+ monkeypatch.setattr(plugin_base, "_INSTALLED_PLUGIN_STORE", restarted)
+ monkeypatch.setattr(plugin_base, "_extend_sys_path_with_plugin_paths", lambda: None)
+ monkeypatch.setattr(plugin_base.pkgutil, "iter_modules", lambda: iter(()))
+ entry_points = plugin_base._get_plugin_entry_points
+ monkeypatch.setattr(
+ plugin_base,
+ "_get_plugin_entry_points",
+ lambda: [
+ entry
+ for entry in entry_points()
+ if entry.module.startswith("datalab_offline_plugin_probe")
+ ],
+ )
+ monkeypatch.setattr(plugin_base.Conf.plugins_enabled, "get", lambda *_args: True)
+ monkeypatch.setattr(
+ plugin_base.Conf.plugins_enabled_list, "get", lambda *_args: list(enabled)
+ )
+ monkeypatch.setattr(
+ plugin_base.Conf.plugins_enabled_list,
+ "set",
+ lambda values: enabled.__setitem__(slice(None), values),
+ )
+ registry.clear_plugin_classes()
+ try:
+ plugin_base.discover_plugins()
+ assert enabled == ["org.example.wheel-plugin"]
+ assert not restarted.get_pending_enabled_names()
+ assert not registry.get_failed_plugins()
+ finally:
+ registry.clear_plugin_classes()
+ registry.get_plugin_classes().extend(classes)
+ registry.clear_discovery_errors()
+ for error in errors:
+ registry.add_discovery_error(error)
+ registry.clear_failed_plugins()
+ for item in failed:
+ registry.add_failed_plugin(
+ item.name, item.filepath, item.traceback, item.source
+ )
+ plugin_base.Conf.traceback_log_available.set(traceback_available)
+
+
+def test_offline_resolution_rejects_dependency_urls(
+ store: InstalledPluginStore, tmp_path: Path
+) -> None:
+ """A transitive direct URL must not bypass the offline-only policy."""
+ plugin = write_files(
+ tmp_path, requires=("remote-helper @ https://must-not-be-requested.test/x.whl",)
+ )
+ with pytest.raises(DependencyError, match="Direct dependency URLs"):
+ store.prepare_wheel_file(str(plugin))
+ assert not store.get_plugins()
+
+
+def test_changed_dependency_is_rejected_after_consent(
+ store: InstalledPluginStore, tmp_path: Path
+) -> None:
+ plugin = write_files(tmp_path)
+ manifest, plan = store.prepare_wheel_file(str(plugin))
+ assert plan is not None
+ try:
+ Path(plan.wheels[0].path).write_bytes(b"changed")
+ with pytest.raises(DependencyError, match="changed after consent"):
+ store.install_wheel(
+ str(plugin), expected_sha256=manifest["sha256"], dependency_plan=plan
+ )
+ finally:
+ plan.close()
+ assert not store.get_plugins()
+ assert not store.get_dependency_paths()
+
+
+def test_windows_native_path_limits_do_not_publish_a_broken_installation(
+ store: InstalledPluginStore, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """A DLL loader path failure is rejected before publishing the store index."""
+ plugin = write_files(tmp_path, native=True)
+ manifest, plan = store.prepare_wheel_file(str(plugin))
+ assert plan is not None
+ monkeypatch.setattr(dependencies, "MAX_NATIVE_PATH", 1)
+ try:
+ with pytest.raises(DependencyError, match="Windows DLL path limit"):
+ store.install_wheel(
+ str(plugin), expected_sha256=manifest["sha256"], dependency_plan=plan
+ )
+ finally:
+ plan.close()
+ assert not store.get_plugins()
+ assert not store.get_dependency_paths()
+
+
+def test_missing_dependency_permission_does_not_allow_host_conflicts(
+ store: InstalledPluginStore, tmp_path: Path
+) -> None:
+ plugin = write_files(tmp_path)
+ plugin.write_bytes(
+ make_plugin_wheel(
+ distribution="datalab-offline-plugin-probe",
+ version="1.0.0",
+ requires_dist=("numpy<1",),
+ )
+ )
+ with pytest.raises(WheelInspectionError, match="incompatible installed version"):
+ store.prepare_wheel_file(str(plugin))
diff --git a/datalab/tests/backbone/plugins/wheel_factory.py b/datalab/tests/backbone/plugins/wheel_factory.py
index 8e22c1dd3..2bab43cb1 100644
--- a/datalab/tests/backbone/plugins/wheel_factory.py
+++ b/datalab/tests/backbone/plugins/wheel_factory.py
@@ -4,6 +4,7 @@
from __future__ import annotations
+import csv
import io
import zipfile
@@ -39,6 +40,7 @@ def make_plugin_wheel(
files: dict[str, str | bytes] | None = None,
extra_files: dict[str, str | bytes] | None = None,
extra_metadata: tuple[str, ...] = (),
+ record: bool = False,
) -> bytes:
"""Return the bytes of a DataLab plugin wheel.
@@ -84,4 +86,10 @@ def make_plugin_wheel(
archive.writestr(f"{dist_info}/entry_points.txt", entry_points)
if top_level is not None:
archive.writestr(f"{dist_info}/top_level.txt", top_level)
+ if record:
+ rows = io.StringIO()
+ writer = csv.writer(rows, lineterminator="\n")
+ for name in [*archive.namelist(), f"{dist_info}/RECORD"]:
+ writer.writerow((name, "", ""))
+ archive.writestr(f"{dist_info}/RECORD", rows.getvalue())
return buffer.getvalue()
diff --git a/datalab/tests/features/plugins/plugin_install_dialog_test.py b/datalab/tests/features/plugins/plugin_install_dialog_test.py
index 8bb1b779f..a5bf4a8be 100644
--- a/datalab/tests/features/plugins/plugin_install_dialog_test.py
+++ b/datalab/tests/features/plugins/plugin_install_dialog_test.py
@@ -8,6 +8,7 @@
from pathlib import Path
import pytest
+from guidata.qthelpers import qt_wait_until
from qtpy import QtWidgets as QW
from datalab import __version__
@@ -157,5 +158,80 @@ def test_refused_or_invalid_files_are_not_installed(
widget.deleteLater()
+@pytest.mark.parametrize("accept", [True, False])
+def test_additional_dependencies_are_planned_before_consent(
+ store: InstalledPluginStore,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ accept: bool,
+) -> None:
+ """The real asynchronous offline path installs nothing before confirmation."""
+ _app = QW.QApplication.instance() or QW.QApplication([])
+ dependency_name = "datalab-install-ui-dependency"
+ folder = tmp_path / "dependencies"
+ folder.mkdir()
+ (folder / wheel_filename(dependency_name, "1.0.0")).write_bytes(
+ make_plugin_wheel(
+ distribution=dependency_name,
+ version="1.0.0",
+ package=f"{PACKAGE}_dependency",
+ entry_points="",
+ requires_dist=(),
+ record=True,
+ )
+ )
+ path = Path(write_wheel(tmp_path))
+ path.write_bytes(
+ make_plugin_wheel(
+ distribution="datalab-install-ui-probe",
+ version="1.0.0",
+ package=PACKAGE,
+ plugin_id=PLUGIN_ID,
+ requires_dist=(f"{dependency_name}>=1",),
+ record=True,
+ )
+ )
+ reloads: list[bool] = []
+ warnings: list[str] = []
+ information: list[str] = []
+ confirmed: list[dict] = []
+ finished: list[bool] = []
+ monkeypatch.setattr(
+ QW.QMessageBox,
+ "warning",
+ lambda _parent, _title, text: warnings.append(text),
+ )
+ monkeypatch.setattr(
+ QW.QMessageBox,
+ "information",
+ lambda _parent, _title, text: information.append(text),
+ )
+ widget = InstalledPluginsWidget(lambda: reloads.append(True))
+
+ def consent(manifest: dict, origin: str | None = None) -> bool:
+ assert not store.get_plugins()
+ assert not store.get_dependency_paths()
+ confirmed.append(manifest)
+ dialog = PluginConsentDialog(manifest, widget, origin=origin)
+ dialog.deleteLater()
+ return accept
+
+ monkeypatch.setattr(widget, "confirm_installation", consent)
+ widget.install_path(str(path), on_finished=lambda: finished.append(True))
+ qt_wait_until(lambda: bool(finished), timeout=90)
+ assert finished == [True], warnings
+ assert not warnings
+ assert len(confirmed) == 1
+ (artifact,) = confirmed[0]["additional_dependencies"]
+ assert artifact["distribution"] == dependency_name
+ assert not Path(artifact["path"]).exists()
+ assert bool(store.get_plugins()) is accept
+ assert bool(store.get_dependency_paths()) is accept
+ assert bool(information) is accept
+ assert not reloads
+ assert not store.activate()
+ widget.deleteLater()
+
+
if __name__ == "__main__":
pytest.main([__file__])
diff --git a/doc/features/advanced/plugins.rst b/doc/features/advanced/plugins.rst
index d920bff11..9582e4909 100644
--- a/doc/features/advanced/plugins.rst
+++ b/doc/features/advanced/plugins.rst
@@ -238,7 +238,43 @@ The ``datalab-plugin`` command maintains the ``catalog.json`` file of a folder:
:: Check catalog.json against the plugin wheels of the folder
datalab-plugin catalog check \\fileserver\datalab\plugins
-``build`` checks every wheel as DataLab does before installing it (pure Python, plugin entry point, dependencies provided by DataLab, Python version supported by DataLab) and does not write the file if a wheel is refused. It keeps the fields edited in an existing ``catalog.json`` file (plugin ID, name, tier, status and its reason, repository, documentation, capabilities and keywords, and the reasons why releases are withdrawn), and revoked plugins remain listed. The file is replaced atomically, so that users may read the catalog meanwhile. ``check`` reports the wheels of the catalog that are missing, changed or refused, and the wheels of the folder that the catalog does not list: once a folder holds a ``catalog.json`` file, run ``build`` again after adding wheels. Both commands exit with a non-zero status when they find a problem.
+``build`` checks the plugin wheels (pure Python, plugin entry point, requirements available from the host or the catalog wheelhouse, supported Python version) and does not write the file if a wheel is refused. It keeps the fields edited in an existing ``catalog.json`` file (plugin ID, name, tier, status and its reason, repository, documentation, capabilities and keywords, and the reasons why releases are withdrawn), and revoked plugins remain listed. The file is replaced atomically, so that users may read the catalog meanwhile. ``check`` reports wheels that are missing, changed, refused or not listed, including the dependency artifacts. Run ``build`` again after adding wheels. Both commands exit with a non-zero status when they find a problem.
+
+.. _plugin_dependencies:
+
+Additional dependencies and offline installation
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Desktop plugins may require packages not shipped with DataLab. Keep the plugin itself as a pure-Python wheel, and supply the additional packages, including all their transitive dependencies, as ordinary wheels in a ``dependencies`` subfolder of the local catalog. These dependency wheels may contain native libraries, provided they match the Python version, ABI and platform of the host. They do not need a DataLab plugin entry point.
+
+On a computer with Internet access, the authoring command prepares the wheelhouse and writes the catalog:
+
+.. code-block:: bat
+
+ datalab-plugin catalog prepare D:\approved_plugins
+
+The command downloads wheels for the current host without installing them or changing its packages. It uses DataLab's declared runtime dependencies as the reference, so that unrelated plugins already installed on the author's computer are not mistaken for packages shipped with DataLab. Prepare wheels on each target environment when different Python versions or platforms need different artifacts, then copy the complete folder to the offline computer or network share. ``build`` and ``check`` themselves do not download missing packages.
+
+For example, a Windows CPython 3.11 catalog may contain:
+
+.. code-block:: text
+
+ approved_plugins\
+ catalog.json
+ datalab_tango-0.1.0-py3-none-any.whl
+ dependencies\
+ pytango-10.3.1-cp311-cp311-win_amd64.whl
+ docstring_parser-0.18.0-py3-none-any.whl
+
+The **Available plugins** tab copies the verified dependency artifacts of the chosen release. For **Install from file...**, place the ``dependencies`` folder next to the plugin wheel. DataLab resolves the installation using only these local files, with no package-index fallback and no compilation. Before confirmation it displays the additional packages, versions, sizes, source folder and SHA-256 digests.
+
+Packages already provided by the Python environment or by DataLab are never replaced, upgraded or downgraded. Additional packages are shared by plugins in the same Python runtime. An incompatible installed version, missing transitive wheel or unsupported native wheel causes an explicit refusal; no partial installation is activated. Catalog metadata checks do not replace this resolution against the actual host.
+
+An installation adding dependencies is activated after restarting DataLab. A newly installed plugin is then enabled, including when an explicit enablement list is configured. Uninstalling the plugin keeps the shared dependencies. Different Python/ABI/platform runtimes use separate dependency directories; dependencies incompatible with a changed host are reported rather than repaired over the network.
+
+There is no environment per plugin: plugins requiring contradictory versions cannot coexist. Wheels requiring external system libraries or drivers still need those prerequisites installed separately. Standalone dependencies are intended for library imports, not for supplying an external Python interpreter or promising that their command-line scripts can be run independently. The Web installer remains unchanged.
+
+On Windows, native library paths must also fit the DLL loader's path limit. DataLab uses short generation directory names and refuses an installation whose DLL paths would still be too long, before publishing it; use a shorter configuration directory in that case.
.. _plugin_install_from_file:
@@ -247,12 +283,12 @@ 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 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 have its requirements provided by the host or the adjacent dependency wheelhouse (see :ref:`plugin_dependencies`);
- 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.
+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. Installations not adding dependencies retain the plugin reload workflow; a newly installed plugin is enabled. Additional dependencies and new versions of already loaded plugins are only activated at the next start of DataLab.
When reloading plugins, DataLab performs the following steps:
diff --git a/doc/release_notes/release_1.04.md b/doc/release_notes/release_1.04.md
index e0dca80ed..fc75f34d9 100644
--- a/doc/release_notes/release_1.04.md
+++ b/doc/release_notes/release_1.04.md
@@ -44,11 +44,14 @@
**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
+* Before installing, DataLab checks the file without running it and shows its name, version, plugin classes, dependencies and SHA-256 digest
+* Plugins may now include additional dependencies as separate wheels, including compatible native libraries, without changing packages already supplied by DataLab. A complete local catalog can be installed without Internet access; missing files and version conflicts are reported before any partial installation is activated
+* The confirmation dialog lists the additional packages and their fingerprints. They are shared by plugins of the same Python runtime, activated after restarting DataLab, and kept when a plugin is uninstalled
+* Installed plugins are listed in the same tab, where they can be uninstalled; a newly installed plugin is enabled after a plugin reload when no additional packages are needed, or after restarting when dependencies were added. A new version of an already loaded plugin is used at the next start
* The new **Available plugins** tab lists the plugins of the [DataLab plugin catalog](https://datalab-platform.com/plugins/), official or from the community, with a search field: **Install** and **Update** download the wheel, check it against the catalog and install it after confirmation. The catalog address may be changed to use a private copy
* Organizations can distribute their own plugins, or the versions of public plugins they have approved, from local plugin catalogs: folders, typically shared on the network, declared in the **Plugin settings** tab or with the `DATALAB_PLUGINS_CATALOG` environment variable. Their plugins are listed in the **Available plugins** tab before those of the DataLab plugin catalog, and hide them when both offer the same plugin
* A local catalog folder may simply hold plugin wheels, listed as they are copied to it, or a `catalog.json` file giving display names, deprecations and withdrawn releases; `datalab-plugin catalog build` writes this file from the wheels of the folder, keeping the edited fields, and `datalab-plugin catalog check` reports wheels that are missing, changed, refused by DataLab or not listed
+* Authors can prepare an offline catalog with `datalab-plugin catalog prepare`: it downloads the additional dependency wheels needed by the reference host without installing them, then writes the catalog. The complete folder can be distributed on a network share or copied to computers without Internet access
* A catalog that cannot be read, such as a network share that is not connected or the online catalog on a computer without Internet access, is reported in the **Available plugins** tab without hiding the plugins of the other catalogs
**Plugin project generator:**
diff --git a/pyproject.toml b/pyproject.toml
index 0574b4059..8def24563 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -53,6 +53,7 @@ dependencies = [
"PyWavelets >= 1.2, < 2.0",
"psutil >= 5.8",
"packaging >= 21.3",
+ "pip >= 22.2",
"jedi >= 0.18.2",
# Web API server for HTTP/JSON remote control and notebook integration
"fastapi >= 0.110.0",
diff --git a/requirements.txt b/requirements.txt
index 576e858bf..236c847cc 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -17,6 +17,7 @@ myst_parser
opencv-python-headless >= 4.8.1.78
packaging >= 21.3
pandas >= 1.4, < 3.1
+pip >= 22.2
pre-commit
psutil >= 5.8
pydantic >= 2.0
diff --git a/scripts/frozen_plugin_probe.py b/scripts/frozen_plugin_probe.py
index 918eacf5a..f2e675224 100644
--- a/scripts/frozen_plugin_probe.py
+++ b/scripts/frozen_plugin_probe.py
@@ -14,6 +14,10 @@
python scripts/frozen_plugin_probe.py install
DataLab.exe --unattended --delay 5000
python scripts/frozen_plugin_probe.py verify
+
+Set ``DATALAB_PLUGIN_PROBE_WHEELHOUSE`` to a ``dependencies`` folder and run
+``prepare`` online first to include a real PyTango wheel. The release check
+then runs ``install`` inside DataLab.exe, without an index or external Python.
"""
from __future__ import annotations
@@ -26,6 +30,7 @@
import zipfile
MARKER_ENV = "DATALAB_PLUGIN_PROBE_MARKER"
+WHEELHOUSE_ENV = "DATALAB_PLUGIN_PROBE_WHEELHOUSE"
#: Modules commonly imported by plugins
MODULES = (
@@ -62,6 +67,9 @@
#: Distributions plugin wheels may declare as requirements
DISTRIBUTIONS = ("datalab-capsule", "guidata", "numpy", "plotpy", "scipy", "sigima")
+if os.environ.get(WHEELHOUSE_ENV):
+ MODULES += ("tango",)
+ DISTRIBUTIONS += ("pytango",)
PLUGIN_SOURCE = f'''\
import importlib
@@ -153,7 +161,12 @@ def make_probe_wheel() -> tuple[str, bytes]:
archive.writestr(
f"{dist_info}/METADATA",
"Metadata-Version: 2.1\nName: datalab-frozen-probe\nVersion: 1.0.0\n"
- "Requires-Dist: datalab-platform\n",
+ "Requires-Dist: datalab-platform\n"
+ + (
+ "Requires-Dist: pytango==10.3.1\n"
+ if os.environ.get(WHEELHOUSE_ENV)
+ else ""
+ ),
)
archive.writestr(
f"{dist_info}/WHEEL",
@@ -178,11 +191,37 @@ def install() -> int:
with open(path, "wb") as file:
file.write(data)
store = get_installed_plugin_store()
- store.install_wheel(path)
+ manifest, plan = store.prepare_wheel_file(
+ path, wheelhouse=os.environ.get(WHEELHOUSE_ENV)
+ )
+ try:
+ store.install_wheel(
+ path, expected_sha256=manifest["sha256"], dependency_plan=plan
+ )
+ finally:
+ if plan is not None:
+ plan.close()
print(f"Probe plugin installed in {store.root}")
return 0
+def prepare() -> int:
+ """Prepare the optional native dependency wheelhouse before the offline check."""
+ from datalab.plugins.catalogtool import (
+ prepare_local_catalog, # pylint: disable=import-outside-toplevel
+ )
+
+ directory = os.path.dirname(os.environ[WHEELHOUSE_ENV])
+ os.makedirs(directory, exist_ok=True)
+ filename, data = make_probe_wheel()
+ with open(os.path.join(directory, filename), "wb") as stream:
+ stream.write(data)
+ report = prepare_local_catalog(directory)
+ for error in report.errors:
+ print(error, file=sys.stderr)
+ return 1 if report.errors else 0
+
+
def verify() -> int:
"""Report the probe results and fail if the plugin was not fully usable."""
marker = os.environ[MARKER_ENV]
@@ -210,7 +249,7 @@ def verify() -> int:
if __name__ == "__main__":
- COMMANDS = {"install": install, "verify": verify}
+ COMMANDS = {"install": install, "verify": verify, "prepare": prepare}
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]]())
From bc1bc0418bcfe6120b9d96d06d24b0e24381da71 Mon Sep 17 00:00:00 2001
From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com>
Date: Thu, 22 Oct 2026 14:58:45 +0200
Subject: [PATCH 2/2] docs(plugins): add offline deployment instructions for
plugin installation
---
doc/features/advanced/plugins.rst | 93 +++++++
.../LC_MESSAGES/features/advanced/plugins.po | 234 +++++++++++++++---
.../LC_MESSAGES/release_notes/release_1.04.po | 25 +-
3 files changed, 312 insertions(+), 40 deletions(-)
diff --git a/doc/features/advanced/plugins.rst b/doc/features/advanced/plugins.rst
index 9582e4909..6aa7799b2 100644
--- a/doc/features/advanced/plugins.rst
+++ b/doc/features/advanced/plugins.rst
@@ -276,6 +276,99 @@ There is no environment per plugin: plugins requiring contradictory versions can
On Windows, native library paths must also fit the DLL loader's path limit. DataLab uses short generation directory names and refuses an installation whose DLL paths would still be too long, before publishing it; use a shorter configuration directory in that case.
+.. _plugin_offline_deployment:
+
+Offline deployment, step by step
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+This procedure is self-contained: the disconnected computer does not need a package index, a Python development environment, or access to the public plugin website. A local network share may be used without Internet access; a USB drive or an ordinary local folder works as well. Preparation takes place on a connected computer, installation on the disconnected computer.
+
+.. important::
+
+ A plugin catalog is a **source of installation files**, not a plugin search directory. Add it under **Local plugin catalogs**, not **Additional plugin directories**. ``DATALAB_PLUGINS_CATALOG`` supplies catalogs; ``DATALAB_PLUGINS`` supplies code loaded directly. Installing copies the approved files into the user's DataLab configuration directory, so the source folder or network share is not needed to run the installed plugin afterwards.
+
+1. Prepare a complete catalog on a connected computer
+"""""""""""""""""""""""""""""""""""""""""""""""""""
+
+The provider needs DataLab installed in a Python environment, with the ``datalab-plugin`` command available. The standalone application does not supply this authoring command; its users only need the prepared files. Use a reference environment matching the target's DataLab version, Python version, operating system, architecture and host package versions. **? > Installation and configuration...** identifies the target installation. A Windows 64-bit CPython 3.11 wheel is not interchangeable with a Linux, 32-bit or CPython 3.12 wheel.
+
+Create a folder, place the plugin's ``.whl`` file at its root, then run:
+
+.. code-block:: powershell
+
+ datalab-plugin catalog prepare D:\approved_plugins
+ datalab-plugin catalog check D:\approved_plugins
+
+``prepare`` needs Internet access: it resolves the missing requirements against the reference host, downloads their wheels, including transitive dependencies, into ``dependencies``, and writes ``catalog.json``. It does not install these packages in the reference host. No source distribution is compiled. If it reports a missing wheel or a conflict, fix the reference catalog before transferring it.
+
+Keep all files produced, including the dependency wheels. Do not copy just ``catalog.json`` or just the plugin wheel. If serving several target environments, prepare their wheels on matching reference environments, combine the needed files, then run ``datalab-plugin catalog build D:\approved_plugins`` followed by ``check``. A pure-Python wheel may also restrict the supported Python versions in its metadata.
+
+``check`` checks the catalog files, their integrity and metadata. It is not a substitute for an installation test on the actual target: complete dependency resolution happens at installation time. Before wider deployment, test the complete folder on an equivalent installation with Internet access unavailable.
+
+2. Transfer the folder, preserving its layout
+"""""""""""""""""""""""""""""""""""""""""""
+
+Copy the complete folder to a USB drive, local disk or network share accessible from the target computer. Preserve relative paths and file names. A mirror of the public catalog may store files under ``wheels\\...`` instead of ``dependencies``: preserve those directories too, because ``catalog.json`` references them. The JSON file alone is not a mirror, and DataLab does not automatically export an online catalog for offline use.
+
+For a strictly offline JSON catalog, the installation ``url`` of every release and dependency artifact must resolve to an existing local file. An absolute ``https://`` URL is still downloaded from its server even if the catalog itself is local; placing a wheel beside that JSON does not override its URL. Informational links such as ``repository``, ``documentation`` and ``source_url`` do not replace installation files. Catalogs generated by ``build`` use relative file URLs; published mirrors must retain the matching relative directory tree.
+
+The user needs read access to this source, and write access to their own configuration and temporary directories. Administrator privileges are not required to install a plugin this way. Limit write access to the shared catalog to its maintainers; a SHA-256 check detects changed files but does not certify that a plugin or dependency is safe.
+
+3. Configure the disconnected computer
+"""""""""""""""""""""""""""""""""""""
+
+Open **Plugins > Configure plugins... > Plugin settings**. Under **Local plugin catalogs**, click **Add**, select the catalog root (the folder containing ``catalog.json`` or the plugin wheels), and apply the configuration. Do not select the ``dependencies`` subfolder. Open **Available plugins** and click **Refresh** if needed.
+
+Administrators may instead set the following environment variable before launching DataLab:
+
+.. code-block:: powershell
+
+ $env:DATALAB_PLUGINS_CATALOG = 'D:\approved_plugins;\\fileserver\datalab\plugins'
+
+This example applies to DataLab started from the same PowerShell window; a shortcut launched separately does not inherit that window's environment. An organization may also deploy the variable through its usual workstation configuration. Environment-variable folders come before folders saved in DataLab's settings; both come before the online catalog. The first matching plugin ID or distribution wins: releases from different sources are not merged.
+
+.. warning::
+
+ Adding a local catalog does **not** disable the online catalog. If its URL is left configured, DataLab still tries to read it when refreshing, which can cause a network wait and an error on a disconnected computer. The other catalogs remain usable after that error.
+
+To disable that lookup, close DataLab, back up its configuration file, and set ``plugins_catalog_url`` to an empty value in its existing ``[main]`` section. With the standard Windows user profile, DataLab 1.3 and later use ``%USERPROFILE%\.DataLab_v1\DataLab_v1_typed.ini`` (on Linux/macOS, normally ``~/.DataLab_v1/DataLab_v1_typed.ini``). Custom profiles may use another base folder. Do not edit the legacy ``DataLab_v1.ini`` file instead.
+
+.. code-block:: ini
+
+ [main]
+ plugins_catalog_url =
+
+Preserve the rest of the file. Do not delete this option to disable it: an absent option uses the default public URL again. Restart DataLab after editing. A private HTTPS URL still requires access to that server; for a strictly file-based offline installation, use a local catalog folder.
+
+4. Install, confirm and restart when requested
+""""""""""""""""""""""""""""""""""""""""""""
+
+In **Available plugins**, find the plugin and click **Install**. DataLab chooses the newest release offered for Desktop and the running Python version, checks the plugin file and listed compatible dependency artifacts against their sizes and SHA-256 digests, then plans any additional installation using the local files only. Selection of a release does not guarantee that all its dependencies can be installed: this is checked against the actual host before confirmation.
+
+Read the confirmation: it gives the plugin identity, classes, requirements and fingerprint, and lists the additional packages, their versions, sizes, fingerprints and source folder when needed. Cancel leaves the installation unchanged. If approved, DataLab installs into its private user storage; it does not modify the environment's existing package versions or load code from the shared catalog at every startup.
+
+Restart DataLab if dependencies were added or if replacing an already loaded wheel. Otherwise, DataLab may offer a plugin reload. After restarting, check **Enable/disable plugins** for the plugin's state and any import error, then try one of its actions. The source catalog may be disconnected after successful installation. For a one-off file installation, **Install plugins > Install from file...** uses the same checks; keep ``dependencies`` next to the plugin wheel.
+
+5. Maintain and troubleshoot the offline catalog
+"""""""""""""""""""""""""""""""""""""""""""""""
+
+When ``catalog.json`` exists, it is authoritative: DataLab lists and copies only its declared releases and dependency artifacts. For a catalog managed from plugin wheels at its root, run ``build`` and ``check`` again on the preparation computer after adding or changing a plugin **or dependency** wheel, then redistribute the complete folder. Simply copying a new wheel beside an existing JSON file does not update that file. ``prepare`` may require Internet; ``build`` and ``check`` do not fetch missing packages.
+
+Do not run ``build`` on an unchanged mirror whose plugin wheels live only under ``wheels\\...``: the command scans the catalog root, not that recursive tree, and would remove unrepresented entries from the generated catalog. Update such a mirror by transferring a complete, consistent published catalog and its files. To curate it with the local authoring commands instead, first arrange the plugin wheels at the root and the additional packages in ``dependencies``.
+
+Users refresh **Available plugins** to discover updates, then explicitly click **Update to...**. Updates are not installed automatically. Plugins installed with ``pip`` in the Python environment are identified but not updated by this dialog. Shared additional dependencies are retained when a plugin is uninstalled, and their existing versions remain protected on subsequent installations.
+
+If an installation fails, read the error in the plugin dialog before changing files:
+
+- **Catalog not found or inaccessible:** check the selected root, drive letter, USB drive, network connection and read permissions. A missing share is not an Internet problem. Refresh once it is available.
+- **Size or SHA-256 mismatch:** the copied files differ from the catalog. Ask the provider for a complete, consistent copy or regenerate the catalog from the intended wheels; do not bypass the check.
+- **No matching wheel or missing requirement:** the wheelhouse may lack a transitive dependency or the wheel for this Python/ABI/platform. Ask the provider to run ``prepare`` on a matching reference environment. The installer never falls back to PyPI.
+- **Incompatible installed version:** the host or another plugin already provides a conflicting package version. Ask for a compatible release or reference installation; do not force an upgrade or downgrade to bypass the refusal.
+- **Windows DLL path too long:** use a shorter configuration path. **DLL import or missing system library:** check the native package's external prerequisites with the provider; DataLab installs neither drivers nor system packages.
+- **Installed but not active:** restart when requested, check the global and individual plugin enablement settings, and consult the import error displayed in **Enable/disable plugins**. Reloading alone does not replace an imported native library.
+
+For local help, use **? > PDF documentation**, then search for *Offline deployment, step by step* or ``DATALAB_PLUGINS_CATALOG``. This PDF is a local file and does not need Internet access. **Online documentation**, the welcome page's documentation link, and external source-code/documentation links open websites instead. The PDF menu action appears only when a local PDF is present; DataLab tries the system language first, then English. If it is absent or unreadable, ask the administrator for the PDF shipped with the matching DataLab version rather than relying on an online link.
+
.. _plugin_install_from_file:
Install plugins
diff --git a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po
index 4faa1861d..637333166 100644
--- a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po
+++ b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po
@@ -3,12 +3,20 @@
# This file is distributed under the same license as the DataLab package.
# FIRST AUTHOR , 2023.
#
-#, fuzzy
msgid ""
msgstr ""
+"Project-Id-Version: PROJECT VERSION\n"
+"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
+"POT-Creation-Date: 2026-10-22 14:49+0200\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME \n"
+"Language: fr\n"
+"Language-Team: fr \n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Babel 2.18.0\n"
msgid "Plugin system for DataLab, the open-source scientific data analysis and visualization platform"
msgstr "Système de plugins de DataLab, la plateforme open-source d'analyse et de visualisation de données scientifiques"
@@ -199,8 +207,146 @@ msgstr "Quel que soit le catalogue, DataLab vérifie la taille et l'empreinte SH
msgid "The ``datalab-plugin`` command maintains the ``catalog.json`` file of a folder:"
msgstr "La commande ``datalab-plugin`` gère le fichier ``catalog.json`` d'un dossier :"
-msgid "``build`` checks every wheel as DataLab does before installing it (pure Python, plugin entry point, dependencies provided by DataLab, Python version supported by DataLab) and does not write the file if a wheel is refused. It keeps the fields edited in an existing ``catalog.json`` file (plugin ID, name, tier, status and its reason, repository, documentation, capabilities and keywords, and the reasons why releases are withdrawn), and revoked plugins remain listed. The file is replaced atomically, so that users may read the catalog meanwhile. ``check`` reports the wheels of the catalog that are missing, changed or refused, and the wheels of the folder that the catalog does not list: once a folder holds a ``catalog.json`` file, run ``build`` again after adding wheels. Both commands exit with a non-zero status when they find a problem."
-msgstr "``build`` vérifie chaque wheel comme DataLab le fait avant de l'installer (Python pur, point d'entrée de plugin, dépendances fournies par DataLab, version de Python prise en charge par DataLab) et n'écrit pas le fichier si une wheel est refusée. Il conserve les champs modifiés d'un fichier ``catalog.json`` existant (identifiant, nom, niveau, statut et sa raison, dépôt, documentation, capacités et mots-clés des plugins, ainsi que les raisons du retrait des versions), et les plugins révoqués restent listés. Le fichier est remplacé de manière atomique, afin que les utilisateurs puissent lire le catalogue pendant ce temps. ``check`` signale les wheels du catalogue manquantes, modifiées ou refusées, ainsi que les wheels du dossier que le catalogue ne liste pas : dès qu'un dossier contient un fichier ``catalog.json``, relancez ``build`` après avoir ajouté des wheels. Les deux commandes se terminent avec un code de sortie non nul lorsqu'elles détectent un problème."
+msgid "``build`` checks the plugin wheels (pure Python, plugin entry point, requirements available from the host or the catalog wheelhouse, supported Python version) and does not write the file if a wheel is refused. It keeps the fields edited in an existing ``catalog.json`` file (plugin ID, name, tier, status and its reason, repository, documentation, capabilities and keywords, and the reasons why releases are withdrawn), and revoked plugins remain listed. The file is replaced atomically, so that users may read the catalog meanwhile. ``check`` reports wheels that are missing, changed, refused or not listed, including the dependency artifacts. Run ``build`` again after adding wheels. Both commands exit with a non-zero status when they find a problem."
+msgstr "``build`` vérifie les paquets wheel des plugins (Python pur, point d'entrée de plugin, exigences satisfaites par l'environnement hôte ou le dépôt de dépendances du catalogue, version de Python prise en charge) et n'écrit pas le fichier si un paquet est refusé. Il conserve les champs modifiés dans un ``catalog.json`` existant (identifiant, nom, catégorie, statut et motif, dépôt, documentation, capacités et mots-clés des plugins, ainsi que les motifs de retrait des versions), et les plugins révoqués restent listés. Le fichier est remplacé de manière atomique, pour que les utilisateurs puissent lire le catalogue pendant cette opération. ``check`` signale les paquets manquants, modifiés, refusés ou non listés, y compris les fichiers de dépendances. Relancez ``build`` après avoir ajouté des paquets. Les deux commandes renvoient un code de sortie non nul si elles détectent un problème."
+
+msgid "Additional dependencies and offline installation"
+msgstr "Dépendances supplémentaires et installation hors ligne"
+
+msgid "Desktop plugins may require packages not shipped with DataLab. Keep the plugin itself as a pure-Python wheel, and supply the additional packages, including all their transitive dependencies, as ordinary wheels in a ``dependencies`` subfolder of the local catalog. These dependency wheels may contain native libraries, provided they match the Python version, ABI and platform of the host. They do not need a DataLab plugin entry point."
+msgstr "Les plugins Desktop peuvent nécessiter des paquets qui ne sont pas fournis avec DataLab. Conservez le plugin lui-même sous forme de paquet wheel en Python pur et fournissez les paquets supplémentaires, y compris toutes leurs dépendances transitives, sous forme de wheels ordinaires dans un sous-dossier ``dependencies`` du catalogue local. Ces paquets de dépendances peuvent contenir des bibliothèques natives, à condition de correspondre à la version de Python, à l'ABI et à la plateforme de l'environnement hôte. Ils n'ont pas besoin de point d'entrée de plugin DataLab."
+
+msgid "On a computer with Internet access, the authoring command prepares the wheelhouse and writes the catalog:"
+msgstr "Sur un ordinateur disposant d'un accès à Internet, la commande de préparation constitue le dépôt de paquets wheel et écrit le catalogue :"
+
+msgid "The command downloads wheels for the current host without installing them or changing its packages. It uses DataLab's declared runtime dependencies as the reference, so that unrelated plugins already installed on the author's computer are not mistaken for packages shipped with DataLab. Prepare wheels on each target environment when different Python versions or platforms need different artifacts, then copy the complete folder to the offline computer or network share. ``build`` and ``check`` themselves do not download missing packages."
+msgstr "La commande télécharge les paquets wheel pour l'environnement courant sans les installer ni modifier ses paquets. Elle utilise les dépendances d'exécution déclarées de DataLab comme référence : les plugins sans rapport déjà installés sur l'ordinateur de l'auteur ne sont donc pas confondus avec les paquets livrés avec DataLab. Préparez les paquets dans chaque environnement cible lorsque des versions de Python ou des plateformes différentes nécessitent des fichiers différents, puis copiez le dossier complet sur l'ordinateur hors ligne ou le partage réseau. Les commandes ``build`` et ``check`` ne téléchargent pas elles-mêmes les paquets manquants."
+
+msgid "For example, a Windows CPython 3.11 catalog may contain:"
+msgstr "Par exemple, un catalogue pour CPython 3.11 sous Windows peut contenir :"
+
+msgid "The **Available plugins** tab copies the verified dependency artifacts of the chosen release. For **Install from file...**, place the ``dependencies`` folder next to the plugin wheel. DataLab resolves the installation using only these local files, with no package-index fallback and no compilation. Before confirmation it displays the additional packages, versions, sizes, source folder and SHA-256 digests."
+msgstr "L'onglet **Plugins disponibles** copie les fichiers de dépendances vérifiés de la version choisie. Pour **Installer depuis un fichier...**, placez le dossier ``dependencies`` à côté du paquet wheel du plugin. DataLab résout l'installation à partir de ces seuls fichiers locaux, sans repli vers un index de paquets et sans compilation. Avant confirmation, il affiche les paquets supplémentaires, leurs versions, leurs tailles, le dossier source et leurs empreintes SHA-256."
+
+msgid "Packages already provided by the Python environment or by DataLab are never replaced, upgraded or downgraded. Additional packages are shared by plugins in the same Python runtime. An incompatible installed version, missing transitive wheel or unsupported native wheel causes an explicit refusal; no partial installation is activated. Catalog metadata checks do not replace this resolution against the actual host."
+msgstr "Les paquets déjà fournis par l'environnement Python ou par DataLab ne sont jamais remplacés, mis à jour ou rétrogradés. Les paquets supplémentaires sont partagés entre les plugins d'un même environnement d'exécution Python. Une version installée incompatible, un paquet transitif manquant ou un paquet natif non pris en charge entraîne un refus explicite ; aucune installation partielle n'est activée. Les vérifications des métadonnées du catalogue ne remplacent pas cette résolution dans l'environnement hôte réel."
+
+msgid "An installation adding dependencies is activated after restarting DataLab. A newly installed plugin is then enabled, including when an explicit enablement list is configured. Uninstalling the plugin keeps the shared dependencies. Different Python/ABI/platform runtimes use separate dependency directories; dependencies incompatible with a changed host are reported rather than repaired over the network."
+msgstr "Une installation qui ajoute des dépendances est activée après le redémarrage de DataLab. Un plugin nouvellement installé est alors activé, y compris lorsqu'une liste explicite de plugins autorisés est configurée. Désinstaller le plugin conserve les dépendances partagées. Les environnements Python/ABI/plateforme différents utilisent des répertoires de dépendances séparés ; les dépendances incompatibles avec un environnement hôte modifié sont signalées plutôt que réparées par téléchargement."
+
+msgid "There is no environment per plugin: plugins requiring contradictory versions cannot coexist. Wheels requiring external system libraries or drivers still need those prerequisites installed separately. Standalone dependencies are intended for library imports, not for supplying an external Python interpreter or promising that their command-line scripts can be run independently. The Web installer remains unchanged."
+msgstr "Il n'y a pas d'environnement par plugin : des plugins exigeant des versions contradictoires ne peuvent pas coexister. Les paquets nécessitant des bibliothèques système ou des pilotes externes exigent toujours l'installation séparée de ces prérequis. Dans l'application autonome, les dépendances sont destinées à être importées comme bibliothèques ; elles ne fournissent pas d'interpréteur Python externe et le fonctionnement indépendant de leurs scripts en ligne de commande n'est pas garanti. L'installateur Web reste inchangé."
+
+msgid "On Windows, native library paths must also fit the DLL loader's path limit. DataLab uses short generation directory names and refuses an installation whose DLL paths would still be too long, before publishing it; use a shorter configuration directory in that case."
+msgstr "Sous Windows, les chemins des bibliothèques natives doivent également respecter la limite du chargeur de DLL. DataLab utilise des noms courts pour les répertoires de générations et refuse une installation dont les chemins de DLL resteraient trop longs, avant de la publier ; utilisez alors un répertoire de configuration dont le chemin est plus court."
+
+msgid "Offline deployment, step by step"
+msgstr "Déploiement hors ligne, pas à pas"
+
+msgid "This procedure is self-contained: the disconnected computer does not need a package index, a Python development environment, or access to the public plugin website. A local network share may be used without Internet access; a USB drive or an ordinary local folder works as well. Preparation takes place on a connected computer, installation on the disconnected computer."
+msgstr "Cette procédure est autonome : l'ordinateur déconnecté n'a besoin ni d'un index de paquets, ni d'un environnement de développement Python, ni d'un accès au site public des plugins. Un partage sur le réseau local peut être utilisé sans accès à Internet ; une clé USB ou un simple dossier local convient également. La préparation se fait sur un ordinateur connecté, l'installation sur l'ordinateur déconnecté."
+
+msgid "A plugin catalog is a **source of installation files**, not a plugin search directory. Add it under **Local plugin catalogs**, not **Additional plugin directories**. ``DATALAB_PLUGINS_CATALOG`` supplies catalogs; ``DATALAB_PLUGINS`` supplies code loaded directly. Installing copies the approved files into the user's DataLab configuration directory, so the source folder or network share is not needed to run the installed plugin afterwards."
+msgstr "Un catalogue de plugins est une **source de fichiers d'installation**, pas un répertoire de recherche de plugins. Ajoutez-le dans **Catalogues locaux de plugins**, et non dans **Répertoires supplémentaires de plugins**. ``DATALAB_PLUGINS_CATALOG`` indique des catalogues ; ``DATALAB_PLUGINS`` indique du code chargé directement. L'installation copie les fichiers approuvés dans le répertoire de configuration DataLab de l'utilisateur : le dossier source ou le partage réseau n'est donc plus nécessaire pour exécuter ensuite le plugin installé."
+
+msgid "1. Prepare a complete catalog on a connected computer"
+msgstr "1. Préparer un catalogue complet sur un ordinateur connecté"
+
+msgid "The provider needs DataLab installed in a Python environment, with the ``datalab-plugin`` command available. The standalone application does not supply this authoring command; its users only need the prepared files. Use a reference environment matching the target's DataLab version, Python version, operating system, architecture and host package versions. **? > Installation and configuration...** identifies the target installation. A Windows 64-bit CPython 3.11 wheel is not interchangeable with a Linux, 32-bit or CPython 3.12 wheel."
+msgstr "Le fournisseur doit disposer de DataLab installé dans un environnement Python, avec la commande ``datalab-plugin`` disponible. L'application autonome ne fournit pas cette commande de préparation ; ses utilisateurs n'ont besoin que des fichiers préparés. Utilisez un environnement de référence correspondant à la version de DataLab, à la version de Python, au système d'exploitation, à l'architecture et aux versions des paquets de l'ordinateur cible. **? > Installation et configuration...** permet d'identifier l'installation cible. Un paquet pour CPython 3.11 sous Windows 64 bits n'est pas interchangeable avec un paquet pour Linux, une architecture 32 bits ou CPython 3.12."
+
+msgid "Create a folder, place the plugin's ``.whl`` file at its root, then run:"
+msgstr "Créez un dossier, placez le fichier ``.whl`` du plugin à sa racine, puis exécutez :"
+
+msgid "``prepare`` needs Internet access: it resolves the missing requirements against the reference host, downloads their wheels, including transitive dependencies, into ``dependencies``, and writes ``catalog.json``. It does not install these packages in the reference host. No source distribution is compiled. If it reports a missing wheel or a conflict, fix the reference catalog before transferring it."
+msgstr "``prepare`` nécessite Internet : la commande résout les exigences manquantes par rapport à l'environnement hôte de référence, télécharge leurs paquets wheel, y compris les dépendances transitives, dans ``dependencies`` et écrit ``catalog.json``. Elle n'installe pas ces paquets dans l'environnement de référence. Aucune distribution source n'est compilée. Si elle signale un paquet manquant ou un conflit, corrigez le catalogue de référence avant de le transférer."
+
+msgid "Keep all files produced, including the dependency wheels. Do not copy just ``catalog.json`` or just the plugin wheel. If serving several target environments, prepare their wheels on matching reference environments, combine the needed files, then run ``datalab-plugin catalog build D:\\approved_plugins`` followed by ``check``. A pure-Python wheel may also restrict the supported Python versions in its metadata."
+msgstr "Conservez tous les fichiers produits, y compris les paquets de dépendances. Ne copiez pas uniquement ``catalog.json`` ou uniquement le paquet wheel du plugin. Pour desservir plusieurs environnements cibles, préparez leurs paquets dans des environnements de référence correspondants, rassemblez les fichiers nécessaires, puis exécutez ``datalab-plugin catalog build D:\\approved_plugins``, suivi de ``check``. Un paquet en Python pur peut lui aussi restreindre les versions de Python prises en charge dans ses métadonnées."
+
+msgid "``check`` checks the catalog files, their integrity and metadata. It is not a substitute for an installation test on the actual target: complete dependency resolution happens at installation time. Before wider deployment, test the complete folder on an equivalent installation with Internet access unavailable."
+msgstr "``check`` vérifie les fichiers du catalogue, leur intégrité et leurs métadonnées. Cette commande ne remplace pas un essai d'installation sur la cible réelle : la résolution complète des dépendances se fait lors de l'installation. Avant un déploiement plus large, testez le dossier complet sur une installation équivalente dont l'accès à Internet est indisponible."
+
+msgid "2. Transfer the folder, preserving its layout"
+msgstr "2. Transférer le dossier en conservant son organisation"
+
+msgid "Copy the complete folder to a USB drive, local disk or network share accessible from the target computer. Preserve relative paths and file names. A mirror of the public catalog may store files under ``wheels\\\\...`` instead of ``dependencies``: preserve those directories too, because ``catalog.json`` references them. The JSON file alone is not a mirror, and DataLab does not automatically export an online catalog for offline use."
+msgstr "Copiez le dossier complet sur une clé USB, un disque local ou un partage réseau accessible depuis l'ordinateur cible. Conservez les chemins relatifs et les noms de fichiers. Un miroir du catalogue public peut stocker les fichiers dans ``wheels\\\\...`` plutôt que dans ``dependencies`` : conservez également ces répertoires, car ``catalog.json`` les référence. Le seul fichier JSON n'est pas un miroir, et DataLab n'exporte pas automatiquement un catalogue en ligne pour un usage hors ligne."
+
+msgid "For a strictly offline JSON catalog, the installation ``url`` of every release and dependency artifact must resolve to an existing local file. An absolute ``https://`` URL is still downloaded from its server even if the catalog itself is local; placing a wheel beside that JSON does not override its URL. Informational links such as ``repository``, ``documentation`` and ``source_url`` do not replace installation files. Catalogs generated by ``build`` use relative file URLs; published mirrors must retain the matching relative directory tree."
+msgstr "Pour un catalogue JSON strictement hors ligne, l'URL d'installation (champ ``url``) de chaque version et de chaque fichier de dépendance doit désigner un fichier local existant. Une URL absolue ``https://`` entraîne toujours un téléchargement depuis son serveur, même si le catalogue est local ; placer un paquet wheel à côté de ce JSON ne remplace pas son URL. Les liens informatifs tels que ``repository``, ``documentation`` et ``source_url`` ne remplacent pas les fichiers d'installation. Les catalogues générés par ``build`` utilisent des URL de fichiers relatives ; les miroirs publiés doivent conserver l'arborescence relative correspondante."
+
+msgid "The user needs read access to this source, and write access to their own configuration and temporary directories. Administrator privileges are not required to install a plugin this way. Limit write access to the shared catalog to its maintainers; a SHA-256 check detects changed files but does not certify that a plugin or dependency is safe."
+msgstr "L'utilisateur doit disposer d'un accès en lecture à cette source, et d'un accès en écriture à ses propres répertoires de configuration et de fichiers temporaires. Les privilèges d'administrateur ne sont pas nécessaires pour installer un plugin de cette manière. Réservez l'accès en écriture au catalogue partagé à ses responsables ; une vérification SHA-256 détecte les fichiers modifiés, mais ne certifie pas l'innocuité d'un plugin ou d'une dépendance."
+
+msgid "3. Configure the disconnected computer"
+msgstr "3. Configurer l'ordinateur déconnecté"
+
+msgid "Open **Plugins > Configure plugins... > Plugin settings**. Under **Local plugin catalogs**, click **Add**, select the catalog root (the folder containing ``catalog.json`` or the plugin wheels), and apply the configuration. Do not select the ``dependencies`` subfolder. Open **Available plugins** and click **Refresh** if needed."
+msgstr "Ouvrez **Plugins > Configurer les plugins... > Paramètres des plugins**. Dans **Catalogues locaux de plugins**, cliquez sur **Ajouter**, sélectionnez la racine du catalogue (le dossier contenant ``catalog.json`` ou les paquets des plugins), puis appliquez la configuration. Ne sélectionnez pas le sous-dossier ``dependencies``. Ouvrez **Plugins disponibles** et cliquez sur **Actualiser** si nécessaire."
+
+msgid "Administrators may instead set the following environment variable before launching DataLab:"
+msgstr "Les administrateurs peuvent aussi définir la variable d'environnement suivante avant de lancer DataLab :"
+
+msgid "This example applies to DataLab started from the same PowerShell window; a shortcut launched separately does not inherit that window's environment. An organization may also deploy the variable through its usual workstation configuration. Environment-variable folders come before folders saved in DataLab's settings; both come before the online catalog. The first matching plugin ID or distribution wins: releases from different sources are not merged."
+msgstr "Cet exemple s'applique à DataLab démarré depuis la même fenêtre PowerShell ; un raccourci lancé séparément n'hérite pas de l'environnement de cette fenêtre. Une organisation peut également déployer la variable par son mécanisme habituel de configuration des postes. Les dossiers de la variable d'environnement sont prioritaires sur ceux enregistrés dans les paramètres de DataLab ; les deux sont prioritaires sur le catalogue en ligne. Le premier identifiant de plugin ou nom de distribution correspondant l'emporte : les versions de sources différentes ne sont pas fusionnées."
+
+msgid "Adding a local catalog does **not** disable the online catalog. If its URL is left configured, DataLab still tries to read it when refreshing, which can cause a network wait and an error on a disconnected computer. The other catalogs remain usable after that error."
+msgstr "Ajouter un catalogue local ne désactive **pas** le catalogue en ligne. Si son URL reste configurée, DataLab tente toujours de le lire lors de l'actualisation, ce qui peut entraîner une attente réseau et une erreur sur un ordinateur déconnecté. Les autres catalogues restent utilisables après cette erreur."
+
+msgid "To disable that lookup, close DataLab, back up its configuration file, and set ``plugins_catalog_url`` to an empty value in its existing ``[main]`` section. With the standard Windows user profile, DataLab 1.3 and later use ``%USERPROFILE%\\.DataLab_v1\\DataLab_v1_typed.ini`` (on Linux/macOS, normally ``~/.DataLab_v1/DataLab_v1_typed.ini``). Custom profiles may use another base folder. Do not edit the legacy ``DataLab_v1.ini`` file instead."
+msgstr "Pour désactiver cette recherche, fermez DataLab, sauvegardez son fichier de configuration et donnez une valeur vide à ``plugins_catalog_url`` dans la section ``[main]`` existante. Avec le profil utilisateur Windows standard, DataLab 1.3 et les versions suivantes utilisent ``%USERPROFILE%\\.DataLab_v1\\DataLab_v1_typed.ini`` (sous Linux/macOS, normalement ``~/.DataLab_v1/DataLab_v1_typed.ini``). Les profils personnalisés peuvent utiliser un autre dossier de base. Ne modifiez pas à sa place l'ancien fichier ``DataLab_v1.ini``."
+
+msgid "Preserve the rest of the file. Do not delete this option to disable it: an absent option uses the default public URL again. Restart DataLab after editing. A private HTTPS URL still requires access to that server; for a strictly file-based offline installation, use a local catalog folder."
+msgstr "Conservez le reste du fichier. Ne supprimez pas cette option pour la désactiver : une option absente rétablit l'URL publique par défaut. Redémarrez DataLab après modification. Une URL HTTPS privée nécessite toujours un accès à son serveur ; pour une installation hors ligne reposant uniquement sur des fichiers, utilisez un dossier de catalogue local."
+
+msgid "4. Install, confirm and restart when requested"
+msgstr "4. Installer, confirmer et redémarrer si nécessaire"
+
+msgid "In **Available plugins**, find the plugin and click **Install**. DataLab chooses the newest release offered for Desktop and the running Python version, checks the plugin file and listed compatible dependency artifacts against their sizes and SHA-256 digests, then plans any additional installation using the local files only. Selection of a release does not guarantee that all its dependencies can be installed: this is checked against the actual host before confirmation."
+msgstr "Dans **Plugins disponibles**, trouvez le plugin et cliquez sur **Installer**. DataLab choisit la version la plus récente proposée pour Desktop et pour la version de Python utilisée, vérifie la taille et les empreintes SHA-256 du plugin et des fichiers de dépendances compatibles listés, puis prépare toute installation supplémentaire à partir des seuls fichiers locaux. La sélection d'une version ne garantit pas que toutes ses dépendances puissent être installées : ce point est vérifié dans l'environnement hôte réel avant confirmation."
+
+msgid "Read the confirmation: it gives the plugin identity, classes, requirements and fingerprint, and lists the additional packages, their versions, sizes, fingerprints and source folder when needed. Cancel leaves the installation unchanged. If approved, DataLab installs into its private user storage; it does not modify the environment's existing package versions or load code from the shared catalog at every startup."
+msgstr "Lisez la confirmation : elle indique l'identité du plugin, ses classes, ses exigences et son empreinte, ainsi que les paquets supplémentaires, leurs versions, leurs tailles, leurs empreintes et le dossier source si nécessaire. Annuler laisse l'installation inchangée. Après accord, DataLab installe les fichiers dans son stockage utilisateur privé ; il ne modifie pas les versions des paquets de l'environnement et ne recharge pas le code depuis le catalogue partagé à chaque démarrage."
+
+msgid "Restart DataLab if dependencies were added or if replacing an already loaded wheel. Otherwise, DataLab may offer a plugin reload. After restarting, check **Enable/disable plugins** for the plugin's state and any import error, then try one of its actions. The source catalog may be disconnected after successful installation. For a one-off file installation, **Install plugins > Install from file...** uses the same checks; keep ``dependencies`` next to the plugin wheel."
+msgstr "Redémarrez DataLab si des dépendances ont été ajoutées ou si vous remplacez un paquet déjà chargé. Sinon, DataLab peut proposer de recharger les plugins. Après redémarrage, vérifiez l'état du plugin et les éventuelles erreurs d'importation dans **Activer/désactiver les plugins**, puis essayez l'une de ses actions. Le catalogue source peut être déconnecté après une installation réussie. Pour installer un fichier ponctuellement, **Installer des plugins > Installer depuis un fichier...** effectue les mêmes vérifications ; conservez ``dependencies`` à côté du paquet wheel du plugin."
+
+msgid "5. Maintain and troubleshoot the offline catalog"
+msgstr "5. Maintenir et dépanner le catalogue hors ligne"
+
+msgid "When ``catalog.json`` exists, it is authoritative: DataLab lists and copies only its declared releases and dependency artifacts. For a catalog managed from plugin wheels at its root, run ``build`` and ``check`` again on the preparation computer after adding or changing a plugin **or dependency** wheel, then redistribute the complete folder. Simply copying a new wheel beside an existing JSON file does not update that file. ``prepare`` may require Internet; ``build`` and ``check`` do not fetch missing packages."
+msgstr "Lorsque ``catalog.json`` existe, il fait autorité : DataLab ne liste et ne copie que les versions et fichiers de dépendances qui y sont déclarés. Pour un catalogue géré à partir de paquets de plugins placés à sa racine, relancez ``build`` et ``check`` sur l'ordinateur de préparation après ajout ou modification d'un paquet de plugin **ou de dépendance**, puis redistribuez le dossier complet. Copier simplement un nouveau paquet à côté d'un JSON existant ne met pas ce fichier à jour. ``prepare`` peut nécessiter Internet ; ``build`` et ``check`` ne récupèrent pas les paquets manquants."
+
+msgid "Do not run ``build`` on an unchanged mirror whose plugin wheels live only under ``wheels\\\\...``: the command scans the catalog root, not that recursive tree, and would remove unrepresented entries from the generated catalog. Update such a mirror by transferring a complete, consistent published catalog and its files. To curate it with the local authoring commands instead, first arrange the plugin wheels at the root and the additional packages in ``dependencies``."
+msgstr "N'exécutez pas ``build`` sur un miroir inchangé dont les paquets de plugins sont uniquement dans ``wheels\\\\...`` : la commande parcourt la racine du catalogue, pas cette arborescence récursive, et supprimerait du catalogue généré les entrées non représentées. Mettez à jour un tel miroir en transférant un catalogue publié complet et cohérent avec ses fichiers. Pour le gérer avec les commandes locales de préparation, disposez d'abord les paquets des plugins à la racine et les paquets supplémentaires dans ``dependencies``."
+
+msgid "Users refresh **Available plugins** to discover updates, then explicitly click **Update to...**. Updates are not installed automatically. Plugins installed with ``pip`` in the Python environment are identified but not updated by this dialog. Shared additional dependencies are retained when a plugin is uninstalled, and their existing versions remain protected on subsequent installations."
+msgstr "Les utilisateurs actualisent **Plugins disponibles** pour découvrir les mises à jour, puis cliquent explicitement sur **Mettre à jour vers...**. Les mises à jour ne sont pas installées automatiquement. Les plugins installés avec ``pip`` dans l'environnement Python sont identifiés, mais ne sont pas mis à jour par cette boîte de dialogue. Les dépendances supplémentaires partagées sont conservées lorsqu'un plugin est désinstallé, et leurs versions existantes restent protégées lors des installations suivantes."
+
+msgid "If an installation fails, read the error in the plugin dialog before changing files:"
+msgstr "Si une installation échoue, lisez l'erreur dans la boîte de dialogue des plugins avant de modifier les fichiers :"
+
+msgid "**Catalog not found or inaccessible:** check the selected root, drive letter, USB drive, network connection and read permissions. A missing share is not an Internet problem. Refresh once it is available."
+msgstr "**Catalogue introuvable ou inaccessible :** vérifiez la racine sélectionnée, la lettre de lecteur, la clé USB, la connexion réseau et les droits de lecture. Un partage manquant n'est pas un problème d'accès à Internet. Actualisez lorsqu'il est de nouveau disponible."
+
+msgid "**Size or SHA-256 mismatch:** the copied files differ from the catalog. Ask the provider for a complete, consistent copy or regenerate the catalog from the intended wheels; do not bypass the check."
+msgstr "**Taille ou SHA-256 incorrect :** les fichiers copiés diffèrent du catalogue. Demandez au fournisseur une copie complète et cohérente ou régénérez le catalogue à partir des paquets souhaités ; ne contournez pas cette vérification."
+
+msgid "**No matching wheel or missing requirement:** the wheelhouse may lack a transitive dependency or the wheel for this Python/ABI/platform. Ask the provider to run ``prepare`` on a matching reference environment. The installer never falls back to PyPI."
+msgstr "**Aucun paquet compatible ou exigence manquante :** le dépôt peut ne pas contenir une dépendance transitive ou le paquet correspondant à ce Python, à cette ABI ou à cette plateforme. Demandez au fournisseur d'exécuter ``prepare`` dans un environnement de référence correspondant. L'installateur ne se replie jamais vers PyPI."
+
+msgid "**Incompatible installed version:** the host or another plugin already provides a conflicting package version. Ask for a compatible release or reference installation; do not force an upgrade or downgrade to bypass the refusal."
+msgstr "**Version installée incompatible :** l'environnement hôte ou un autre plugin fournit déjà une version de paquet en conflit. Demandez une version ou une installation de référence compatible ; ne forcez pas de mise à jour ou de rétrogradation pour contourner le refus."
+
+msgid "**Windows DLL path too long:** use a shorter configuration path. **DLL import or missing system library:** check the native package's external prerequisites with the provider; DataLab installs neither drivers nor system packages."
+msgstr "**Chemin de DLL Windows trop long :** utilisez un chemin de configuration plus court. **Erreur d'importation de DLL ou bibliothèque système manquante :** vérifiez les prérequis externes du paquet natif avec le fournisseur ; DataLab n'installe ni pilotes ni paquets système."
+
+msgid "**Installed but not active:** restart when requested, check the global and individual plugin enablement settings, and consult the import error displayed in **Enable/disable plugins**. Reloading alone does not replace an imported native library."
+msgstr "**Installé mais inactif :** redémarrez si cela est demandé, vérifiez l'activation globale et individuelle des plugins et consultez l'erreur d'importation affichée dans **Activer/désactiver les plugins**. Un simple rechargement ne remplace pas une bibliothèque native déjà importée."
+
+msgid "For local help, use **? > PDF documentation**, then search for *Offline deployment, step by step* or ``DATALAB_PLUGINS_CATALOG``. This PDF is a local file and does not need Internet access. **Online documentation**, the welcome page's documentation link, and external source-code/documentation links open websites instead. The PDF menu action appears only when a local PDF is present; DataLab tries the system language first, then English. If it is absent or unreadable, ask the administrator for the PDF shipped with the matching DataLab version rather than relying on an online link."
+msgstr "Pour l'aide locale, utilisez **? > Documentation PDF**, puis recherchez *Déploiement hors ligne, pas à pas* ou ``DATALAB_PLUGINS_CATALOG``. Ce PDF est un fichier local et ne nécessite pas Internet. **Documentation en ligne**, le lien vers la documentation de la page d'accueil et les liens externes vers le code source ou la documentation ouvrent, eux, des sites web. L'action de menu du PDF n'apparaît que si un PDF local est présent ; DataLab essaie d'abord la langue du système, puis l'anglais. S'il est absent ou illisible, demandez à l'administrateur le PDF livré avec la version correspondante de DataLab plutôt que de dépendre d'un lien en ligne."
msgid "Install plugins"
msgstr "Installer des plugins"
@@ -208,8 +354,8 @@ 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 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 have its requirements provided by the host or the adjacent dependency wheelhouse (see :ref:`plugin_dependencies`);"
+msgstr "un paquet 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 ses exigences doivent être satisfaites par l'environnement hôte ou le dépôt de dépendances adjacent (voir :ref:`plugin_dependencies`) ;"
msgid "a single Python module named ``datalab_.py``."
msgstr "un module Python unique nommé ``datalab_.py``."
@@ -217,8 +363,8 @@ 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 "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. Installations not adding dependencies retain the plugin reload workflow; a newly installed plugin is enabled. Additional dependencies and new versions of already loaded plugins are only activated at the next start of DataLab."
+msgstr "Les plugins installés sont conservés dans le dossier ``installed_plugins`` du répertoire de configuration DataLab (par exemple ``~/.DataLab_v1/installed_plugins``) et listés dans l'onglet, où **Désinstaller** les supprime. Les installations sans ajout de dépendances conservent le mécanisme de rechargement des plugins ; un plugin nouvellement installé est activé. Les dépendances supplémentaires et les nouvelles versions de plugins déjà chargés ne sont activées 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 :"
@@ -614,62 +760,82 @@ msgid "Plugins may also extends DataLab I/O features by providing new image or s
msgstr "Les plugins peuvent également étendre les fonctionnalités d'entrée/sortie de DataLab en fournissant de nouveaux formats d'image ou de signal. Pour ce faire, ils doivent fournir une sous-classe de :class:`ImageFormatBase` ou :class:`SignalFormatBase`, dans laquelle les informations de format sont définies à l'aide de la classe :class:`FormatInfo`."
msgid "Object representing classic image file types"
-msgstr ""
+msgstr "Objet représentant les types de fichiers image classiques"
msgid "Read data and return it"
-msgstr ""
+msgstr "Lire les données et les renvoyer"
msgid "Parameters"
msgstr "Paramètres"
msgid "File name"
-msgstr ""
+msgstr "Nom du fichier"
msgid "Returns"
msgstr "Retourne"
msgid "Image array data"
-msgstr ""
+msgstr "Données du tableau de l'image"
msgid "Write data to file"
-msgstr ""
+msgstr "Écrire les données dans un fichier"
+
+msgid "Write classic image data with validated format-specific options."
+msgstr "Écrire des données d'image classiques avec des options propres au format validées."
msgid "Information about a plugin that failed to load or instantiate."
msgstr "Informations sur un plugin qui n'a pas pu être chargé ou instancié."
msgid "Format info"
-msgstr ""
+msgstr "Informations sur le format"
msgid "Return reStructuredText table row for format info (table `.. list-table::` format, with 5 columns)"
-msgstr ""
+msgstr "Renvoyer une ligne de tableau reStructuredText décrivant le format (tableau ``.. list-table::`` à 5 colonnes)"
msgid "Base image format object."
-msgstr ""
+msgstr "Objet de base pour les formats d'image."
msgid "This class is used to define the interface for image I/O formats. It is an abstract base class that defines the methods that must be implemented by any image format class."
-msgstr ""
+msgstr "Cette classe définit l'interface des formats d'entrée/sortie des images. C'est une classe de base abstraite qui définit les méthodes devant être implémentées par toute classe de format d'image."
+
+msgid "Reject read parameters for formats that do not support them."
+msgstr "Refuser les paramètres de lecture pour les formats qui ne les prennent pas en charge."
msgid "Read list of image objects from file"
-msgstr ""
+msgstr "Lire une liste d'objets image dans un fichier"
msgid "Callback worker object"
-msgstr ""
+msgstr "Objet de traitement des rappels"
+
+msgid "Optional format-specific read parameters"
+msgstr "Paramètres de lecture facultatifs propres au format"
msgid "List of image objects"
-msgstr ""
+msgstr "Liste d'objets image"
msgid "file name"
-msgstr ""
+msgstr "nom du fichier"
-#, fuzzy
msgid "native object (signal or image)"
-msgstr "signaux et images générés"
+msgstr "objet natif (signal ou image)"
msgid "Raises"
msgstr "Lève"
msgid "if format is not supported"
-msgstr ""
+msgstr "si le format n'est pas pris en charge"
+
+msgid "Write an image with format-specific options."
+msgstr "Écrire une image avec des options propres au format."
+
+msgid "Image object"
+msgstr "Objet image"
+
+msgid "Format-specific writer options"
+msgstr "Options d'écriture propres au format"
+
+msgid "If this format does not support writer options"
+msgstr "Si ce format ne prend pas en charge les options d'écriture"
msgid "Plugin base class"
msgstr "Classe de base du plugin"
@@ -948,35 +1114,34 @@ msgid "return html formatted text (default: True)"
msgstr "retourner du texte formaté en html (par défaut : True)"
msgid "Class representing a signal file type"
-msgstr ""
+msgstr "Classe représentant un type de fichier de signal"
msgid "Create empty object"
-msgstr ""
+msgstr "Créer un objet vide"
msgid "Index of object in file"
-msgstr ""
+msgstr "Indice de l'objet dans le fichier"
-#, fuzzy
msgid "Signal object"
-msgstr "objets sélectionnés"
+msgstr "Objet signal"
msgid "Create signal object from xydata and filename."
-msgstr ""
+msgstr "Créer un objet signal à partir de xydata et du nom de fichier."
msgid "XY data"
-msgstr ""
+msgstr "Données XY"
msgid "Create signal objects from xydata and filename"
-msgstr ""
+msgstr "Créer des objets signal à partir de xydata et du nom de fichier"
msgid "List of signal objects"
-msgstr ""
+msgstr "Liste d'objets signal"
msgid "Read list of signal objects from file"
-msgstr ""
+msgstr "Lire une liste d'objets signal dans un fichier"
msgid "Read data and metadata from file, write metadata to object, return xydata"
-msgstr ""
+msgstr "Lire les données et métadonnées d'un fichier, écrire les métadonnées dans l'objet et renvoyer xydata"
msgid "Discover plugins through package entry points and naming convention."
msgstr "Découvrir les plugins via les points d'entrée de packages et la convention de nommage."
@@ -1362,4 +1527,3 @@ msgstr "Valide, exécute et valide de manière transactionnelle une recette sur
msgid "Validate, execute, and atomically commit one recipe invocation."
msgstr "Valide, exécute et valide de manière atomique un appel de recette."
-
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 60b66c3a1..02e847453 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
@@ -3,14 +3,20 @@
# This file is distributed under the same license as the DataLab package.
# FIRST AUTHOR , 2026.
#
-#, fuzzy
msgid ""
msgstr ""
+"Project-Id-Version: PROJECT VERSION\n"
+"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
+"POT-Creation-Date: 2026-10-22 14:31+0200\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME \n"
"Language: fr\n"
+"Language-Team: fr \n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Babel 2.18.0\n"
msgid "Version 1.4"
msgstr "Version 1.4"
@@ -114,11 +120,17 @@ 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 "Before installing, DataLab checks the file without running it and shows its name, version, plugin classes, dependencies and SHA-256 digest"
+msgstr "Avant l'installation, DataLab vérifie le fichier sans l'exécuter et affiche son nom, sa version, ses classes de plugins, ses dépendances et son empreinte SHA-256"
-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 "Plugins may now include additional dependencies as separate wheels, including compatible native libraries, without changing packages already supplied by DataLab. A complete local catalog can be installed without Internet access; missing files and version conflicts are reported before any partial installation is activated"
+msgstr "Les plugins peuvent désormais fournir des dépendances supplémentaires sous forme de paquets wheel séparés, y compris des bibliothèques natives compatibles, sans modifier les paquets déjà fournis par DataLab. Un catalogue local complet peut être installé sans accès à Internet ; les fichiers manquants et les conflits de versions sont signalés avant toute activation d'une installation partielle"
+
+msgid "The confirmation dialog lists the additional packages and their fingerprints. They are shared by plugins of the same Python runtime, activated after restarting DataLab, and kept when a plugin is uninstalled"
+msgstr "La boîte de dialogue de confirmation liste les paquets supplémentaires et leurs empreintes. Ils sont partagés entre les plugins d'un même environnement d'exécution Python, activés après redémarrage de DataLab et conservés lorsqu'un plugin est désinstallé"
+
+msgid "Installed plugins are listed in the same tab, where they can be uninstalled; a newly installed plugin is enabled after a plugin reload when no additional packages are needed, or after restarting when dependencies were added. 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é après rechargement si aucun paquet supplémentaire n'est nécessaire, ou après redémarrage si des dépendances ont été ajoutées. Une nouvelle version d'un plugin déjà chargé est utilisée au prochain démarrage"
msgid "The new **Available plugins** tab lists the plugins of the [DataLab plugin catalog](https://datalab-platform.com/plugins/), official or from the community, with a search field: **Install** and **Update** download the wheel, check it against the catalog and install it after confirmation. The catalog address may be changed to use a private copy"
msgstr "Le nouvel onglet **Plugins disponibles** liste les plugins du [catalogue de plugins DataLab](https://datalab-platform.com/plugins/), officiels ou issus de la communauté, avec un champ de recherche : **Installer** et **Mettre à jour** téléchargent le wheel, le vérifient par rapport au catalogue et l'installent après confirmation. L'adresse du catalogue peut être modifiée pour utiliser une copie privée"
@@ -129,6 +141,9 @@ msgstr "Les organisations peuvent diffuser leurs propres plugins, ou les version
msgid "A local catalog folder may simply hold plugin wheels, listed as they are copied to it, or a `catalog.json` file giving display names, deprecations and withdrawn releases; `datalab-plugin catalog build` writes this file from the wheels of the folder, keeping the edited fields, and `datalab-plugin catalog check` reports wheels that are missing, changed, refused by DataLab or not listed"
msgstr "Un dossier de catalogue local peut simplement contenir des wheels de plugins, listées dès qu'elles y sont copiées, ou un fichier `catalog.json` indiquant les noms d'affichage, les dépréciations et les versions retirées ; `datalab-plugin catalog build` écrit ce fichier à partir des wheels du dossier en conservant les champs modifiés, et `datalab-plugin catalog check` signale les wheels manquantes, modifiées, refusées par DataLab ou non listées"
+msgid "Authors can prepare an offline catalog with `datalab-plugin catalog prepare`: it downloads the additional dependency wheels needed by the reference host without installing them, then writes the catalog. The complete folder can be distributed on a network share or copied to computers without Internet access"
+msgstr "Les auteurs peuvent préparer un catalogue hors ligne avec `datalab-plugin catalog prepare` : la commande télécharge les paquets de dépendances supplémentaires nécessaires à l'environnement hôte de référence sans les installer, puis écrit le catalogue. Le dossier complet peut être distribué sur un partage réseau ou copié sur des ordinateurs sans accès à Internet"
+
msgid "A catalog that cannot be read, such as a network share that is not connected or the online catalog on a computer without Internet access, is reported in the **Available plugins** tab without hiding the plugins of the other catalogs"
msgstr "Un catalogue illisible, comme un partage réseau non connecté ou le catalogue en ligne sur un ordinateur sans accès à Internet, est signalé dans l'onglet **Plugins disponibles** sans masquer les plugins des autres catalogues"