pypi.py 9.5 KB

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