Skip to content

Make WHEEL file errors more explicit #7529

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Dec 31, 2019
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
57 changes: 41 additions & 16 deletions src/pip/_internal/operations/install/wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,13 @@ def install_unpacked_wheel(
# TODO: Look into moving this into a dedicated class for representing an
# installation.

version = wheel_version(wheeldir)
try:
version = wheel_version(wheeldir)
except UnsupportedWheel as e:
raise UnsupportedWheel(
"{} has an invalid wheel, {}".format(name, str(e))
)

check_compatibility(version, name)

if root_is_purelib(name, wheeldir):
Expand Down Expand Up @@ -651,25 +657,48 @@ def install_wheel(


def wheel_version(source_dir):
# type: (Optional[str]) -> Optional[Tuple[int, ...]]
# type: (Optional[str]) -> Tuple[int, ...]
"""Return the Wheel-Version of an extracted wheel, if possible.
Otherwise, return None if we couldn't parse / extract it.
Otherwise, raise UnsupportedWheel if we couldn't parse / extract it.
"""
try:
dist = [d for d in pkg_resources.find_on_path(None, source_dir)][0]
dists = [d for d in pkg_resources.find_on_path(None, source_dir)]
except Exception as e:
raise UnsupportedWheel(
"could not find a contained distribution due to: {!r}".format(e)
)

wheel_data = dist.get_metadata('WHEEL')
wheel_data = Parser().parsestr(wheel_data)
if not dists:
raise UnsupportedWheel("no contained distribution found")

version = wheel_data['Wheel-Version'].strip()
version = tuple(map(int, version.split('.')))
return version
except Exception:
return None
dist = dists[0]

try:
wheel_text = dist.get_metadata('WHEEL')
except (IOError, OSError) as e:
raise UnsupportedWheel("could not read WHEEL file: {!r}".format(e))
except UnicodeDecodeError as e:
raise UnsupportedWheel("error decoding WHEEL: {!r}".format(e))

# FeedParser (used by Parser) does not raise any exceptions. The returned
# message may have .defects populated, but for backwards-compatibility we
# currently ignore them.
wheel_data = Parser().parsestr(wheel_text)

version_text = wheel_data["Wheel-Version"]
if version_text is None:
raise UnsupportedWheel("WHEEL is missing Wheel-Version")

version = version_text.strip()

try:
return tuple(map(int, version.split('.')))
except ValueError:
raise UnsupportedWheel("invalid Wheel-Version: {!r}".format(version))


def check_compatibility(version, name):
# type: (Optional[Tuple[int, ...]], str) -> None
# type: (Tuple[int, ...], str) -> None
"""Raises errors or warns if called with an incompatible Wheel-Version.

Pip should refuse to install a Wheel-Version that's a major series
Expand All @@ -681,10 +710,6 @@ def check_compatibility(version, name):

:raises UnsupportedWheel: when an incompatible Wheel-Version is given
"""
if not version:
raise UnsupportedWheel(
"%s is in an unsupported or invalid wheel" % name
)
if version[0] > VERSION_COMPATIBLE[0]:
raise UnsupportedWheel(
"%s's Wheel-Version (%s) is not compatible with this version "
Expand Down
5 changes: 5 additions & 0 deletions tests/lib/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,11 @@ def read_bytes(self):
with open(self, "rb") as fp:
return fp.read()

def write_bytes(self, content):
# type: (bytes) -> None
with open(self, "wb") as f:
f.write(content)

def read_text(self):
with open(self, "r") as fp:
return fp.read()
Expand Down
73 changes: 68 additions & 5 deletions tests/unit/test_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
import textwrap

import pytest
from mock import patch
from mock import Mock, patch
from pip._vendor import pkg_resources
from pip._vendor.packaging.requirements import Requirement

from pip._internal.exceptions import UnsupportedWheel
Expand All @@ -22,7 +23,7 @@
from pip._internal.utils.compat import WINDOWS
from pip._internal.utils.misc import hash_file
from pip._internal.utils.unpacking import unpack_file
from tests.lib import DATA_DIR, assert_paths_equal
from tests.lib import DATA_DIR, assert_paths_equal, skip_if_python2


def call_get_legacy_build_wheel_path(caplog, names):
Expand Down Expand Up @@ -191,14 +192,76 @@ def test_get_csv_rows_for_installed__long_lines(tmpdir, caplog):

def test_wheel_version(tmpdir, data):
future_wheel = 'futurewheel-1.9-py2.py3-none-any.whl'
broken_wheel = 'brokenwheel-1.0-py2.py3-none-any.whl'
future_version = (1, 9)

unpack_file(data.packages.joinpath(future_wheel), tmpdir + 'future')
unpack_file(data.packages.joinpath(broken_wheel), tmpdir + 'broken')

assert wheel.wheel_version(tmpdir + 'future') == future_version
assert not wheel.wheel_version(tmpdir + 'broken')


def test_wheel_version_fails_on_error(monkeypatch):
err = RuntimeError("example")
monkeypatch.setattr(pkg_resources, "find_on_path", Mock(side_effect=err))
with pytest.raises(UnsupportedWheel) as e:
wheel.wheel_version(".")
assert repr(err) in str(e.value)


def test_wheel_version_fails_no_dists(tmpdir):
with pytest.raises(UnsupportedWheel) as e:
wheel.wheel_version(str(tmpdir))
assert "no contained distribution found" in str(e.value)


def test_wheel_version_fails_missing_wheel(tmpdir):
dist_info_dir = tmpdir / "simple-0.1.0.dist-info"
dist_info_dir.mkdir()
dist_info_dir.joinpath("METADATA").touch()

with pytest.raises(UnsupportedWheel) as e:
wheel.wheel_version(str(tmpdir))
assert "could not read WHEEL file" in str(e.value)


@skip_if_python2
def test_wheel_version_fails_on_bad_encoding(tmpdir):
dist_info_dir = tmpdir / "simple-0.1.0.dist-info"
dist_info_dir.mkdir()
dist_info_dir.joinpath("METADATA").touch()
dist_info_dir.joinpath("WHEEL").write_bytes(b"\xff")

with pytest.raises(UnsupportedWheel) as e:
wheel.wheel_version(str(tmpdir))
assert "error decoding WHEEL" in str(e.value)


def test_wheel_version_fails_on_no_wheel_version(tmpdir):
dist_info_dir = tmpdir / "simple-0.1.0.dist-info"
dist_info_dir.mkdir()
dist_info_dir.joinpath("METADATA").touch()
dist_info_dir.joinpath("WHEEL").touch()

with pytest.raises(UnsupportedWheel) as e:
wheel.wheel_version(str(tmpdir))
assert "missing Wheel-Version" in str(e.value)


@pytest.mark.parametrize("version", [
("",),
("1.b",),
("1.",),
])
def test_wheel_version_fails_on_bad_wheel_version(tmpdir, version):
dist_info_dir = tmpdir / "simple-0.1.0.dist-info"
dist_info_dir.mkdir()
dist_info_dir.joinpath("METADATA").touch()
dist_info_dir.joinpath("WHEEL").write_text(
"Wheel-Version: {}".format(version)
)

with pytest.raises(UnsupportedWheel) as e:
wheel.wheel_version(str(tmpdir))
assert "invalid Wheel-Version" in str(e.value)


def test_check_compatibility():
Expand Down