Repository: pypa/setuptools
Pull request #1200 (2017-11-17 16:19:16)

easy_install: add support for installing from wheels
Wheels are installed as eggs, so each install is self-contained and multiple versions of the same package can be installed at the same time.

Limitations:
    - headers are not supported: since they are extracted to the self-contained egg directory, and not copied to the system includes directory
    - resulting egg metadata requirements have their markers stripped: in practice, since eggs' name include the Python version (so using `python2 setup.py ...` followed by `python3 setup.py ...` will work if one of setup_requires' requirement has one or more Python 3 specific dependencies).

Note: `glib.c` and `pep425tags.py` were copied from pip (not wheel, for manylinux support), and modified for Python 2.6 compatibility.

benoit-pierre: I forgot to mention the rational for this PR: with development of PEP 517/518 support in hiatus, it might be a while before there's an alternative for setuptools' setup_requires. But some Python packages are not installable from source (and don't provide eggs), yet can be used for building other packages: for example PyQt5 is only installable from wheel, and provide some tools needed for generating UI files (pyrcc5, pyuic5).


benoit-pierre: Since headers are not correctly supported, this patch could introduce some issues for users that still rely easy_install instead of pip, maybe the main easy_install module/script should be removed?

Alternatively, wheels could be opt-in when using easy_install directly (but always enabled when internally used by setuptools).
jaraco: > Note: glib.c and pep425tags.py were copied from pip (not wheel, for manylinux support), and modified for Python 2.6 compatibility.

I wonder if it makes sense to extract those modules into packages that could then be vendored and shared by both codebases. Otherwise, we'll inevitably end up dealing with the divergence of these two modules (which has already happened). At the very least, I'd like for each module to have a header describing where it's canonical source resides and keep a log of changes against that source).

It's a little ironic that Setuptools has been waiting for pip to drop Python 2.6 support but now that it's potentially adopting pip modules, it has to restore Python 2.6 support. :( I think that's a sign that we should just merge and release the drop-py26 branch. I'll do that.
benoit-pierre: > > Note: glib.c and pep425tags.py were copied from pip (not wheel, for manylinux support), and modified for Python 2.6 compatibility.

> I wonder if it makes sense to extract those modules into packages that could then be vendored and shared by both codebases. Otherwise, we'll inevitably end up dealing with the divergence of these two modules (which has already happened). At the very least, I'd like for each module to have a header describing where it's canonical source resides and keep a log of changes against that source).

As part of packaging maybe? See https://github.com/pypa/packaging/issues/83

> It's a little ironic that Setuptools has been waiting for pip to drop Python 2.6 support but now that it's potentially adopting pip modules, it has to restore Python 2.6 support. :( I think that's a sign that we should just merge and release the drop-py26 branch. I'll do that.

👍 
jaraco: > As part of packaging maybe?

Yes, that sounds like the right solution. I'm okay with vendoring the modules here until they're released with packaging, especially if there's a comment reminding us that that's what's happening.

Also, Python 2.6 support is now dropped.

Patch:
diff --git a/setuptools/command/easy_install.py b/setuptools/command/easy_install.py
index 71991efac..12e223106 100755
--- a/setuptools/command/easy_install.py
+++ b/setuptools/command/easy_install.py
@@ -53,6 +53,7 @@ from setuptools.package_index import (
     PackageIndex, parse_requirement_arg, URL_SCHEME,
 )
 from setuptools.command import bdist_egg, egg_info
+from setuptools.wheel import Wheel
 from pkg_resources import (
     yield_lines, normalize_path, resource_string, ensure_directory,
     get_distribution, find_distributions, Environment, Requirement,
@@ -842,6 +843,8 @@ class easy_install(Command):
             return [self.install_egg(dist_filename, tmpdir)]
         elif dist_filename.lower().endswith('.exe'):
             return [self.install_exe(dist_filename, tmpdir)]
+        elif dist_filename.lower().endswith('.whl'):
+            return [self.install_wheel(dist_filename, tmpdir)]
 
         # Anything else, try to extract and build
         setup_base = tmpdir
@@ -1038,6 +1041,35 @@ class easy_install(Command):
                     f.write('\n'.join(locals()[name]) + '\n')
                     f.close()
 
+    def install_wheel(self, wheel_path, tmpdir):
+        wheel = Wheel(wheel_path)
+        assert wheel.is_compatible()
+        destination = os.path.join(self.install_dir, wheel.egg_name())
+        destination = os.path.abspath(destination)
+        if not self.dry_run:
+            ensure_directory(destination)
+        if os.path.isdir(destination) and not os.path.islink(destination):
+            dir_util.remove_tree(destination, dry_run=self.dry_run)
+        elif os.path.exists(destination):
+            self.execute(
+                os.unlink,
+                (destination,),
+                "Removing " + destination,
+            )
+        try:
+            self.execute(
+                wheel.install_as_egg,
+                (destination,),
+                ("Installing %s to %s") % (
+                    os.path.basename(wheel_path),
+                    os.path.dirname(destination)
+                ),
+            )
+        finally:
+            update_dist_caches(destination, fix_zipimporter_caches=False)
+        self.add_output(destination)
+        return self.egg_distribution(destination)
+
     __mv_warning = textwrap.dedent("""
         Because this distribution was installed --multi-version, before you can
         import modules from this package in an application, you will need to
diff --git a/setuptools/glibc.py b/setuptools/glibc.py
new file mode 100644
index 000000000..a134591c3
--- /dev/null
+++ b/setuptools/glibc.py
@@ -0,0 +1,86 @@
+# This file originally from pip:
+# https://github.com/pypa/pip/blob/8f4f15a5a95d7d5b511ceaee9ed261176c181970/src/pip/_internal/utils/glibc.py
+from __future__ import absolute_import
+
+import ctypes
+import re
+import warnings
+
+
+def glibc_version_string():
+    "Returns glibc version string, or None if not using glibc."
+
+    # ctypes.CDLL(None) internally calls dlopen(NULL), and as the dlopen
+    # manpage says, "If filename is NULL, then the returned handle is for the
+    # main program". This way we can let the linker do the work to figure out
+    # which libc our process is actually using.
+    process_namespace = ctypes.CDLL(None)
+    try:
+        gnu_get_libc_version = process_namespace.gnu_get_libc_version
+    except AttributeError:
+        # Symbol doesn't exist -> therefore, we are not linked to
+        # glibc.
+        return None
+
+    # Call gnu_get_libc_version, which returns a string like "2.5"
+    gnu_get_libc_version.restype = ctypes.c_char_p
+    version_str = gnu_get_libc_version()
+    # py2 / py3 compatibility:
+    if not isinstance(version_str, str):
+        version_str = version_str.decode("ascii")
+
+    return version_str
+
+
+# Separated out from have_compatible_glibc for easier unit testing
+def check_glibc_version(version_str, required_major, minimum_minor):
+    # Parse string and check against requested version.
+    #
+    # We use a regexp instead of str.split because we want to discard any
+    # random junk that might come after the minor version -- this might happen
+    # in patched/forked versions of glibc (e.g. Linaro's version of glibc
+    # uses version strings like "2.20-2014.11"). See gh-3588.
+    m = re.match(r"(?P<major>[0-9]+)\.(?P<minor>[0-9]+)", version_str)
+    if not m:
+        warnings.warn("Expected glibc version with 2 components major.minor,"
+                      " got: %s" % version_str, RuntimeWarning)
+        return False
+    return (int(m.group("major")) == required_major and
+            int(m.group("minor")) >= minimum_minor)
+
+
+def have_compatible_glibc(required_major, minimum_minor):
+    version_str = glibc_version_string()
+    if version_str is None:
+        return False
+    return check_glibc_version(version_str, required_major, minimum_minor)
+
+
+# platform.libc_ver regularly returns completely nonsensical glibc
+# versions. E.g. on my computer, platform says:
+#
+#   ~$ python2.7 -c 'import platform; print(platform.libc_ver())'
+#   ('glibc', '2.7')
+#   ~$ python3.5 -c 'import platform; print(platform.libc_ver())'
+#   ('glibc', '2.9')
+#
+# But the truth is:
+#
+#   ~$ ldd --version
+#   ldd (Debian GLIBC 2.22-11) 2.22
+#
+# This is unfortunate, because it means that the linehaul data on libc
+# versions that was generated by pip 8.1.2 and earlier is useless and
+# misleading. Solution: instead of using platform, use our code that actually
+# works.
+def libc_ver():
+    """Try to determine the glibc version
+
+    Returns a tuple of strings (lib, version) which default to empty strings
+    in case the lookup fails.
+    """
+    glibc_version = glibc_version_string()
+    if glibc_version is None:
+        return ("", "")
+    else:
+        return ("glibc", glibc_version)
diff --git a/setuptools/package_index.py b/setuptools/package_index.py
index fe2ef50fe..ad7433078 100755
--- a/setuptools/package_index.py
+++ b/setuptools/package_index.py
@@ -21,13 +21,14 @@ import setuptools
 from pkg_resources import (
     CHECKOUT_DIST, Distribution, BINARY_DIST, normalize_path, SOURCE_DIST,
     Environment, find_distributions, safe_name, safe_version,
-    to_filename, Requirement, DEVELOP_DIST,
+    to_filename, Requirement, DEVELOP_DIST, EGG_DIST,
 )
 from setuptools import ssl_support
 from distutils import log
 from distutils.errors import DistutilsError
 from fnmatch import translate
 from setuptools.py27compat import get_all_headers
+from setuptools.wheel import Wheel
 
 EGG_FRAGMENT = re.compile(r'^egg=([-A-Za-z0-9_.+!]+)$')
 HREF = re.compile("""href\\s*=\\s*['"]?([^'"> ]+)""", re.I)
@@ -115,6 +116,17 @@ def distros_for_location(location, basename, metadata=None):
     if basename.endswith('.egg') and '-' in basename:
         # only one, unambiguous interpretation
         return [Distribution.from_location(location, basename, metadata)]
+    if basename.endswith('.whl') and '-' in basename:
+        wheel = Wheel(basename)
+        if not wheel.is_compatible():
+            return []
+        return [Distribution(
+            location=location,
+            project_name=wheel.project_name,
+            version=wheel.version,
+            # Increase priority over eggs.
+            precedence=EGG_DIST + 1,
+        )]
     if basename.endswith('.exe'):
         win_base, py_ver, platform = parse_bdist_wininst(basename)
         if win_base is not None:
diff --git a/setuptools/pep425tags.py b/setuptools/pep425tags.py
new file mode 100644
index 000000000..dfe55d587
--- /dev/null
+++ b/setuptools/pep425tags.py
@@ -0,0 +1,316 @@
+# This file originally from pip:
+# https://github.com/pypa/pip/blob/8f4f15a5a95d7d5b511ceaee9ed261176c181970/src/pip/_internal/pep425tags.py
+"""Generate and work with PEP 425 Compatibility Tags."""
+from __future__ import absolute_import
+
+import distutils.util
+import platform
+import re
+import sys
+import sysconfig
+import warnings
+from collections import OrderedDict
+
+from . import glibc
+
+_osx_arch_pat = re.compile(r'(.+)_(\d+)_(\d+)_(.+)')
+
+
+def get_config_var(var):
+    try:
+        return sysconfig.get_config_var(var)
+    except IOError as e:  # Issue #1074
+        warnings.warn("{}".format(e), RuntimeWarning)
+        return None
+
+
+def get_abbr_impl():
+    """Return abbreviated implementation name."""
+    if hasattr(sys, 'pypy_version_info'):
+        pyimpl = 'pp'
+    elif sys.platform.startswith('java'):
+        pyimpl = 'jy'
+    elif sys.platform == 'cli':
+        pyimpl = 'ip'
+    else:
+        pyimpl = 'cp'
+    return pyimpl
+
+
+def get_impl_ver():
+    """Return implementation version."""
+    impl_ver = get_config_var("py_version_nodot")
+    if not impl_ver or get_abbr_impl() == 'pp':
+        impl_ver = ''.join(map(str, get_impl_version_info()))
+    return impl_ver
+
+
+def get_impl_version_info():
+    """Return sys.version_info-like tuple for use in decrementing the minor
+    version."""
+    if get_abbr_impl() == 'pp':
+        # as per https://github.com/pypa/pip/issues/2882
+        return (sys.version_info[0], sys.pypy_version_info.major,
+                sys.pypy_version_info.minor)
+    else:
+        return sys.version_info[0], sys.version_info[1]
+
+
+def get_impl_tag():
+    """
+    Returns the Tag for this specific implementation.
+    """
+    return "{}{}".format(get_abbr_impl(), get_impl_ver())
+
+
+def get_flag(var, fallback, expected=True, warn=True):
+    """Use a fallback method for determining SOABI flags if the needed config
+    var is unset or unavailable."""
+    val = get_config_var(var)
+    if val is None:
+        if warn:
+            warnings.warn("Config variable '{0}' is unset, Python ABI tag may "
+                          "be incorrect".format(var), RuntimeWarning, 2)
+        return fallback()
+    return val == expected
+
+
+def get_abi_tag():
+    """Return the ABI tag based on SOABI (if available) or emulate SOABI
+    (CPython 2, PyPy)."""
+    soabi = get_config_var('SOABI')
+    impl = get_abbr_impl()
+    if not soabi and impl in {'cp', 'pp'} and hasattr(sys, 'maxunicode'):
+        d = ''
+        m = ''
+        u = ''
+        if get_flag('Py_DEBUG',
+                    lambda: hasattr(sys, 'gettotalrefcount'),
+                    warn=(impl == 'cp')):
+            d = 'd'
+        if get_flag('WITH_PYMALLOC',
+                    lambda: impl == 'cp',
+                    warn=(impl == 'cp')):
+            m = 'm'
+        if get_flag('Py_UNICODE_SIZE',
+                    lambda: sys.maxunicode == 0x10ffff,
+                    expected=4,
+                    warn=(impl == 'cp' and
+                          sys.version_info < (3, 3))) \
+                and sys.version_info < (3, 3):
+            u = 'u'
+        abi = '%s%s%s%s%s' % (impl, get_impl_ver(), d, m, u)
+    elif soabi and soabi.startswith('cpython-'):
+        abi = 'cp' + soabi.split('-')[1]
+    elif soabi:
+        abi = soabi.replace('.', '_').replace('-', '_')
+    else:
+        abi = None
+    return abi
+
+
+def _is_running_32bit():
+    return sys.maxsize == 2147483647
+
+
+def get_platform():
+    """Return our platform name 'win32', 'linux_x86_64'"""
+    if sys.platform == 'darwin':
+        # distutils.util.get_platform() returns the release based on the value
+        # of MACOSX_DEPLOYMENT_TARGET on which Python was built, which may
+        # be significantly older than the user's current machine.
+        release, _, machine = platform.mac_ver()
+        split_ver = release.split('.')
+
+        if machine == "x86_64" and _is_running_32bit():
+            machine = "i386"
+        elif machine == "ppc64" and _is_running_32bit():
+            machine = "ppc"
+
+        return 'macosx_{}_{}_{}'.format(split_ver[0], split_ver[1], machine)
+
+    # XXX remove distutils dependency
+    result = distutils.util.get_platform().replace('.', '_').replace('-', '_')
+    if result == "linux_x86_64" and _is_running_32bit():
+        # 32 bit Python program (running on a 64 bit Linux): pip should only
+        # install and run 32 bit compiled extensions in that case.
+        result = "linux_i686"
+
+    return result
+
+
+def is_manylinux1_compatible():
+    # Only Linux, and only x86-64 / i686
+    if get_platform() not in {"linux_x86_64", "linux_i686"}:
+        return False
+
+    # Check for presence of _manylinux module
+    try:
+        import _manylinux
+        return bool(_manylinux.manylinux1_compatible)
+    except (ImportError, AttributeError):
+        # Fall through to heuristic check below
+        pass
+
+    # Check glibc version. CentOS 5 uses glibc 2.5.
+    return glibc.have_compatible_glibc(2, 5)
+
+
+def get_darwin_arches(major, minor, machine):
+    """Return a list of supported arches (including group arches) for
+    the given major, minor and machine architecture of an macOS machine.
+    """
+    arches = []
+
+    def _supports_arch(major, minor, arch):
+        # Looking at the application support for macOS versions in the chart
+        # provided by https://en.wikipedia.org/wiki/OS_X#Versions it appears
+        # our timeline looks roughly like:
+        #
+        # 10.0 - Introduces ppc support.
+        # 10.4 - Introduces ppc64, i386, and x86_64 support, however the ppc64
+        #        and x86_64 support is CLI only, and cannot be used for GUI
+        #        applications.
+        # 10.5 - Extends ppc64 and x86_64 support to cover GUI applications.
+        # 10.6 - Drops support for ppc64
+        # 10.7 - Drops support for ppc
+        #
+        # Given that we do not know if we're installing a CLI or a GUI
+        # application, we must be conservative and assume it might be a GUI
+        # application and behave as if ppc64 and x86_64 support did not occur
+        # until 10.5.
+        #
+        # Note: The above information is taken from the "Application support"
+        #       column in the chart not the "Processor support" since I believe
+        #       that we care about what instruction sets an application can use
+        #       not which processors the OS supports.
+        if arch == 'ppc':
+            return (major, minor) <= (10, 5)
+        if arch == 'ppc64':
+            return (major, minor) == (10, 5)
+        if arch == 'i386':
+            return (major, minor) >= (10, 4)
+        if arch == 'x86_64':
+            return (major, minor) >= (10, 5)
+        if arch in groups:
+            for garch in groups[arch]:
+                if _supports_arch(major, minor, garch):
+                    return True
+        return False
+
+    groups = OrderedDict([
+        ("fat", ("i386", "ppc")),
+        ("intel", ("x86_64", "i386")),
+        ("fat64", ("x86_64", "ppc64")),
+        ("fat32", ("x86_64", "i386", "ppc")),
+    ])
+
+    if _supports_arch(major, minor, machine):
+        arches.append(machine)
+
+    for garch in groups:
+        if machine in groups[garch] and _supports_arch(major, minor, garch):
+            arches.append(garch)
+
+    arches.append('universal')
+
+    return arches
+
+
+def get_supported(versions=None, noarch=False, platform=None,
+                  impl=None, abi=None):
+    """Return a list of supported tags for each version specified in
+    `versions`.
+
+    :param versions: a list of string versions, of the form ["33", "32"],
+        or None. The first version will be assumed to support our ABI.
+    :param platform: specify the exact platform you want valid
+        tags for, or None. If None, use the local system platform.
+    :param impl: specify the exact implementation you want valid
+        tags for, or None. If None, use the local interpreter impl.
+    :param abi: specify the exact abi you want valid
+        tags for, or None. If None, use the local interpreter abi.
+    """
+    supported = []
+
+    # Versions must be given with respect to the preference
+    if versions is None:
+        versions = []
+        version_info = get_impl_version_info()
+        major = version_info[:-1]
+        # Support all previous minor Python versions.
+        for minor in range(version_info[-1], -1, -1):
+            versions.append(''.join(map(str, major + (minor,))))
+
+    impl = impl or get_abbr_impl()
+
+    abis = []
+
+    abi = abi or get_abi_tag()
+    if abi:
+        abis[0:0] = [abi]
+
+    abi3s = set()
+    import imp
+    for suffix in imp.get_suffixes():
+        if suffix[0].startswith('.abi'):
+            abi3s.add(suffix[0].split('.', 2)[1])
+
+    abis.extend(sorted(list(abi3s)))
+
+    abis.append('none')
+
+    if not noarch:
+        arch = platform or get_platform()
+        if arch.startswith('macosx'):
+            # support macosx-10.6-intel on macosx-10.9-x86_64
+            match = _osx_arch_pat.match(arch)
+            if match:
+                name, major, minor, actual_arch = match.groups()
+                tpl = '{}_{}_%i_%s'.format(name, major)
+                arches = []
+                for m in reversed(range(int(minor) + 1)):
+                    for a in get_darwin_arches(int(major), m, actual_arch):
+                        arches.append(tpl % (m, a))
+            else:
+                # arch pattern didn't match (?!)
+                arches = [arch]
+        elif platform is None and is_manylinux1_compatible():
+            arches = [arch.replace('linux', 'manylinux1'), arch]
+        else:
+            arches = [arch]
+
+        # Current version, current API (built specifically for our Python):
+        for abi in abis:
+            for arch in arches:
+                supported.append(('%s%s' % (impl, versions[0]), abi, arch))
+
+        # abi3 modules compatible with older version of Python
+        for version in versions[1:]:
+            # abi3 was introduced in Python 3.2
+            if version in {'31', '30'}:
+                break
+            for abi in abi3s:   # empty set if not Python 3
+                for arch in arches:
+                    supported.append(("%s%s" % (impl, version), abi, arch))
+
+        # Has binaries, does not use the Python API:
+        for arch in arches:
+            supported.append(('py%s' % (versions[0][0]), 'none', arch))
+
+    # No abi / arch, but requires our implementation:
+    supported.append(('%s%s' % (impl, versions[0]), 'none', 'any'))
+    # Tagged specifically as being cross-version compatible
+    # (with just the major version specified)
+    supported.append(('%s%s' % (impl, versions[0][0]), 'none', 'any'))
+
+    # No abi / arch, generic Python
+    for i, version in enumerate(versions):
+        supported.append(('py%s' % (version,), 'none', 'any'))
+        if i == 0:
+            supported.append(('py%s' % (version[0]), 'none', 'any'))
+
+    return supported
+
+
+implementation_tag = get_impl_tag()
diff --git a/setuptools/wheel.py b/setuptools/wheel.py
new file mode 100644
index 000000000..6e3df77ce
--- /dev/null
+++ b/setuptools/wheel.py
@@ -0,0 +1,125 @@
+'''Wheels support.'''
+
+from distutils.util import get_platform
+import email
+import itertools
+import os
+import re
+import zipfile
+
+from pkg_resources import Distribution, PathMetadata, parse_version
+from setuptools import Distribution as SetuptoolsDistribution
+from setuptools import pep425tags
+from setuptools.command.egg_info import write_requirements
+
+
+WHEEL_NAME = re.compile(
+    r"""^(?P<project_name>.+?)-(?P<version>\d.*?)
+    ((-(?P<build>\d.*?))?-(?P<py_version>.+?)-(?P<abi>.+?)-(?P<platform>.+?)
+    )\.whl$""",
+re.VERBOSE).match
+
+
+class Wheel(object):
+
+    def __init__(self, filename):
+        match = WHEEL_NAME(os.path.basename(filename))
+        if match is None:
+            raise ValueError('invalid wheel name: %r' % filename)
+        self.filename = filename
+        for k, v in match.groupdict().items():
+            setattr(self, k, v)
+
+    def tags(self):
+        '''List tags (py_version, abi, platform) supported by this wheel.'''
+        return itertools.product(self.py_version.split('.'),
+                                 self.abi.split('.'),
+                                 self.platform.split('.'))
+
+    def is_compatible(self):
+        '''Is the wheel is compatible with the current platform?'''
+        supported_tags = pep425tags.get_supported()
+        return next((True for t in self.tags() if t in supported_tags), False)
+
+    def egg_name(self):
+        return Distribution(
+            project_name=self.project_name, version=self.version,
+            platform=(None if self.platform == 'any' else get_platform()),
+        ).egg_name() + '.egg'
+
+    def install_as_egg(self, destination_eggdir):
+        '''Install wheel as an egg directory.'''
+        with zipfile.ZipFile(self.filename) as zf:
+            dist_basename = '%s-%s' % (self.project_name, self.version)
+            dist_info = '%s.dist-info' % dist_basename
+            dist_data = '%s.data' % dist_basename
+            def get_metadata(name):
+                with zf.open('%s/%s' % (dist_info, name)) as fp:
+                    value = fp.read().decode('utf-8')
+                    return email.parser.Parser().parsestr(value)
+            wheel_metadata = get_metadata('WHEEL')
+            dist_metadata = get_metadata('METADATA')
+            # Check wheel format version is supported.
+            wheel_version = parse_version(wheel_metadata.get('Wheel-Version'))
+            if not parse_version('1.0') <= wheel_version < parse_version('2.0dev0'):
+                raise ValueError('unsupported wheel format version: %s' % wheel_version)
+            # Extract to target directory.
+            os.mkdir(destination_eggdir)
+            zf.extractall(destination_eggdir)
+            # Convert metadata.
+            dist_info = os.path.join(destination_eggdir, dist_info)
+            dist = Distribution.from_location(
+                destination_eggdir, dist_info,
+                metadata=PathMetadata(destination_eggdir, dist_info)
+            )
+            # Note: we need to evaluate and strip markers now,
+            # as we can't easily convert back from the syntax:
+            # foobar; "linux" in sys_platform and extra == 'test'
+            def raw_req(req):
+                req.marker = None
+                return str(req)
+            install_requires = list(sorted(map(raw_req, dist.requires())))
+            extras_require = {
+                extra: list(sorted(
+                    req
+                    for req in map(raw_req, dist.requires((extra,)))
+                    if req not in install_requires
+                ))
+                for extra in dist.extras
+            }
+            egg_info = os.path.join(destination_eggdir, 'EGG-INFO')
+            os.rename(dist_info, egg_info)
+            os.rename(os.path.join(egg_info, 'METADATA'),
+                      os.path.join(egg_info, 'PKG-INFO'))
+            setup_dist = SetuptoolsDistribution(attrs=dict(
+                install_requires=install_requires,
+                extras_require=extras_require,
+            ))
+            write_requirements(setup_dist.get_command_obj('egg_info'),
+                               None, os.path.join(egg_info, 'requires.txt'))
+            # Move data entries to their correct location.
+            dist_data = os.path.join(destination_eggdir, dist_data)
+            dist_data_scripts = os.path.join(dist_data, 'scripts')
+            if os.path.exists(dist_data_scripts):
+                egg_info_scripts = os.path.join(destination_eggdir,
+                                                'EGG-INFO', 'scripts')
+                os.mkdir(egg_info_scripts)
+                for entry in os.listdir(dist_data_scripts):
+                    # Remove bytecode, as it's not properly handled
+                    # during easy_install scripts install phase.
+                    if entry.endswith('.pyc'):
+                        os.unlink(os.path.join(dist_data_scripts, entry))
+                    else:
+                        os.rename(os.path.join(dist_data_scripts, entry),
+                                  os.path.join(egg_info_scripts, entry))
+                os.rmdir(dist_data_scripts)
+            for subdir in filter(os.path.exists, (
+                os.path.join(dist_data, d)
+                for d in ('data', 'headers', 'purelib', 'platlib')
+            )):
+                for entry in os.listdir(subdir):
+                    os.rename(os.path.join(subdir, entry),
+                              os.path.join(destination_eggdir, entry))
+                os.rmdir(subdir)
+            if os.path.exists(dist_data):
+                os.rmdir(dist_data)


Test patch:
diff --git a/setuptools/tests/test_wheel.py b/setuptools/tests/test_wheel.py
new file mode 100644
index 000000000..a0c16c539
--- /dev/null
+++ b/setuptools/tests/test_wheel.py
@@ -0,0 +1,430 @@
+"""wheel tests
+"""
+
+from distutils.sysconfig import get_config_var
+from distutils.util import get_platform
+import contextlib
+import glob
+import inspect
+import os
+import subprocess
+import sys
+
+import pytest
+
+from pkg_resources import Distribution, PathMetadata, PY_MAJOR
+from setuptools.wheel import Wheel
+
+from .contexts import tempdir
+from .files import build_files
+from .textwrap import DALS
+
+
+WHEEL_INFO_TESTS = (
+    ('invalid.whl', ValueError),
+    ('simplewheel-2.0-1-py2.py3-none-any.whl', {
+        'project_name': 'simplewheel',
+        'version': '2.0',
+        'build': '1',
+        'py_version': 'py2.py3',
+        'abi': 'none',
+        'platform': 'any',
+    }),
+    ('simple.dist-0.1-py2.py3-none-any.whl', {
+        'project_name': 'simple.dist',
+        'version': '0.1',
+        'build': None,
+        'py_version': 'py2.py3',
+        'abi': 'none',
+        'platform': 'any',
+    }),
+    ('example_pkg_a-1-py3-none-any.whl', {
+        'project_name': 'example_pkg_a',
+        'version': '1',
+        'build': None,
+        'py_version': 'py3',
+        'abi': 'none',
+        'platform': 'any',
+    }),
+    ('PyQt5-5.9-5.9.1-cp35.cp36.cp37-abi3-manylinux1_x86_64.whl', {
+        'project_name': 'PyQt5',
+        'version': '5.9',
+        'build': '5.9.1',
+        'py_version': 'cp35.cp36.cp37',
+        'abi': 'abi3',
+        'platform': 'manylinux1_x86_64',
+    }),
+)
+
+@pytest.mark.parametrize(
+    ('filename', 'info'), WHEEL_INFO_TESTS,
+    ids=[t[0] for t in WHEEL_INFO_TESTS]
+)
+def test_wheel_info(filename, info):
+    if inspect.isclass(info):
+        with pytest.raises(info):
+            Wheel(filename)
+        return
+    w = Wheel(filename)
+    assert {k: getattr(w, k) for k in info.keys()} == info
+
+
+@contextlib.contextmanager
+def build_wheel(extra_file_defs=None, **kwargs):
+    file_defs = {
+        'setup.py': DALS(
+            '''
+            from setuptools import setup
+            import setuptools
+            setup(**%r)
+            '''
+        ) % kwargs,
+    }
+    if extra_file_defs:
+        file_defs.update(extra_file_defs)
+    with tempdir() as source_dir:
+        build_files(file_defs, source_dir)
+        subprocess.check_call((sys.executable, 'setup.py',
+                               '-q', 'bdist_wheel'), cwd=source_dir)
+        yield glob.glob(os.path.join(source_dir, 'dist', '*.whl'))[0]
+
+
+def tree(root):
+    def depth(path):
+        return len(path.split(os.path.sep))
+    def prefix(path_depth):
+        if not path_depth:
+            return ''
+        return '|  ' * (path_depth - 1) + '|-- '
+    lines = []
+    root_depth = depth(root)
+    for dirpath, dirnames, filenames in os.walk(root):
+        dirnames.sort()
+        filenames.sort()
+        dir_depth = depth(dirpath) - root_depth
+        if dir_depth > 0:
+            lines.append('%s%s/' % (prefix(dir_depth - 1),
+                                    os.path.basename(dirpath)))
+        for f in filenames:
+            lines.append('%s%s' % (prefix(dir_depth), f))
+    return '\n'.join(lines) + '\n'
+
+
+def _check_wheel_install(filename, install_dir, install_tree,
+                         project_name, version, requires_txt):
+    w = Wheel(filename)
+    egg_path = os.path.join(install_dir, w.egg_name())
+    w.install_as_egg(egg_path)
+    if install_tree is not None:
+        install_tree = install_tree.format(
+            py_version=PY_MAJOR,
+            platform=get_platform(),
+            shlib_ext=get_config_var('EXT_SUFFIX') or get_config_var('SO')
+        )
+        assert install_tree == tree(install_dir)
+    metadata = PathMetadata(egg_path, os.path.join(egg_path, 'EGG-INFO'))
+    dist = Distribution.from_filename(egg_path, metadata=metadata)
+    assert dist.project_name == project_name
+    assert dist.version == version
+    if requires_txt is None:
+        assert not dist.has_metadata('requires.txt')
+    else:
+        assert requires_txt == dist.get_metadata('requires.txt').lstrip()
+
+
+class Record(object):
+
+    def __init__(self, id, **kwargs):
+        self._id = id
+        self._fields = kwargs
+
+    def __repr__(self):
+        return '%s(**%r)' % (self._id, self._fields)
+
+
+WHEEL_INSTALL_TESTS = (
+
+    dict(
+        id='basic',
+        file_defs={
+            'foo': {
+                '__init__.py': ''
+            }
+        },
+        setup_kwargs=dict(
+            packages=['foo'],
+        ),
+        install_tree=DALS(
+            '''
+            foo-1.0-py{py_version}.egg/
+            |-- EGG-INFO/
+            |  |-- DESCRIPTION.rst
+            |  |-- PKG-INFO
+            |  |-- RECORD
+            |  |-- WHEEL
+            |  |-- metadata.json
+            |  |-- top_level.txt
+            |-- foo/
+            |  |-- __init__.py
+            '''
+        ),
+    ),
+
+    dict(
+        id='data',
+        file_defs={
+            'data.txt': DALS(
+                '''
+                Some data...
+                '''
+            ),
+        },
+        setup_kwargs=dict(
+            data_files=[('data_dir', ['data.txt'])],
+        ),
+        install_tree=DALS(
+            '''
+            foo-1.0-py{py_version}.egg/
+            |-- EGG-INFO/
+            |  |-- DESCRIPTION.rst
+            |  |-- PKG-INFO
+            |  |-- RECORD
+            |  |-- WHEEL
+            |  |-- metadata.json
+            |  |-- top_level.txt
+            |-- data_dir/
+            |  |-- data.txt
+            '''
+        ),
+    ),
+
+    dict(
+        id='extension',
+        file_defs={
+            'extension.c': DALS(
+                '''
+                #include "Python.h"
+
+                #if PY_MAJOR_VERSION >= 3
+
+                static struct PyModuleDef moduledef = {
+                        PyModuleDef_HEAD_INIT,
+                        "extension",
+                        NULL,
+                        0,
+                        NULL,
+                        NULL,
+                        NULL,
+                        NULL,
+                        NULL
+                };
+
+                #define INITERROR return NULL
+
+                PyMODINIT_FUNC PyInit_extension(void)
+
+                #else
+
+                #define INITERROR return
+
+                void initextension(void)
+
+                #endif
+                {
+                #if PY_MAJOR_VERSION >= 3
+                    PyObject *module = PyModule_Create(&moduledef);
+                #else
+                    PyObject *module = Py_InitModule("extension", NULL);
+                #endif
+                    if (module == NULL)
+                        INITERROR;
+                #if PY_MAJOR_VERSION >= 3
+                    return module;
+                #endif
+                }
+                '''
+            ),
+        },
+        setup_kwargs=dict(
+            ext_modules=[
+                Record('setuptools.Extension',
+                       name='extension',
+                       sources=['extension.c'])
+            ],
+        ),
+        install_tree=DALS(
+            '''
+            foo-1.0-py{py_version}-{platform}.egg/
+            |-- extension{shlib_ext}
+            |-- EGG-INFO/
+            |  |-- DESCRIPTION.rst
+            |  |-- PKG-INFO
+            |  |-- RECORD
+            |  |-- WHEEL
+            |  |-- metadata.json
+            |  |-- top_level.txt
+            '''
+        ),
+    ),
+
+    dict(
+        id='header',
+        file_defs={
+            'header.h': DALS(
+                '''
+                '''
+            ),
+        },
+        setup_kwargs=dict(
+            headers=['header.h'],
+        ),
+        install_tree=DALS(
+            '''
+            foo-1.0-py{py_version}.egg/
+            |-- header.h
+            |-- EGG-INFO/
+            |  |-- DESCRIPTION.rst
+            |  |-- PKG-INFO
+            |  |-- RECORD
+            |  |-- WHEEL
+            |  |-- metadata.json
+            |  |-- top_level.txt
+            '''
+        ),
+    ),
+
+    dict(
+        id='script',
+        file_defs={
+            'script.py': DALS(
+                '''
+                #/usr/bin/python
+                print('hello world!')
+                '''
+            ),
+            'script.sh': DALS(
+                '''
+                #/bin/sh
+                echo 'hello world!'
+                '''
+            ),
+        },
+        setup_kwargs=dict(
+            scripts=['script.py', 'script.sh'],
+        ),
+        install_tree=DALS(
+            '''
+            foo-1.0-py{py_version}.egg/
+            |-- EGG-INFO/
+            |  |-- DESCRIPTION.rst
+            |  |-- PKG-INFO
+            |  |-- RECORD
+            |  |-- WHEEL
+            |  |-- metadata.json
+            |  |-- top_level.txt
+            |  |-- scripts/
+            |  |  |-- script.py
+            |  |  |-- script.sh
+            '''
+        ),
+    ),
+
+    dict(
+        id='requires1',
+        install_requires='foobar==2.0',
+        install_tree=DALS(
+            '''
+            foo-1.0-py{py_version}.egg/
+            |-- EGG-INFO/
+            |  |-- DESCRIPTION.rst
+            |  |-- PKG-INFO
+            |  |-- RECORD
+            |  |-- WHEEL
+            |  |-- metadata.json
+            |  |-- requires.txt
+            |  |-- top_level.txt
+            '''),
+        requires_txt=DALS(
+            '''
+            foobar==2.0
+            '''
+        ),
+    ),
+
+    dict(
+        id='requires2',
+        install_requires='''
+        bar
+        foo<=2.0; %r in sys_platform
+        ''' % sys.platform,
+        requires_txt=DALS(
+            '''
+            bar
+            foo<=2.0
+            '''
+        ),
+    ),
+
+    dict(
+        id='requires3',
+        install_requires='''
+        bar; %r != sys_platform
+        ''' % sys.platform,
+    ),
+
+    dict(
+        id='requires4',
+        install_requires='''
+        foo
+        ''',
+        extras_require={
+            'extra': 'foobar>3',
+        },
+        requires_txt=DALS(
+            '''
+            foo
+
+            [extra]
+            foobar>3
+            '''
+        ),
+    ),
+
+    dict(
+        id='requires5',
+        extras_require={
+            'extra': 'foobar; %r != sys_platform' % sys.platform,
+        },
+        requires_txt=DALS(
+            '''
+            [extra]
+            '''
+        ),
+    ),
+
+)
+
+@pytest.mark.parametrize(
+    'params', WHEEL_INSTALL_TESTS,
+    ids=list(params['id'] for params in WHEEL_INSTALL_TESTS),
+)
+def test_wheel_install(params):
+    project_name = params.get('name', 'foo')
+    version = params.get('version', '1.0')
+    install_requires = params.get('install_requires', [])
+    extras_require = params.get('extras_require', {})
+    requires_txt = params.get('requires_txt', None)
+    install_tree = params.get('install_tree')
+    file_defs = params.get('file_defs', {})
+    setup_kwargs = params.get('setup_kwargs', {})
+    with build_wheel(
+        name=project_name,
+        version=version,
+        install_requires=install_requires,
+        extras_require=extras_require,
+        extra_file_defs=file_defs,
+        **setup_kwargs
+    ) as filename, tempdir() as install_dir:
+        _check_wheel_install(filename, install_dir,
+                             install_tree, project_name,
+                             version, requires_txt)
diff --git a/tests/requirements.txt b/tests/requirements.txt
index 4761505fc..38b692471 100644
--- a/tests/requirements.txt
+++ b/tests/requirements.txt
@@ -4,3 +4,4 @@ pytest-flake8; python_version>="2.7"
 virtualenv>=13.0.0
 pytest-virtualenv>=1.2.7
 pytest>=3.0.2
+wheel