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."