Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions datalab/gui/plugins/available.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from __future__ import annotations

import shutil
import sys
import tempfile
from collections.abc import Callable
from html import escape
Expand Down Expand Up @@ -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":
Expand Down
4 changes: 4 additions & 0 deletions datalab/locale/fr/LC_MESSAGES/datalab.po
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
27 changes: 23 additions & 4 deletions datalab/plugins/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -58,6 +59,7 @@ class CatalogRelease:
size: int = 0
targets: tuple[str, ...] = ()
yanked: str = ""
requires_python: str | None = None


@dataclasses.dataclass(frozen=True)
Expand All @@ -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,
)
Expand Down Expand Up @@ -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"),
)


Expand Down
46 changes: 43 additions & 3 deletions datalab/plugins/wheels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -401,4 +440,5 @@ def inspect_wheel(
"WEB_ENTRY_POINT_GROUP",
"WheelInspectionError",
"inspect_wheel",
"is_compatible_wheel",
]
17 changes: 17 additions & 0 deletions datalab/tests/backbone/plugins/plugin_catalog_unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
27 changes: 27 additions & 0 deletions datalab/tests/backbone/plugins/plugin_wheels_unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
18 changes: 17 additions & 1 deletion datalab/tests/features/plugins/plugin_available_dialog_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"],
}
)
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion doc/features/advanced/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ Available plugins

This tab lists the plugins of the `DataLab plugin catalog <https://datalab-platform.com/plugins/>`_, 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.

Expand Down
4 changes: 2 additions & 2 deletions doc/locale/fr/LC_MESSAGES/features/advanced/plugins.po
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,8 @@ msgstr "Plugins disponibles"
msgid "This tab lists the plugins of the `DataLab plugin catalog <https://datalab-platform.com/plugins/>`_, 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 <https://datalab-platform.com/plugins/>`_, 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."
Expand Down
Loading