pypi.py 6.9 KB

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