pypi.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. from distutils.version import LooseVersion
  2. import errno
  3. import hashlib
  4. import logging
  5. try:
  6. from pathlib import Path
  7. except ImportError:
  8. from pathlib2 import Path # Backport
  9. import re
  10. import shutil
  11. from tempfile import mkdtemp
  12. import zipfile
  13. import yarg
  14. from requests_download import download, HashTracker
  15. from .util import get_cache_dir
  16. logger = logging.getLogger(__name__)
  17. def find_pypi_release(requirement):
  18. if '==' in requirement:
  19. name, version = requirement.split('==', 1)
  20. return yarg.get(name).release(version)
  21. else:
  22. return yarg.get(requirement).latest_release
  23. class NoWheelError(Exception): pass
  24. class WheelDownloader(object):
  25. def __init__(self, requirement, py_version, bitness):
  26. self.requirement = requirement
  27. self.py_version = py_version
  28. self.bitness = bitness
  29. if requirement.count('==') != 1:
  30. raise ValueError("Requirement {!r} did not match name==version".format(requirement))
  31. self.name, self.version = requirement.split('==', 1)
  32. def score_platform(self, platform):
  33. target = 'win_amd64' if self.bitness == 64 else 'win32'
  34. d = {target: 2, 'any': 1}
  35. return max(d.get(p, 0) for p in platform.split('.'))
  36. def score_abi(self, abi):
  37. py_version_nodot = ''.join(self.py_version.split('.')[:2])
  38. # Are there other valid options here?
  39. d = {'cp%sm' % py_version_nodot: 3, # Is the m reliable?
  40. 'abi3': 2, 'none': 1}
  41. return max(d.get(a, 0) for a in abi.split('.'))
  42. def score_interpreter(self, interpreter):
  43. py_version_nodot = ''.join(self.py_version.split('.')[:2])
  44. py_version_major = self.py_version.split('.')[0]
  45. d = {'cp'+py_version_nodot: 4,
  46. 'cp'+py_version_major: 3,
  47. 'py'+py_version_nodot: 2,
  48. 'py'+py_version_major: 1
  49. }
  50. return max(d.get(i, 0) for i in interpreter.split('.'))
  51. def pick_best_wheel(self, release_list):
  52. best_score = (0, 0, 0)
  53. best = None
  54. for release in release_list:
  55. if release.package_type != 'wheel':
  56. continue
  57. m = re.search(r'-([^-]+)-([^-]+)-([^-]+)\.whl', release.filename)
  58. if not m:
  59. continue
  60. interpreter, abi, platform = m.group(1, 2, 3)
  61. score = (self.score_platform(platform),
  62. self.score_abi(abi),
  63. self.score_interpreter(interpreter)
  64. )
  65. if any(s==0 for s in score):
  66. # Incompatible
  67. continue
  68. if score > best_score:
  69. best = release
  70. best_score = score
  71. return best
  72. def check_cache(self):
  73. release_dir = get_cache_dir() / 'pypi' / self.name / self.version
  74. if not release_dir.is_dir():
  75. return None
  76. rel = self.pick_best_wheel(CachedRelease(p.name)
  77. for p in release_dir.iterdir())
  78. if rel is None:
  79. return None
  80. logger.info('Using cached wheel: %s', rel.filename)
  81. return release_dir / rel.filename
  82. def fetch(self):
  83. p = self.check_cache()
  84. if p is not None:
  85. return p
  86. release_list = yarg.get(self.name).release(self.version)
  87. preferred_release = self.pick_best_wheel(release_list)
  88. if preferred_release is None:
  89. raise NoWheelError('No compatible wheels found for {0.name} {0.version}'.format(self))
  90. download_to = get_cache_dir() / 'pypi' / self.name / self.version
  91. try:
  92. download_to.mkdir(parents=True)
  93. except OSError as e:
  94. # Py2 compatible equivalent of FileExistsError
  95. if e.errno != errno.EEXIST:
  96. raise
  97. target = download_to / preferred_release.filename
  98. from . import __version__
  99. hasher = HashTracker(hashlib.md5())
  100. headers = {'user-agent': 'pynsist/'+__version__}
  101. logger.info('Downloading wheel: %s', preferred_release.url)
  102. download(preferred_release.url, str(target), headers=headers,
  103. trackers=(hasher,))
  104. if hasher.hashobj.hexdigest() != preferred_release.md5_digest:
  105. target.unlink()
  106. raise ValueError('Downloaded wheel corrupted: {}'.format(preferred_release.url))
  107. return target
  108. class CachedRelease(object):
  109. # Mock enough of the yarg Release object to be compatible with
  110. # pick_best_release above
  111. def __init__(self, filename):
  112. self.filename = filename
  113. self.package_type = 'wheel' if filename.endswith('.whl') else ''
  114. def merge_dir_to(src, dst):
  115. """Merge all files from one directory into another.
  116. Subdirectories will be merged recursively. If filenames are the same, those
  117. from src will overwrite those in dst. If a regular file clashes with a
  118. directory, an error will occur.
  119. """
  120. for p in src.iterdir():
  121. if p.is_dir():
  122. dst_p = dst / p.name
  123. if dst_p.is_dir():
  124. merge_dir_to(p, dst_p)
  125. elif dst_p.is_file():
  126. raise RuntimeError('Directory {} clashes with file {}'
  127. .format(p, dst_p))
  128. else:
  129. shutil.copytree(str(p), str(dst_p))
  130. else:
  131. # Copy regular file
  132. dst_p = dst / p.name
  133. if dst_p.is_dir():
  134. raise RuntimeError('File {} clashes with directory {}'
  135. .format(p, dst_p))
  136. shutil.copy2(str(p), str(dst_p))
  137. def extract_wheel(whl_file, target_dir):
  138. """Extract importable modules from a wheel to the target directory
  139. """
  140. # Extract to temporary directory
  141. td = Path(mkdtemp())
  142. with zipfile.ZipFile(str(whl_file), mode='r') as zf:
  143. zf.extractall(str(td))
  144. # Move extra lib files out of the .data subdirectory
  145. for p in td.iterdir():
  146. if p.suffix == '.data':
  147. if (p / 'purelib').is_dir():
  148. merge_dir_to(p / 'purelib', td)
  149. if (p / 'platlib').is_dir():
  150. merge_dir_to(p / 'platlib', td)
  151. # Copy to target directory
  152. target = Path(target_dir)
  153. copied_something = False
  154. for p in td.iterdir():
  155. if p.suffix not in {'.data', '.dist-info'}:
  156. if p.is_dir():
  157. # If the dst directory already exists, this will combine them.
  158. # shutil.copytree will not combine them.
  159. target.joinpath(p.name).mkdir(exist_ok = True)
  160. merge_dir_to(p, target / p.name)
  161. else:
  162. shutil.copy2(str(p), str(target))
  163. copied_something = True
  164. if not copied_something:
  165. raise RuntimeError("Did not find any files to extract from wheel {}"
  166. .format(whl_file))
  167. # Clean up temporary directory
  168. shutil.rmtree(str(td))
  169. def fetch_pypi_wheels(requirements, target_dir, py_version, bitness):
  170. for req in requirements:
  171. wd = WheelDownloader(req, py_version, bitness)
  172. whl_file = wd.fetch()
  173. extract_wheel(whl_file, target_dir)