From 296f73b98c7f0f426ce45c22ce3374608cf4893a Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:16:43 +0200 Subject: [PATCH] fix(plugins): refuse wheels built for another Python version Assisted-by: Claude Opus 5.5 --- .../dlplugins/datalab/plugins/wheels.py | 46 +++++++++++++++++-- tests/python/test_wheel_inspection.py | 20 ++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/runtime/dlplugins/datalab/plugins/wheels.py b/src/runtime/dlplugins/datalab/plugins/wheels.py index 90421eb..621206c 100644 --- a/src/runtime/dlplugins/datalab/plugins/wheels.py +++ b/src/runtime/dlplugins/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/tests/python/test_wheel_inspection.py b/tests/python/test_wheel_inspection.py index eea30f6..ae0c284 100644 --- a/tests/python/test_wheel_inspection.py +++ b/tests/python/test_wheel_inspection.py @@ -117,6 +117,26 @@ def test_inspection_rejects_unsafe_payloads( inspect(make_wheel(top_level=top_level, extra_files=extra_files)) +def test_inspection_rejects_wheel_built_for_another_python() -> None: + """Pyodide's Python must be able to install the wheel's interpreter tag.""" + filename = FILENAME.replace("py3-none-any", "cp313-none-any") + data = make_wheel() + + with pytest.raises(WheelInspectionError, match="built for cp313"): + inspect_wheel( + data, + filename=filename, + available_distributions={"sigima": "1.2.0"}, + python_version="3.12", + ) + assert inspect_wheel( + data, + filename=filename, + available_distributions={"sigima": "1.2.0"}, + python_version="3.13", + )["tags"] == ["cp313-none-any"] + + def test_inspection_rejects_missing_host_dependency() -> None: with pytest.raises(WheelInspectionError, match="not provided by DataLab-Web"): inspect_wheel(