1. Daniel Holth
  2. wheel

Commits

Paul Moore  committed 003f39b

Add a wheel install command

  • Participants
  • Parent commits 91758d2
  • Branches default

Comments (0)

Files changed (5)

File wheel/install.py

View file
 import zipfile
 import hashlib
 import csv
+import sysconfig
+import shutil
 
 from wheel.decorator import reify
 from wheel.util import urlsafe_b64encode, from_json,\
-    urlsafe_b64decode, native, binary
+    urlsafe_b64decode, native, binary, generate_supported, \
+    HashingFile, compatibility_match
 from wheel import signatures
 from wheel.pkginfo import read_pkg_info_bytes
+from wheel.bdist_wheel import open_for_csv
 
 # The next major version after this version of the 'wheel' tool:
 VERSION_TOO_HIGH = (1, 0)
         return "%s.data" % self.parsed_filename.group('namever')
 
     @property
+    def record_name(self):
+        return "%s/%s" % (self.distinfo_name, 'RECORD')
+
+    @property
     def wheelinfo_name(self):
         return "%s/%s" % (self.distinfo_name, self.WHEEL_INFO)
 
         """Parse wheel metadata"""
         return read_pkg_info_bytes(self.zipfile.read(self.wheelinfo_name))
 
+    def supports_current_python(self):
+        for tags in generate_supported():
+            for dtags in self.compatibility_tags:
+                if compatibility_match(dtags, tags):
+                    return True
+        return False
+
+    def install(self, force=False, overrides={}):
+        """Install the wheel into site-packages"""
+
+        # Utility to get the target directory for a particular key
+        def get_path(key):
+            return overrides.get(key) or sysconfig.get_path(key)
+
+        # The base target location is either purelib or platlib
+        if self.parsed_wheel_info['Root-Is-Purelib'] == 'true':
+            root = get_path('purelib')
+        else:
+            root = get_path('platlib')
+
+        # Parse all the names in the archive
+        name_trans = {}
+        for name in self.zipfile.namelist():
+            # Zip files can contain entries representing directories.
+            # These end in a '/'.
+            # We ignore these, as we create directories on demand.
+            if name.endswith('/'):
+                continue
+
+            # Pathnames in a zipfile namelist are always /-separated.
+            # In theory, paths could start with ./ or have other oddities
+            # but this won't happen in practical cases of well-formed wheels.
+            # We'll cover the simple case of an initial './' as it's both easy
+            # to do and more common than most other oddities.
+            if name.startswith('./'):
+                name = name[2:]
+
+            # Split off the base directory to identify files that are to be
+            # installed in non-root locations
+            basedir, sep, filename = name.partition('/')
+            if sep and basedir == self.datadir_name:
+                # Data file. Target destination is elsewhere
+                key, sep, filename = filename.partition('/')
+                if not sep:
+                    raise ValueError("Invalid filename in wheel: {}".format(name))
+                target = get_path(key)
+            else:
+                # Normal file. Target destination is root
+                target = root
+                filename = name
+
+            # Map the actual filename from the zipfile to its intended target
+            # directory and the pathname relative to that directory.
+            dest = os.path.normpath(os.path.join(target, filename))
+            name_trans[name] = (target, filename, dest)
+
+        # We're now ready to start processing the actual install. The process
+        # is as follows:
+        #   1. Prechecks - is the wheel valid, is its declared architecture
+        #      OK, etc. [[Should probably have already been done]]
+        #   2. Overwrite check - do any of the files to be installed already
+        #      exist?
+        #   3. Actual install - put the files in their target locations.
+        #   4. Update RECORD - write a suitably modified RECORD file to
+        #      reflect the actual installed paths.
+        self.check_version()
+        if not self.supports_current_python():
+            raise ValueError("Wheel was not built for this version of Python")
+
+        if not force:
+            for k, v in name_trans.items():
+                target, filename, dest = v
+                if os.path.exists(dest):
+                    raise ValueError("Wheel file {} would overwrite an existing file. Use force is this is intended".format(k))
+
+        record_data = []
+        for name, (target, filename, dest) in name_trans.items():
+            source = HashingFile(self.zipfile.open(name))
+            # Skip the RECORD file
+            if name == self.distinfo_name + '/RECORD':
+                continue
+            ddir = os.path.dirname(dest)
+            if not os.path.isdir(ddir):
+                os.makedirs(ddir)
+            destination = open(dest, 'wb')
+            shutil.copyfileobj(source, destination)
+            destination.close()
+            reldest = os.path.relpath(dest, root)
+            reldest.replace(os.sep, '/')
+            record_data.append((reldest, source.digest(), source.length))
+            source.close()
+
+        record_name = os.path.join(root, self.distinfo_name, 'RECORD')
+        writer = csv.writer(open_for_csv(record_name, 'w+'))
+        for reldest, digest, length in sorted(record_data):
+            writer.writerow((reldest, digest, length))
+        writer.writerow((self.distinfo_name + '/RECORD', '', ''))
+
     def check_version(self):
         version = self.parsed_wheel_info['Wheel-Version']
         if tuple(map(int, version.split('.'))) >= VERSION_TOO_HIGH:

