pypi.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. """Find, download and unpack wheels."""
  2. import fnmatch
  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 glob
  11. import yarg
  12. from requests_download import download, HashTracker
  13. from .util import get_cache_dir, normalize_path
  14. logger = logging.getLogger(__name__)
  15. class NoWheelError(Exception): pass
  16. class WheelLocator(object):
  17. def __init__(self, requirement, py_version, bitness, extra_sources=None):
  18. self.requirement = requirement
  19. self.py_version = py_version
  20. self.bitness = bitness
  21. self.extra_sources = extra_sources or []
  22. if requirement.count('==') != 1:
  23. raise ValueError("Requirement {!r} did not match name==version".format(requirement))
  24. self.name, self.version = requirement.split('==', 1)
  25. def score_platform(self, platform):
  26. target = 'win_amd64' if self.bitness == 64 else 'win32'
  27. d = {target: 2, 'any': 1}
  28. return max(d.get(p, 0) for p in platform.split('.'))
  29. def score_abi(self, abi):
  30. py_version_nodot = ''.join(self.py_version.split('.')[:2])
  31. # Are there other valid options here?
  32. d = {'cp%sm' % py_version_nodot: 3, # Is the m reliable?
  33. 'abi3': 2, 'none': 1}
  34. return max(d.get(a, 0) for a in abi.split('.'))
  35. def score_interpreter(self, interpreter):
  36. py_version_nodot = ''.join(self.py_version.split('.')[:2])
  37. py_version_major = self.py_version.split('.')[0]
  38. d = {'cp'+py_version_nodot: 4,
  39. 'cp'+py_version_major: 3,
  40. 'py'+py_version_nodot: 2,
  41. 'py'+py_version_major: 1
  42. }
  43. return max(d.get(i, 0) for i in interpreter.split('.'))
  44. def pick_best_wheel(self, release_list):
  45. best_score = (0, 0, 0)
  46. best = None
  47. for release in release_list:
  48. if release.package_type != 'wheel':
  49. continue
  50. m = re.search(r'-([^-]+)-([^-]+)-([^-]+)\.whl', release.filename)
  51. if not m:
  52. continue
  53. interpreter, abi, platform = m.group(1, 2, 3)
  54. score = (self.score_platform(platform),
  55. self.score_abi(abi),
  56. self.score_interpreter(interpreter)
  57. )
  58. if any(s==0 for s in score):
  59. # Incompatible
  60. continue
  61. if score > best_score:
  62. best = release
  63. best_score = score
  64. return best
  65. def check_extra_sources(self):
  66. """Find a compatible wheel in the specified extra_sources directories.
  67. Returns a Path or None.
  68. """
  69. whl_filename_prefix = '{name}-{version}-'.format(
  70. name=re.sub("[^\w\d.]+", "_", self.name),
  71. version=re.sub("[^\w\d.]+", "_", self.version),
  72. )
  73. for source in self.extra_sources:
  74. candidates = [CachedRelease(p.name)
  75. for p in source.iterdir()
  76. if p.name.startswith(whl_filename_prefix)]
  77. rel = self.pick_best_wheel(candidates)
  78. if rel:
  79. path = source / rel.filename
  80. logger.info('Using wheel from extra directory: %s', path)
  81. return path
  82. def check_cache(self):
  83. """Find a wheel previously downloaded from PyPI in the cache.
  84. Returns a Path or None.
  85. """
  86. release_dir = get_cache_dir() / 'pypi' / self.name / self.version
  87. if not release_dir.is_dir():
  88. return None
  89. rel = self.pick_best_wheel(CachedRelease(p.name)
  90. for p in release_dir.iterdir())
  91. if rel is None:
  92. return None
  93. logger.info('Using cached wheel: %s', rel.filename)
  94. return release_dir / rel.filename
  95. def get_from_pypi(self):
  96. """Download a compatible wheel from PyPI.
  97. Downloads to the cache directory and returns the destination as a Path.
  98. Raises NoWheelError if no compatible wheel is found.
  99. """
  100. try:
  101. pypi_pkg = yarg.get(self.name)
  102. except yarg.HTTPError as e:
  103. if e.status_code == 404:
  104. raise NoWheelError("No package named {} found on PyPI".format(self.name))
  105. raise
  106. release_list = pypi_pkg.release(self.version)
  107. if release_list is None:
  108. raise NoWheelError("No release {0.version} for package {0.name}".format(self))
  109. preferred_release = self.pick_best_wheel(release_list)
  110. if preferred_release is None:
  111. raise NoWheelError('No compatible wheels found for {0.name} {0.version}'.format(self))
  112. download_to = get_cache_dir() / 'pypi' / self.name / self.version
  113. try:
  114. download_to.mkdir(parents=True)
  115. except OSError:
  116. # Ignore OSError only if the directory exists
  117. if not download_to.is_dir():
  118. raise
  119. target = download_to / preferred_release.filename
  120. from . import __version__
  121. hasher = HashTracker(hashlib.md5())
  122. headers = {'user-agent': 'pynsist/'+__version__}
  123. logger.info('Downloading wheel: %s', preferred_release.url)
  124. download(preferred_release.url, str(target), headers=headers,
  125. trackers=(hasher,))
  126. if hasher.hashobj.hexdigest() != preferred_release.md5_digest:
  127. target.unlink()
  128. raise ValueError('Downloaded wheel corrupted: {}'.format(preferred_release.url))
  129. return target
  130. def fetch(self):
  131. """Find and return a compatible wheel (main interface)"""
  132. p = self.check_extra_sources()
  133. if p is not None:
  134. return p
  135. p = self.check_cache()
  136. if p is not None:
  137. return p
  138. return self.get_from_pypi()
  139. class CachedRelease(object):
  140. # Mock enough of the yarg Release object to be compatible with
  141. # pick_best_release above
  142. def __init__(self, filename):
  143. self.filename = filename
  144. self.package_type = 'wheel' if filename.endswith('.whl') else ''
  145. def merge_dir_to(src, dst):
  146. """Merge all files from one directory into another.
  147. Subdirectories will be merged recursively. If filenames are the same, those
  148. from src will overwrite those in dst. If a regular file clashes with a
  149. directory, an error will occur.
  150. """
  151. for p in src.iterdir():
  152. if p.is_dir():
  153. dst_p = dst / p.name
  154. if dst_p.is_dir():
  155. merge_dir_to(p, dst_p)
  156. elif dst_p.is_file():
  157. raise RuntimeError('Directory {} clashes with file {}'
  158. .format(p, dst_p))
  159. else:
  160. shutil.copytree(str(p), str(dst_p))
  161. else:
  162. # Copy regular file
  163. dst_p = dst / p.name
  164. if dst_p.is_dir():
  165. raise RuntimeError('File {} clashes with directory {}'
  166. .format(p, dst_p))
  167. shutil.copy2(str(p), str(dst_p))
  168. def extract_wheel(whl_file, target_dir, exclude=None):
  169. """Extract importable modules from a wheel to the target directory
  170. """
  171. # Extract to temporary directory
  172. td = Path(mkdtemp())
  173. with zipfile.ZipFile(str(whl_file), mode='r') as zf:
  174. if exclude:
  175. basename = Path(Path(target_dir).name)
  176. for zpath in zf.namelist():
  177. path = basename / zpath
  178. if is_excluded(path, exclude):
  179. continue # Skip excluded paths
  180. zf.extract(zpath, path=str(td))
  181. else:
  182. zf.extractall(str(td))
  183. # Move extra lib files out of the .data subdirectory
  184. for p in td.iterdir():
  185. if p.suffix == '.data':
  186. if (p / 'purelib').is_dir():
  187. merge_dir_to(p / 'purelib', td)
  188. if (p / 'platlib').is_dir():
  189. merge_dir_to(p / 'platlib', td)
  190. # Copy to target directory
  191. target = Path(target_dir)
  192. copied_something = False
  193. for p in td.iterdir():
  194. if p.suffix not in {'.data'}:
  195. if p.is_dir():
  196. # If the dst directory already exists, this will combine them.
  197. # shutil.copytree will not combine them.
  198. try:
  199. target.joinpath(p.name).mkdir()
  200. except OSError:
  201. if not target.joinpath(p.name).is_dir():
  202. raise
  203. merge_dir_to(p, target / p.name)
  204. else:
  205. shutil.copy2(str(p), str(target))
  206. copied_something = True
  207. if not copied_something:
  208. raise RuntimeError("Did not find any files to extract from wheel {}"
  209. .format(whl_file))
  210. # Clean up temporary directory
  211. shutil.rmtree(str(td))
  212. def fetch_pypi_wheels(wheels_requirements, wheels_paths, target_dir, py_version,
  213. bitness, extra_sources=None, exclude=None):
  214. for req in wheels_requirements:
  215. wl = WheelLocator(req, py_version, bitness, extra_sources)
  216. whl_file = wl.fetch()
  217. extract_wheel(whl_file, target_dir, exclude=exclude)
  218. for glob_path in wheels_paths:
  219. for path in glob.glob(glob_path):
  220. extract_wheel(path, target_dir, exclude=exclude)
  221. def is_excluded(path, exclude):
  222. """Return True if path matches an exclude pattern"""
  223. path = normalize_path(path)
  224. for pattern in (exclude or ()):
  225. if fnmatch.fnmatch(path, pattern):
  226. return True
  227. return False