diff --git a/datalab/gui/plugins/available.py b/datalab/gui/plugins/available.py
index db66b5a0a..afafe05eb 100644
--- a/datalab/gui/plugins/available.py
+++ b/datalab/gui/plugins/available.py
@@ -10,6 +10,7 @@
from __future__ import annotations
import shutil
+import sys
import tempfile
from collections.abc import Callable
from html import escape
@@ -135,6 +136,9 @@ def _describe(
if plugin.status == "revoked":
return _("Withdrawn: %s") % plugin.status_reason, None
if release is None:
+ if plugin.get_installable_release(python_version="") is not None:
+ python = f"{sys.version_info.major}.{sys.version_info.minor}"
+ return _("Not available for Python %s") % python, None
return _("Not available for DataLab desktop"), None
how, version = installed
if how == "environment":
diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po
index b3ae483e1..0ffbc70db 100644
--- a/datalab/locale/fr/LC_MESSAGES/datalab.po
+++ b/datalab/locale/fr/LC_MESSAGES/datalab.po
@@ -2298,6 +2298,10 @@ msgstr "Retiré : %s"
msgid "Not available for DataLab desktop"
msgstr "Non disponible pour DataLab desktop"
+#, python-format
+msgid "Not available for Python %s"
+msgstr "Non disponible pour Python %s"
+
#, python-format
msgid "Installed in the Python environment (%s)"
msgstr "Installé dans l'environnement Python (%s)"
diff --git a/datalab/plugins/catalog.py b/datalab/plugins/catalog.py
index d7625571e..9f3fb9c27 100644
--- a/datalab/plugins/catalog.py
+++ b/datalab/plugins/catalog.py
@@ -21,7 +21,7 @@
import urllib.request
from datalab import __version__
-from datalab.plugins.wheels import MAX_WHEEL_BYTES
+from datalab.plugins.wheels import MAX_WHEEL_BYTES, is_compatible_wheel
DEFAULT_CATALOG_URL = "https://datalab-platform.com/plugins/catalog.json"
CATALOG_SCHEMA_VERSION = 1
@@ -49,6 +49,7 @@ class CatalogRelease:
size: Wheel size in bytes
targets: Hosts supported by the wheel (``desktop``, ``web``)
yanked: Reason why the release should not be installed, if any
+ requires_python: ``Requires-Python`` metadata of the wheel, if any
"""
version: str
@@ -58,6 +59,7 @@ class CatalogRelease:
size: int = 0
targets: tuple[str, ...] = ()
yanked: str = ""
+ requires_python: str | None = None
@dataclasses.dataclass(frozen=True)
@@ -76,15 +78,31 @@ class CatalogPlugin:
license: str = ""
keywords: tuple[str, ...] = ()
- def get_installable_release(self, target: str = "desktop") -> CatalogRelease | None:
- """Return the newest release that may be installed on a host."""
+ def get_installable_release(
+ self, target: str = "desktop", python_version: str | None = None
+ ) -> CatalogRelease | None:
+ """Return the newest release that may be installed on a host.
+
+ Args:
+ target: Host (``desktop`` or ``web``)
+ python_version: Host Python version, ignored if empty (default:
+ running interpreter)
+ """
if self.status == "revoked":
return None
return next(
(
release
for release in self.releases
- if release.url and not release.yanked and target in release.targets
+ if release.url
+ and not release.yanked
+ and target in release.targets
+ and (
+ python_version == ""
+ or is_compatible_wheel(
+ release.filename, release.requires_python, python_version
+ )
+ )
),
None,
)
@@ -127,6 +145,7 @@ def _parse_release(item: dict, catalog_url: str) -> CatalogRelease:
size=int(item.get("size", 0)),
targets=tuple(item.get("targets", ())),
yanked=item.get("yanked", ""),
+ requires_python=item.get("requires_python"),
)
diff --git a/datalab/plugins/wheels.py b/datalab/plugins/wheels.py
index 90421eb3d..621206c60 100644
--- a/datalab/plugins/wheels.py
+++ b/datalab/plugins/wheels.py
@@ -27,6 +27,7 @@
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.utils import canonicalize_name, parse_wheel_filename
from packaging.version import InvalidVersion, Version
@@ -72,6 +73,40 @@ class WheelInspectionError(ValueError):
"""Raised when an archive cannot be accepted as a plugin wheel."""
+def _host_python(python_version: str | None) -> Version:
+ return Version(
+ python_version or f"{sys.version_info.major}.{sys.version_info.minor}"
+ )
+
+
+def _pure_python_tags(python: Version) -> frozenset[Tag]:
+ """Return the pure-Python wheel tags a Python version installs, as pip does."""
+ interpreter = f"{interpreter_name()}{python.major}{python.minor}"
+ return frozenset(
+ compatible_tags((python.major, python.minor), interpreter, ["any"])
+ )
+
+
+def is_compatible_wheel(
+ filename: str, requires_python: str | None, python_version: str | None = None
+) -> bool:
+ """Return True if a Python version can install a pure-Python wheel.
+
+ Args:
+ filename: Wheel file name, which carries its tags
+ requires_python: ``Requires-Python`` metadata of the wheel, if any
+ python_version: Host Python version (default: running interpreter)
+ """
+ try:
+ wheel_tags = parse_wheel_filename(filename)[3]
+ python = _host_python(python_version)
+ if requires_python and python not in SpecifierSet(requires_python):
+ return False
+ except (InvalidSpecifier, InvalidVersion, ValueError):
+ return False
+ return bool(wheel_tags & _pure_python_tags(python))
+
+
def _is_stdlib_module(name: str) -> bool:
stdlib_names = getattr(sys, "stdlib_module_names", None)
if stdlib_names is not None:
@@ -310,6 +345,13 @@ def inspect_wheel(
raise WheelInspectionError(f"Invalid wheel filename: {filename!r}") from exc
if 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))
@@ -350,9 +392,6 @@ def inspect_wheel(
raise WheelInspectionError("Wheel filename and METADATA version differ")
required_python = package_metadata.get("Requires-Python")
- current_python = Version(
- python_version or f"{sys.version_info.major}.{sys.version_info.minor}"
- )
if required_python:
try:
specifier = SpecifierSet(required_python)
@@ -401,4 +440,5 @@ def inspect_wheel(
"WEB_ENTRY_POINT_GROUP",
"WheelInspectionError",
"inspect_wheel",
+ "is_compatible_wheel",
]
diff --git a/datalab/tests/backbone/plugins/plugin_catalog_unit_test.py b/datalab/tests/backbone/plugins/plugin_catalog_unit_test.py
index 3a2f19d55..25e9f6b1d 100644
--- a/datalab/tests/backbone/plugins/plugin_catalog_unit_test.py
+++ b/datalab/tests/backbone/plugins/plugin_catalog_unit_test.py
@@ -107,6 +107,23 @@ def test_installable_release_skips_yanked_web_only_and_revoked(tmp_path: Path) -
assert plugin.matches("SPECTRO baseline") and not plugin.matches("camera")
+def test_installable_release_suits_the_host_python(tmp_path: Path) -> None:
+ """Releases requiring another Python, by metadata or by tag, are skipped."""
+ newest = publish(tmp_path, b"3", "3.0.0", requires_python=">=3.12")
+ tagged = publish(tmp_path, b"2", "2.0.0")
+ tagged["filename"] = tagged["filename"].replace("py3-none", "cp313-none")
+ oldest = publish(tmp_path, b"1", "1.0.0", requires_python=">=3.9")
+ url = write_catalog(tmp_path, [plugin_record([newest, tagged, oldest])])
+
+ (plugin,) = fetch_catalog(url)
+
+ assert plugin.get_installable_release(python_version="3.13").version == "3.0.0"
+ assert plugin.get_installable_release(python_version="3.12").version == "3.0.0"
+ assert plugin.get_installable_release(python_version="3.11").version == "1.0.0"
+ assert plugin.get_installable_release(python_version="3.8") is None
+ assert plugin.get_installable_release(python_version="").version == "3.0.0"
+
+
def test_downloads_must_match_the_catalog(tmp_path: Path) -> None:
"""A wheel changed after publication is refused."""
release = publish(tmp_path, b"original")
diff --git a/datalab/tests/backbone/plugins/plugin_wheels_unit_test.py b/datalab/tests/backbone/plugins/plugin_wheels_unit_test.py
index dd9a88a17..37164bb5d 100644
--- a/datalab/tests/backbone/plugins/plugin_wheels_unit_test.py
+++ b/datalab/tests/backbone/plugins/plugin_wheels_unit_test.py
@@ -14,6 +14,7 @@
WEB_ENTRY_POINT_GROUP,
WheelInspectionError,
inspect_wheel,
+ is_compatible_wheel,
)
from datalab.tests.backbone.plugins.wheel_factory import (
make_plugin_wheel,
@@ -131,6 +132,32 @@ def test_inspection_checks_python_requirement() -> None:
inspect(make_plugin_wheel(requires_python=">=3.12"))
+@pytest.mark.parametrize(
+ ("tag", "python_version"),
+ [("cp313", "3.9"), ("py312", "3.11"), ("py2", "3.11"), ("cp39", "3.11")],
+)
+def test_inspection_rejects_wheels_built_for_another_python(
+ tag: str, python_version: str
+) -> None:
+ """Pure-Python tags must target the host Python, even without Requires-Python."""
+ filename = FILENAME.replace("py3-none-any", f"{tag}-none-any")
+ data = make_plugin_wheel(requires_python=">=2.7")
+
+ with pytest.raises(WheelInspectionError, match=f"built for {tag}, host has"):
+ inspect(data, filename=filename, python_version=python_version)
+ assert not is_compatible_wheel(filename, None, python_version)
+
+
+@pytest.mark.parametrize("tag", ["py3", "py2.py3", "py39", "py311", "cp311"])
+def test_inspection_accepts_wheels_for_the_host_python(tag: str) -> None:
+ """Generic and host-specific pure-Python tags are accepted, as by pip."""
+ filename = FILENAME.replace("py3-none-any", f"{tag}-none-any")
+
+ assert inspect(make_plugin_wheel(), filename=filename)["tags"]
+ assert is_compatible_wheel(filename, ">=3.9", "3.11")
+ assert not is_compatible_wheel(filename, ">=3.12", "3.11")
+
+
def test_inspection_rejects_duplicate_dist_info_directories() -> None:
"""A wheel carries the metadata of a single distribution."""
data = make_plugin_wheel(
diff --git a/datalab/tests/features/plugins/plugin_available_dialog_test.py b/datalab/tests/features/plugins/plugin_available_dialog_test.py
index d6cda94df..fee536421 100644
--- a/datalab/tests/features/plugins/plugin_available_dialog_test.py
+++ b/datalab/tests/features/plugins/plugin_available_dialog_test.py
@@ -50,16 +50,20 @@ def fixture_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
del sys.modules[name]
-def publish_catalog(site: Path, versions: tuple[str, ...]) -> str:
+def publish_catalog(
+ site: Path, versions: tuple[str, ...], requires_python: dict[str, str] | None = None
+) -> str:
"""Publish probe plugin releases in a catalog folder and return its URL."""
releases = []
for version in sorted(versions, reverse=True):
+ python = (requires_python or {}).get(version, ">=3.9")
data = make_plugin_wheel(
distribution=DISTRIBUTION,
version=version,
package=PACKAGE,
plugin_id=PLUGIN_ID,
requires_dist=("datalab-platform>=1.0",),
+ requires_python=python,
)
digest = hashlib.sha256(data).hexdigest()
filename = wheel_filename(DISTRIBUTION, version)
@@ -73,6 +77,7 @@ def publish_catalog(site: Path, versions: tuple[str, ...]) -> str:
"url": f"wheels/{digest}/{filename}",
"sha256": digest,
"size": len(data),
+ "requires_python": python,
"targets": ["desktop"],
}
)
@@ -149,6 +154,17 @@ def test_catalog_plugin_is_installed_then_offered_as_update(
(row,) = available.item_widgets
assert row.action_button.text() == "Update to 1.1.0"
+ future = {"2.0.0": ">=4"}
+ Conf.plugins_catalog_url.set(publish_catalog(site, ("1.1.0", "2.0.0"), future))
+ available.refresh()
+ (row,) = available.item_widgets
+ assert row.action_button.text() == "Update to 1.1.0"
+ Conf.plugins_catalog_url.set(publish_catalog(site, ("2.0.0",), future))
+ available.refresh()
+ (row,) = available.item_widgets
+ assert row.action_button is None
+ assert row.status_label.text().startswith("Not available for Python 3.")
+
Conf.plugins_catalog_url.set((tmp_path / "missing.json").as_uri())
available.refresh()
assert "cannot be loaded" in available.status_label.text()
diff --git a/doc/features/advanced/plugins.rst b/doc/features/advanced/plugins.rst
index 196604d3b..110ebbaae 100644
--- a/doc/features/advanced/plugins.rst
+++ b/doc/features/advanced/plugins.rst
@@ -196,7 +196,7 @@ Available plugins
This tab lists the plugins of the `DataLab plugin catalog `_, with their description, license and source code link. Plugins maintained by the DataLab team are marked **Official**; the others are **Community** plugins, maintained by their authors. A search field filters the list.
-**Install** downloads the newest release available for DataLab desktop, checks that its size and SHA-256 digest match the catalog, then installs it as described in :ref:`plugin_install_from_file`, after the same confirmation. When the catalog offers a newer version of a plugin installed this way, the button becomes **Update to...**. Plugins installed with ``pip`` in the Python environment are shown as such and are not modified.
+**Install** downloads the newest release available for DataLab desktop and for the running Python version, checks that its size and SHA-256 digest match the catalog, then installs it as described in :ref:`plugin_install_from_file`, after the same confirmation. When the catalog offers a newer version of a plugin installed this way, the button becomes **Update to...**. Plugins installed with ``pip`` in the Python environment are shown as such and are not modified.
The catalog is downloaded only when the tab is shown or refreshed. Its address is the ``plugins_catalog_url`` option of the DataLab configuration file: it may point to a private copy of the catalog, including a ``file:`` URL for a folder shared on the network, or be emptied to disable the catalog.
diff --git a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po
index 946be1441..50bc98c1e 100644
--- a/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po
+++ b/doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po
@@ -160,8 +160,8 @@ msgstr "Plugins disponibles"
msgid "This tab lists the plugins of the `DataLab plugin catalog `_, with their description, license and source code link. Plugins maintained by the DataLab team are marked **Official**; the others are **Community** plugins, maintained by their authors. A search field filters the list."
msgstr "Cet onglet liste les plugins du `catalogue de plugins DataLab `_, avec leur description, leur licence et un lien vers leur code source. Les plugins maintenus par l'équipe DataLab sont marqués **Officiel** ; les autres sont des plugins de la **Communauté**, maintenus par leurs auteurs. Un champ de recherche filtre la liste."
-msgid "**Install** downloads the newest release available for DataLab desktop, checks that its size and SHA-256 digest match the catalog, then installs it as described in :ref:`plugin_install_from_file`, after the same confirmation. When the catalog offers a newer version of a plugin installed this way, the button becomes **Update to...**. Plugins installed with ``pip`` in the Python environment are shown as such and are not modified."
-msgstr "**Installer** télécharge la version la plus récente disponible pour DataLab desktop, vérifie que sa taille et son empreinte SHA-256 correspondent au catalogue, puis l'installe comme décrit dans :ref:`plugin_install_from_file`, après la même confirmation. Lorsque le catalogue propose une version plus récente d'un plugin installé ainsi, le bouton devient **Mettre à jour vers...**. Les plugins installés avec ``pip`` dans l'environnement Python sont signalés comme tels et ne sont pas modifiés."
+msgid "**Install** downloads the newest release available for DataLab desktop and for the running Python version, checks that its size and SHA-256 digest match the catalog, then installs it as described in :ref:`plugin_install_from_file`, after the same confirmation. When the catalog offers a newer version of a plugin installed this way, the button becomes **Update to...**. Plugins installed with ``pip`` in the Python environment are shown as such and are not modified."
+msgstr "**Installer** télécharge la version la plus récente disponible pour DataLab desktop et pour la version de Python utilisée, vérifie que sa taille et son empreinte SHA-256 correspondent au catalogue, puis l'installe comme décrit dans :ref:`plugin_install_from_file`, après la même confirmation. Lorsque le catalogue propose une version plus récente d'un plugin installé ainsi, le bouton devient **Mettre à jour vers...**. Les plugins installés avec ``pip`` dans l'environnement Python sont signalés comme tels et ne sont pas modifiés."
msgid "The catalog is downloaded only when the tab is shown or refreshed. Its address is the ``plugins_catalog_url`` option of the DataLab configuration file: it may point to a private copy of the catalog, including a ``file:`` URL for a folder shared on the network, or be emptied to disable the catalog."
msgstr "Le catalogue n'est téléchargé que lorsque l'onglet est affiché ou actualisé. Son adresse est l'option ``plugins_catalog_url`` du fichier de configuration de DataLab : elle peut désigner une copie privée du catalogue, y compris une URL ``file:`` pour un dossier partagé sur le réseau, ou être vidée pour désactiver le catalogue."