File wheel/test/test-1.0-py2.py3-none-win32.whl

Binary file added.

File wheel/test/test_install.py

View file
+# Test wheel.
+# The file has the following contents:
+#   hello.pyd
+#   hello/hello.py
+#   hello/__init__.py
+#   test-1.0.data/data/hello.dat
+#   test-1.0.data/headers/hello.dat
+#   test-1.0.data/scripts/hello.sh
+#   test-1.0.dist-info/WHEEL
+#   test-1.0.dist-info/METADATA
+#   test-1.0.dist-info/RECORD
+# The root is PLATLIB
+# So, some in PLATLIB, and one in each of DATA, HEADERS and SCRIPTS.
+
+from wheel.install import WheelFile
+from tempfile import mkdtemp
+import shutil
+import os
+
+THISDIR = os.path.dirname(__file__)
+TESTWHEEL = os.path.join(THISDIR, 'test-1.0-py2.py3-none-win32.whl')
+
+def check(*path):
+    return os.path.exists(os.path.join(*path))
+
+def test_install():
+    whl = WheelFile(TESTWHEEL)
+    tempdir = mkdtemp()
+    try:
+        locs = {}
+        for key in ('purelib', 'platlib', 'scripts', 'headers', 'data'):
+            locs[key] = os.path.join(tempdir, key)
+            os.mkdir(locs[key])
+        whl.install(overrides=locs)
+        assert len(os.listdir(locs['purelib'])) == 0
+        assert check(locs['platlib'], 'hello.pyd')
+        assert check(locs['platlib'], 'hello', 'hello.py')
+        assert check(locs['platlib'], 'hello', '__init__.py')
+        assert check(locs['data'], 'hello.dat')
+        assert check(locs['headers'], 'hello.dat')
+        assert check(locs['scripts'], 'hello.sh')
+        assert check(locs['platlib'], 'test-1.0.dist-info', 'RECORD')
+    finally:
+        shutil.rmtree(tempdir)

File wheel/tool/__init__.py

View file
     wf.zipfile.extractall(destination)
     wf.zipfile.close()
 
+@wb.command
+def install(wheelfile, force=False):
+    wf = wheel.install.WheelFile(wheelfile)
+    wf.install(force)
+    wf.zipfile.close()
+
 def main():
     wb.run()
-    
+    

File wheel/util.py

View file
 import sys
 import base64
 import json
+import hashlib
 try:
     import sysconfig
 except ImportError:  # pragma nocover
 from distutils.util import get_platform
 
 __all__ = ['urlsafe_b64encode', 'urlsafe_b64decode', 'utf8', 'to_json',
-           'from_json', 'generate_supported', 'get_abbr_impl', 'get_impl_ver']
+           'from_json', 'generate_supported', 'get_abbr_impl', 'get_impl_ver',
+           'compatibility_match']
 
 
 def urlsafe_b64encode(data):
             # Add pure Python distributions if not already done so
             supported.append(('py%s' % (version), 'none', 'any'))
     return supported
+
+def compatibility_match(declared, tag):
+    dpyver, dabi, dplat = declared
+    pyver, abi, plat = tag
+
+    # Platform: declared 'any' or matches
+    if dplat != 'any' and dplat != plat:
+        return False
+    # ABI: declared 'none' or matches
+    if dabi != 'none' and dabi != abi:
+        return False
+
+    # Python version: Implementation must match unless declared as 'py'
+    # (generic), major version must match, and if declared as for a specific
+    # minor version this must match too.
+    if dpyver[:2] != 'py' and dpyver[:2] != pyver[:2]:
+        return False
+    if dpyver[2] != pyver[2]:
+        return False
+    if len(dpyver) > 3 and dpyver[3] != pyver[3]:
+        return False
+    return True
+
+class HashingFile(object):
+    def __init__(self, fd, hashtype='sha256'):
+        self.fd = fd
+        self.hashtype = hashtype
+        self.hash = hashlib.new(hashtype)
+        self.length = 0
+    def read(self, n):
+        data = self.fd.read(n)
+        self.hash.update(data)
+        self.length += len(data)
+        return data
+    def close(self):
+        self.fd.close()
+    def digest(self):
+        if self.hashtype == 'md5':
+            return self.hash.hexdigest()
+        digest = self.hash.digest()
+        return self.hashtype + '=' + native(urlsafe_b64encode(digest))