pypi.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  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. return release_dir / rel.filename
  78. def fetch(self):
  79. p = self.check_cache()
  80. if p is not None:
  81. return p
  82. release_list = yarg.get(self.name).release(self.version)
  83. preferred_release = self.pick_best_wheel(release_list)
  84. if preferred_release is None:
  85. raise NoWheelError('No compatible wheels found for {0.name} {0.version}'.format(self))
  86. download_to = get_cache_dir() / 'pypi' / self.name / self.version
  87. try:
  88. download_to.mkdir(parents=True)
  89. except OSError as e:
  90. # Py2 compatible equivalent of FileExistsError
  91. if e.errno != errno.EEXIST:
  92. raise
  93. target = download_to / preferred_release.filename
  94. from . import __version__
  95. hasher = HashTracker(hashlib.md5())
  96. headers = {'user-agent': 'pynsist/'+__version__}
  97. logger.info('Downloading wheel: %s', preferred_release.url)
  98. download(preferred_release.url, str(target), headers=headers,
  99. trackers=(hasher,))
  100. if hasher.hashobj.hexdigest() != preferred_release.md5_digest:
  101. target.unlink()
  102. raise ValueError('Downloaded wheel corrupted: {}'.format(preferred_release.url))
  103. return target
  104. class CachedRelease(object):
  105. # Mock enough of the yarg Release object to be compatible with
  106. # pick_best_release above
  107. def __init__(self, filename):
  108. self.filename = filename
  109. self.package_type = 'wheel' if filename.endswith('.whl') else ''
  110. def merge_dir_to(src, dst):
  111. """Merge all files from one directory into another.
  112. Subdirectories will be merged recursively. If filenames are the same, those
  113. from src will overwrite those in dst. If a regular file clashes with a
  114. directory, an error will occur.
  115. """
  116. for p in src.iterdir():
  117. if p.is_dir():
  118. dst_p = dst / p.name
  119. if dst_p.is_dir():
  120. merge_dir_to(p, dst_p)
  121. elif dst_p.is_file():
  122. raise RuntimeError('Directory {} clashes with file {}'
  123. .format(p, dst_p))
  124. else:
  125. shutil.copytree(str(p), str(dst_p))
  126. else:
  127. # Copy regular file
  128. dst_p = dst / p.name
  129. if dst_p.is_dir():
  130. raise RuntimeError('File {} clashes with directory {}'
  131. .format(p, dst_p))
  132. shutil.copy2(str(p), str(dst_p))
  133. def extract_wheel(whl_file, target_dir):
  134. """Extract importable modules from a wheel to the target directory
  135. """
  136. # Extract to temporary directory
  137. td = Path(mkdtemp())
  138. with zipfile.ZipFile(str(whl_file), mode='r') as zf:
  139. zf.extractall(str(td))
  140. # Move extra lib files out of the .data subdirectory
  141. for p in td.iterdir():
  142. if p.suffix == '.data':
  143. if (p / 'purelib').is_dir():
  144. merge_dir_to(p / 'purelib', td)
  145. if (p / 'platlib').is_dir():
  146. merge_dir_to(p / 'platlib', td)
  147. # Copy to target directory
  148. target = Path(target_dir)
  149. copied_something = False
  150. for p in td.iterdir():
  151. if p.suffix not in {'.data', '.dist-info'}:
  152. if p.is_dir():
  153. shutil.copytree(str(p), str(target / p.name))
  154. else:
  155. shutil.copy2(str(p), str(target))
  156. copied_something = True
  157. if not copied_something:
  158. raise RuntimeError("Did not find any files to extract from wheel {}"
  159. .format(whl_file))
  160. # Clean up temporary directory
  161. shutil.rmtree(str(td))
  162. def fetch_pypi_wheels(requirements, target_dir, py_version, bitness):
  163. for req in requirements:
  164. wd = WheelDownloader(req, py_version, bitness)
  165. whl_file = wd.fetch()
  166. extract_wheel(whl_file, target_dir)