pypi.py 12 KB

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