Skip to content

Commit 6914384

Browse files
authored
Make WHEEL file errors more explicit (#7529)
* Raise exception on exception in finding wheel dist We plan to replace this code with direct extraction from a zip, so no point catching anything more precise. * Raise exception if no dist is found in wheel_version * Catch file read errors when reading WHEEL get_metadata delegates to the underlying implementation which tries to locate and read the file, throwing an IOError (Python 2) or OSError subclass on any errors. Since the new explicit test checks the same case as brokenwheel in test_wheel_version we remove the redundant test. * Check for WHEEL decoding errors explicitly This was the last error that could be thrown by get_metadata, so we can also remove the catch-all except block. * Move WHEEL parsing outside try...except This API does not raise an exception, but returns any errors on the message object itself. We are preserving the original behavior, and can decide later whether to start warning or raising our own exception. * Raise explicit error if Wheel-Version is missing `email.message.Message.__getitem__` returns None on missing values, so we have to check for ourselves explicitly. * Raise explicit exception on failure to parse Wheel-Version This is also the last exception that can be raised, so we remove `except Exception`. * Remove dead code Since wheel_version never returns None, this exception will never be raised.
1 parent ea7bbff commit 6914384

File tree

3 files changed

+114
-21
lines changed

3 files changed

+114
-21
lines changed

src/pip/_internal/operations/install/wheel.py

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,13 @@ def install_unpacked_wheel(
324324
# TODO: Look into moving this into a dedicated class for representing an
325325
# installation.
326326

327-
version = wheel_version(wheeldir)
327+
try:
328+
version = wheel_version(wheeldir)
329+
except UnsupportedWheel as e:
330+
raise UnsupportedWheel(
331+
"{} has an invalid wheel, {}".format(name, str(e))
332+
)
333+
328334
check_compatibility(version, name)
329335

330336
if root_is_purelib(name, wheeldir):
@@ -651,25 +657,48 @@ def install_wheel(
651657

652658

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

661-
wheel_data = dist.get_metadata('WHEEL')
662-
wheel_data = Parser().parsestr(wheel_data)
671+
if not dists:
672+
raise UnsupportedWheel("no contained distribution found")
663673

664-
version = wheel_data['Wheel-Version'].strip()
665-
version = tuple(map(int, version.split('.')))
666-
return version
667-
except Exception:
668-
return None
674+
dist = dists[0]
675+
676+
try:
677+
wheel_text = dist.get_metadata('WHEEL')
678+
except (IOError, OSError) as e:
679+
raise UnsupportedWheel("could not read WHEEL file: {!r}".format(e))
680+
except UnicodeDecodeError as e:
681+
raise UnsupportedWheel("error decoding WHEEL: {!r}".format(e))
682+
683+
# FeedParser (used by Parser) does not raise any exceptions. The returned
684+
# message may have .defects populated, but for backwards-compatibility we
685+
# currently ignore them.
686+
wheel_data = Parser().parsestr(wheel_text)
687+
688+
version_text = wheel_data["Wheel-Version"]
689+
if version_text is None:
690+
raise UnsupportedWheel("WHEEL is missing Wheel-Version")
691+
692+
version = version_text.strip()
693+
694+
try:
695+
return tuple(map(int, version.split('.')))
696+
except ValueError:
697+
raise UnsupportedWheel("invalid Wheel-Version: {!r}".format(version))
669698

670699

671700
def check_compatibility(version, name):
672-
# type: (Optional[Tuple[int, ...]], str) -> None
701+
# type: (Tuple[int, ...], str) -> None
673702
"""Raises errors or warns if called with an incompatible Wheel-Version.
674703
675704
Pip should refuse to install a Wheel-Version that's a major series
@@ -681,10 +710,6 @@ def check_compatibility(version, name):
681710
682711
:raises UnsupportedWheel: when an incompatible Wheel-Version is given
683712
"""
684-
if not version:
685-
raise UnsupportedWheel(
686-
"%s is in an unsupported or invalid wheel" % name
687-
)
688713
if version[0] > VERSION_COMPATIBLE[0]:
689714
raise UnsupportedWheel(
690715
"%s's Wheel-Version (%s) is not compatible with this version "

tests/lib/path.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,11 @@ def read_bytes(self):
183183
with open(self, "rb") as fp:
184184
return fp.read()
185185

186+
def write_bytes(self, content):
187+
# type: (bytes) -> None
188+
with open(self, "wb") as f:
189+
f.write(content)
190+
186191
def read_text(self):
187192
with open(self, "r") as fp:
188193
return fp.read()

tests/unit/test_wheel.py

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
import textwrap
66

77
import pytest
8-
from mock import patch
8+
from mock import Mock, patch
9+
from pip._vendor import pkg_resources
910
from pip._vendor.packaging.requirements import Requirement
1011

1112
from pip._internal.exceptions import UnsupportedWheel
@@ -22,7 +23,7 @@
2223
from pip._internal.utils.compat import WINDOWS
2324
from pip._internal.utils.misc import hash_file
2425
from pip._internal.utils.unpacking import unpack_file
25-
from tests.lib import DATA_DIR, assert_paths_equal
26+
from tests.lib import DATA_DIR, assert_paths_equal, skip_if_python2
2627

2728

2829
def call_get_legacy_build_wheel_path(caplog, names):
@@ -191,14 +192,76 @@ def test_get_csv_rows_for_installed__long_lines(tmpdir, caplog):
191192

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

197197
unpack_file(data.packages.joinpath(future_wheel), tmpdir + 'future')
198-
unpack_file(data.packages.joinpath(broken_wheel), tmpdir + 'broken')
199198

200199
assert wheel.wheel_version(tmpdir + 'future') == future_version
201-
assert not wheel.wheel_version(tmpdir + 'broken')
200+
201+
202+
def test_wheel_version_fails_on_error(monkeypatch):
203+
err = RuntimeError("example")
204+
monkeypatch.setattr(pkg_resources, "find_on_path", Mock(side_effect=err))
205+
with pytest.raises(UnsupportedWheel) as e:
206+
wheel.wheel_version(".")
207+
assert repr(err) in str(e.value)
208+
209+
210+
def test_wheel_version_fails_no_dists(tmpdir):
211+
with pytest.raises(UnsupportedWheel) as e:
212+
wheel.wheel_version(str(tmpdir))
213+
assert "no contained distribution found" in str(e.value)
214+
215+
216+
def test_wheel_version_fails_missing_wheel(tmpdir):
217+
dist_info_dir = tmpdir / "simple-0.1.0.dist-info"
218+
dist_info_dir.mkdir()
219+
dist_info_dir.joinpath("METADATA").touch()
220+
221+
with pytest.raises(UnsupportedWheel) as e:
222+
wheel.wheel_version(str(tmpdir))
223+
assert "could not read WHEEL file" in str(e.value)
224+
225+
226+
@skip_if_python2
227+
def test_wheel_version_fails_on_bad_encoding(tmpdir):
228+
dist_info_dir = tmpdir / "simple-0.1.0.dist-info"
229+
dist_info_dir.mkdir()
230+
dist_info_dir.joinpath("METADATA").touch()
231+
dist_info_dir.joinpath("WHEEL").write_bytes(b"\xff")
232+
233+
with pytest.raises(UnsupportedWheel) as e:
234+
wheel.wheel_version(str(tmpdir))
235+
assert "error decoding WHEEL" in str(e.value)
236+
237+
238+
def test_wheel_version_fails_on_no_wheel_version(tmpdir):
239+
dist_info_dir = tmpdir / "simple-0.1.0.dist-info"
240+
dist_info_dir.mkdir()
241+
dist_info_dir.joinpath("METADATA").touch()
242+
dist_info_dir.joinpath("WHEEL").touch()
243+
244+
with pytest.raises(UnsupportedWheel) as e:
245+
wheel.wheel_version(str(tmpdir))
246+
assert "missing Wheel-Version" in str(e.value)
247+
248+
249+
@pytest.mark.parametrize("version", [
250+
("",),
251+
("1.b",),
252+
("1.",),
253+
])
254+
def test_wheel_version_fails_on_bad_wheel_version(tmpdir, version):
255+
dist_info_dir = tmpdir / "simple-0.1.0.dist-info"
256+
dist_info_dir.mkdir()
257+
dist_info_dir.joinpath("METADATA").touch()
258+
dist_info_dir.joinpath("WHEEL").write_text(
259+
"Wheel-Version: {}".format(version)
260+
)
261+
262+
with pytest.raises(UnsupportedWheel) as e:
263+
wheel.wheel_version(str(tmpdir))
264+
assert "invalid Wheel-Version" in str(e.value)
202265

203266

204267
def test_check_compatibility():

0 commit comments

Comments
 (0)