Skip to content
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

Refactor wheel info extraction during install #7536

Merged
merged 12 commits into from
Jan 1, 2020
Merged
109 changes: 53 additions & 56 deletions src/pip/_internal/operations/install/wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from pip._vendor.distlib.scripts import ScriptMaker
from pip._vendor.distlib.util import get_export_entry
from pip._vendor.packaging.utils import canonicalize_name
from pip._vendor.six import StringIO
from pip._vendor.six import StringIO, ensure_str

from pip._internal.exceptions import InstallationError, UnsupportedWheel
from pip._internal.locations import get_major_minor_version
Expand All @@ -33,6 +33,7 @@
from pip._internal.utils.unpacking import unpack_file

if MYPY_CHECK_RUNNING:
from email.message import Message
from typing import (
Dict, List, Optional, Sequence, Tuple, IO, Text, Any,
Iterable, Callable, Set,
Expand Down Expand Up @@ -97,23 +98,9 @@ def fix_script(path):
return None


dist_info_re = re.compile(r"""^(?P<namever>(?P<name>.+?)(-(?P<ver>.+?))?)
\.dist-info$""", re.VERBOSE)


def root_is_purelib(name, wheeldir):
# type: (str, str) -> bool
"""True if the extracted wheel in wheeldir should go into purelib."""
name_folded = name.replace("-", "_")
for item in os.listdir(wheeldir):
match = dist_info_re.match(item)
if match and match.group('name') == name_folded:
with open(os.path.join(wheeldir, item, 'WHEEL')) as wheel:
for line in wheel:
line = line.lower().rstrip()
if line == "root-is-purelib: true":
return True
return False
def wheel_root_is_purelib(metadata):
# type: (Message) -> bool
return metadata.get("Root-Is-Purelib", "").lower() == "true"


def get_entrypoints(filename):
Expand Down Expand Up @@ -324,23 +311,25 @@ def install_unpacked_wheel(
# TODO: Look into moving this into a dedicated class for representing an
# installation.

source = wheeldir.rstrip(os.path.sep) + os.path.sep

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

check_compatibility(version, name)

if root_is_purelib(name, wheeldir):
if wheel_root_is_purelib(metadata):
lib_dir = scheme.purelib
else:
lib_dir = scheme.platlib

source = wheeldir.rstrip(os.path.sep) + os.path.sep
subdirs = os.listdir(source)
info_dirs = [s for s in subdirs if s.endswith('.dist-info')]
data_dirs = [s for s in subdirs if s.endswith('.data')]

# Record details of the files moved
Expand Down Expand Up @@ -434,27 +423,6 @@ def clobber(

clobber(source, lib_dir, True)

assert info_dirs, "{} .dist-info directory not found".format(
req_description
)

assert len(info_dirs) == 1, (
'{} multiple .dist-info directories found: {}'.format(
req_description, ', '.join(info_dirs)
)
)

info_dir = info_dirs[0]

info_dir_name = canonicalize_name(info_dir)
canonical_name = canonicalize_name(name)
if not info_dir_name.startswith(canonical_name):
raise UnsupportedWheel(
"{} .dist-info directory {!r} does not start with {!r}".format(
req_description, info_dir, canonical_name
)
)

dest_info_dir = os.path.join(lib_dir, info_dir)

# Get the defined entry points
Expand Down Expand Up @@ -656,25 +624,48 @@ def install_wheel(
)


def wheel_version(source_dir):
# type: (Optional[str]) -> Tuple[int, ...]
"""Return the Wheel-Version of an extracted wheel, if possible.
Otherwise, raise UnsupportedWheel if we couldn't parse / extract it.
def wheel_dist_info_dir(source, name):
# type: (str, str) -> str
"""Returns the name of the contained .dist-info directory.

Raises AssertionError or UnsupportedWheel if not found, >1 found, or
it doesn't match the provided name.
"""
try:
dists = [d for d in pkg_resources.find_on_path(None, source_dir)]
except Exception as e:
subdirs = os.listdir(source)
info_dirs = [s for s in subdirs if s.endswith('.dist-info')]

if not info_dirs:
raise UnsupportedWheel(".dist-info directory not found")

if len(info_dirs) > 1:
raise UnsupportedWheel(
"multiple .dist-info directories found: {}".format(
", ".join(info_dirs)
)
)

info_dir = info_dirs[0]

info_dir_name = canonicalize_name(info_dir)
canonical_name = canonicalize_name(name)
if not info_dir_name.startswith(canonical_name):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (and for a follow-up PR): since we have canonicalized everything we might as well check for an exact match
if not info_dir_name[:-len('.dist-info')] == canonical_name
this would catch a messed-up wheel with canonical_name = foobar and info_dir_name = foobar1234.dist-info.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the .dist-info dir also has version, we would need that too.

raise UnsupportedWheel(
"could not find a contained distribution due to: {!r}".format(e)
".dist-info directory {!r} does not start with {!r}".format(
info_dir, canonical_name
)
)

if not dists:
raise UnsupportedWheel("no contained distribution found")
return info_dir

dist = dists[0]

def wheel_metadata(source, dist_info_dir):
# type: (str, str) -> Message
"""Return the WHEEL metadata of an extracted wheel, if possible.
Otherwise, raise UnsupportedWheel.
"""
try:
wheel_text = dist.get_metadata('WHEEL')
with open(os.path.join(source, dist_info_dir, "WHEEL"), "rb") as f:
wheel_text = ensure_str(f.read())
except (IOError, OSError) as e:
raise UnsupportedWheel("could not read WHEEL file: {!r}".format(e))
except UnicodeDecodeError as e:
Expand All @@ -683,8 +674,14 @@ def wheel_version(source_dir):
# 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)
return Parser().parsestr(wheel_text)


def wheel_version(wheel_data):
# type: (Message) -> Tuple[int, ...]
"""Given WHEEL metadata, return the parsed Wheel-Version.
Otherwise, raise UnsupportedWheel.
"""
version_text = wheel_data["Wheel-Version"]
if version_text is None:
raise UnsupportedWheel("WHEEL is missing Wheel-Version")
Expand Down

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

98 changes: 47 additions & 51 deletions tests/unit/test_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
import logging
import os
import textwrap
from email import message_from_string

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

from pip._internal.exceptions import UnsupportedWheel
Expand Down Expand Up @@ -190,59 +190,64 @@ def test_get_csv_rows_for_installed__long_lines(tmpdir, caplog):
assert messages == expected


def test_wheel_version(tmpdir, data):
future_wheel = 'futurewheel-1.9-py2.py3-none-any.whl'
future_version = (1, 9)
def test_wheel_dist_info_dir_found(tmpdir):
expected = "simple-0.1.dist-info"
tmpdir.joinpath(expected).mkdir()
assert wheel.wheel_dist_info_dir(str(tmpdir), "simple") == expected

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

assert wheel.wheel_version(tmpdir + 'future') == future_version
def test_wheel_dist_info_dir_multiple(tmpdir):
tmpdir.joinpath("simple-0.1.dist-info").mkdir()
tmpdir.joinpath("unrelated-0.1.dist-info").mkdir()
with pytest.raises(UnsupportedWheel) as e:
wheel.wheel_dist_info_dir(str(tmpdir), "simple")
assert "multiple .dist-info directories found" in str(e.value)


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


def test_wheel_version_fails_no_dists(tmpdir):
def test_wheel_dist_info_dir_wrong_name(tmpdir):
tmpdir.joinpath("unrelated-0.1.dist-info").mkdir()
with pytest.raises(UnsupportedWheel) as e:
wheel.wheel_version(str(tmpdir))
assert "no contained distribution found" in str(e.value)
wheel.wheel_dist_info_dir(str(tmpdir), "simple")
assert "does not start with 'simple'" in str(e.value)


def test_wheel_version_ok(tmpdir, data):
assert wheel.wheel_version(
message_from_string("Wheel-Version: 1.9")
) == (1, 9)


def test_wheel_version_fails_missing_wheel(tmpdir):
def test_wheel_metadata_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))
wheel.wheel_metadata(str(tmpdir), dist_info_dir.name)
assert "could not read WHEEL file" in str(e.value)


@skip_if_python2
def test_wheel_version_fails_on_bad_encoding(tmpdir):
def test_wheel_metadata_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))
wheel.wheel_metadata(str(tmpdir), dist_info_dir.name)
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()

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


Expand All @@ -251,19 +256,26 @@ def test_wheel_version_fails_on_no_wheel_version(tmpdir):
("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)
)

def test_wheel_version_fails_on_bad_wheel_version(version):
with pytest.raises(UnsupportedWheel) as e:
wheel.wheel_version(str(tmpdir))
wheel.wheel_version(
message_from_string("Wheel-Version: {}".format(version))
)
assert "invalid Wheel-Version" in str(e.value)


@pytest.mark.parametrize("text,expected", [
("Root-Is-Purelib: true", True),
("Root-Is-Purelib: false", False),
("Root-Is-Purelib: hello", False),
("", False),
("root-is-purelib: true", True),
("root-is-purelib: True", True),
])
def test_wheel_root_is_purelib(text, expected):
assert wheel.wheel_root_is_purelib(message_from_string(text)) == expected


def test_check_compatibility():
name = 'test'
vc = wheel.VERSION_COMPATIBLE
Expand Down Expand Up @@ -296,22 +308,6 @@ def test_unpack_wheel_no_flatten(self, tmpdir):
unpack_file(filepath, tmpdir)
assert os.path.isdir(os.path.join(tmpdir, 'meta-1.0.dist-info'))

def test_purelib_platlib(self, data):
"""
Test the "wheel is purelib/platlib" code.
"""
packages = [
("pure_wheel", data.packages.joinpath("pure_wheel-1.7"), True),
("plat_wheel", data.packages.joinpath("plat_wheel-1.7"), False),
("pure_wheel", data.packages.joinpath(
"pure_wheel-_invalidversion_"), True),
("plat_wheel", data.packages.joinpath(
"plat_wheel-_invalidversion_"), False),
]

for name, path, expected in packages:
assert wheel.root_is_purelib(name, path) == expected


class TestInstallUnpackedWheel(object):
"""
Expand Down