bdist_wheel.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. """
  2. Create a wheel (.whl) distribution.
  3. A wheel is a built archive format.
  4. """
  5. import csv
  6. import hashlib
  7. import os
  8. import subprocess
  9. import warnings
  10. import shutil
  11. import json
  12. import wheel
  13. try:
  14. import sysconfig
  15. except ImportError: # pragma nocover
  16. # Python < 2.7
  17. import distutils.sysconfig as sysconfig
  18. import pkg_resources
  19. safe_name = pkg_resources.safe_name
  20. safe_version = pkg_resources.safe_version
  21. from shutil import rmtree
  22. from email.generator import Generator
  23. from distutils.util import get_platform
  24. from distutils.core import Command
  25. from distutils.sysconfig import get_python_version
  26. from distutils import log as logger
  27. from .pep425tags import get_abbr_impl, get_impl_ver, get_abi_tag
  28. from .util import native, open_for_csv
  29. from .archive import archive_wheelfile
  30. from .pkginfo import read_pkg_info, write_pkg_info
  31. from .metadata import pkginfo_to_dict
  32. from . import pep425tags, metadata
  33. def safer_name(name):
  34. return safe_name(name).replace('-', '_')
  35. def safer_version(version):
  36. return safe_version(version).replace('-', '_')
  37. class bdist_wheel(Command):
  38. description = 'create a wheel distribution'
  39. user_options = [('bdist-dir=', 'b',
  40. "temporary directory for creating the distribution"),
  41. ('plat-name=', 'p',
  42. "platform name to embed in generated filenames "
  43. "(default: %s)" % get_platform()),
  44. ('keep-temp', 'k',
  45. "keep the pseudo-installation tree around after " +
  46. "creating the distribution archive"),
  47. ('dist-dir=', 'd',
  48. "directory to put final built distributions in"),
  49. ('skip-build', None,
  50. "skip rebuilding everything (for testing/debugging)"),
  51. ('relative', None,
  52. "build the archive using relative paths"
  53. "(default: false)"),
  54. ('owner=', 'u',
  55. "Owner name used when creating a tar file"
  56. " [default: current user]"),
  57. ('group=', 'g',
  58. "Group name used when creating a tar file"
  59. " [default: current group]"),
  60. ('universal', None,
  61. "make a universal wheel"
  62. " (default: false)"),
  63. ('python-tag=', None,
  64. "Python implementation compatibility tag"
  65. " (default: py%s)" % get_impl_ver()[0]),
  66. ]
  67. boolean_options = ['keep-temp', 'skip-build', 'relative', 'universal']
  68. def initialize_options(self):
  69. self.bdist_dir = None
  70. self.data_dir = None
  71. self.plat_name = None
  72. self.plat_tag = None
  73. self.format = 'zip'
  74. self.keep_temp = False
  75. self.dist_dir = None
  76. self.distinfo_dir = None
  77. self.egginfo_dir = None
  78. self.root_is_pure = None
  79. self.skip_build = None
  80. self.relative = False
  81. self.owner = None
  82. self.group = None
  83. self.universal = False
  84. self.python_tag = 'py' + get_impl_ver()[0]
  85. self.plat_name_supplied = False
  86. def finalize_options(self):
  87. if self.bdist_dir is None:
  88. bdist_base = self.get_finalized_command('bdist').bdist_base
  89. self.bdist_dir = os.path.join(bdist_base, 'wheel')
  90. self.data_dir = self.wheel_dist_name + '.data'
  91. self.plat_name_supplied = self.plat_name is not None
  92. need_options = ('dist_dir', 'plat_name', 'skip_build')
  93. self.set_undefined_options('bdist',
  94. *zip(need_options, need_options))
  95. self.root_is_pure = not (self.distribution.has_ext_modules()
  96. or self.distribution.has_c_libraries())
  97. # Support legacy [wheel] section for setting universal
  98. wheel = self.distribution.get_option_dict('wheel')
  99. if 'universal' in wheel:
  100. # please don't define this in your global configs
  101. val = wheel['universal'][1].strip()
  102. if val.lower() in ('1', 'true', 'yes'):
  103. self.universal = True
  104. @property
  105. def wheel_dist_name(self):
  106. """Return distribution full name with - replaced with _"""
  107. return '-'.join((safer_name(self.distribution.get_name()),
  108. safer_version(self.distribution.get_version())))
  109. def get_tag(self):
  110. # bdist sets self.plat_name if unset, we should only use it for purepy
  111. # wheels if the user supplied it.
  112. if self.plat_name_supplied:
  113. plat_name = self.plat_name
  114. elif self.root_is_pure:
  115. plat_name = 'any'
  116. else:
  117. plat_name = self.plat_name or get_platform()
  118. plat_name = plat_name.replace('-', '_').replace('.', '_')
  119. if self.root_is_pure:
  120. if self.universal:
  121. impl = 'py2.py3'
  122. else:
  123. impl = self.python_tag
  124. tag = (impl, 'none', plat_name)
  125. else:
  126. impl_name = get_abbr_impl()
  127. impl_ver = get_impl_ver()
  128. # PEP 3149
  129. abi_tag = str(get_abi_tag()).lower()
  130. tag = (impl_name + impl_ver, abi_tag, plat_name)
  131. supported_tags = pep425tags.get_supported(
  132. supplied_platform=plat_name if self.plat_name_supplied else None)
  133. # XXX switch to this alternate implementation for non-pure:
  134. assert tag == supported_tags[0]
  135. return tag
  136. def get_archive_basename(self):
  137. """Return archive name without extension"""
  138. impl_tag, abi_tag, plat_tag = self.get_tag()
  139. archive_basename = "%s-%s-%s-%s" % (
  140. self.wheel_dist_name,
  141. impl_tag,
  142. abi_tag,
  143. plat_tag)
  144. return archive_basename
  145. def run(self):
  146. build_scripts = self.reinitialize_command('build_scripts')
  147. build_scripts.executable = 'python'
  148. if not self.skip_build:
  149. self.run_command('build')
  150. install = self.reinitialize_command('install',
  151. reinit_subcommands=True)
  152. install.root = self.bdist_dir
  153. install.compile = False
  154. install.skip_build = self.skip_build
  155. install.warn_dir = False
  156. # A wheel without setuptools scripts is more cross-platform.
  157. # Use the (undocumented) `no_ep` option to setuptools'
  158. # install_scripts command to avoid creating entry point scripts.
  159. install_scripts = self.reinitialize_command('install_scripts')
  160. install_scripts.no_ep = True
  161. # Use a custom scheme for the archive, because we have to decide
  162. # at installation time which scheme to use.
  163. for key in ('headers', 'scripts', 'data', 'purelib', 'platlib'):
  164. setattr(install,
  165. 'install_' + key,
  166. os.path.join(self.data_dir, key))
  167. basedir_observed = ''
  168. if os.name == 'nt':
  169. # win32 barfs if any of these are ''; could be '.'?
  170. # (distutils.command.install:change_roots bug)
  171. basedir_observed = os.path.normpath(os.path.join(self.data_dir, '..'))
  172. self.install_libbase = self.install_lib = basedir_observed
  173. setattr(install,
  174. 'install_purelib' if self.root_is_pure else 'install_platlib',
  175. basedir_observed)
  176. logger.info("installing to %s", self.bdist_dir)
  177. self.run_command('install')
  178. archive_basename = self.get_archive_basename()
  179. pseudoinstall_root = os.path.join(self.dist_dir, archive_basename)
  180. if not self.relative:
  181. archive_root = self.bdist_dir
  182. else:
  183. archive_root = os.path.join(
  184. self.bdist_dir,
  185. self._ensure_relative(install.install_base))
  186. self.set_undefined_options(
  187. 'install_egg_info', ('target', 'egginfo_dir'))
  188. self.distinfo_dir = os.path.join(self.bdist_dir,
  189. '%s.dist-info' % self.wheel_dist_name)
  190. self.egg2dist(self.egginfo_dir,
  191. self.distinfo_dir)
  192. self.write_wheelfile(self.distinfo_dir)
  193. self.write_record(self.bdist_dir, self.distinfo_dir)
  194. # Make the archive
  195. if not os.path.exists(self.dist_dir):
  196. os.makedirs(self.dist_dir)
  197. wheel_name = archive_wheelfile(pseudoinstall_root, archive_root)
  198. # Sign the archive
  199. if 'WHEEL_TOOL' in os.environ:
  200. subprocess.call([os.environ['WHEEL_TOOL'], 'sign', wheel_name])
  201. # Add to 'Distribution.dist_files' so that the "upload" command works
  202. getattr(self.distribution, 'dist_files', []).append(
  203. ('bdist_wheel', get_python_version(), wheel_name))
  204. if not self.keep_temp:
  205. if self.dry_run:
  206. logger.info('removing %s', self.bdist_dir)
  207. else:
  208. rmtree(self.bdist_dir)
  209. def write_wheelfile(self, wheelfile_base, generator='bdist_wheel (' + wheel.__version__ + ')'):
  210. from email.message import Message
  211. msg = Message()
  212. msg['Wheel-Version'] = '1.0' # of the spec
  213. msg['Generator'] = generator
  214. msg['Root-Is-Purelib'] = str(self.root_is_pure).lower()
  215. # Doesn't work for bdist_wininst
  216. impl_tag, abi_tag, plat_tag = self.get_tag()
  217. for impl in impl_tag.split('.'):
  218. for abi in abi_tag.split('.'):
  219. for plat in plat_tag.split('.'):
  220. msg['Tag'] = '-'.join((impl, abi, plat))
  221. wheelfile_path = os.path.join(wheelfile_base, 'WHEEL')
  222. logger.info('creating %s', wheelfile_path)
  223. with open(wheelfile_path, 'w') as f:
  224. Generator(f, maxheaderlen=0).flatten(msg)
  225. def _ensure_relative(self, path):
  226. # copied from dir_util, deleted
  227. drive, path = os.path.splitdrive(path)
  228. if path[0:1] == os.sep:
  229. path = drive + path[1:]
  230. return path
  231. def _pkginfo_to_metadata(self, egg_info_path, pkginfo_path):
  232. return metadata.pkginfo_to_metadata(egg_info_path, pkginfo_path)
  233. def license_file(self):
  234. """Return license filename from a license-file key in setup.cfg, or None."""
  235. metadata = self.distribution.get_option_dict('metadata')
  236. if not 'license_file' in metadata:
  237. return None
  238. return metadata['license_file'][1]
  239. def setupcfg_requirements(self):
  240. """Generate requirements from setup.cfg as
  241. ('Requires-Dist', 'requirement; qualifier') tuples. From a metadata
  242. section in setup.cfg:
  243. [metadata]
  244. provides-extra = extra1
  245. extra2
  246. requires-dist = requirement; qualifier
  247. another; qualifier2
  248. unqualified
  249. Yields
  250. ('Provides-Extra', 'extra1'),
  251. ('Provides-Extra', 'extra2'),
  252. ('Requires-Dist', 'requirement; qualifier'),
  253. ('Requires-Dist', 'another; qualifier2'),
  254. ('Requires-Dist', 'unqualified')
  255. """
  256. metadata = self.distribution.get_option_dict('metadata')
  257. # our .ini parser folds - to _ in key names:
  258. for key, title in (('provides_extra', 'Provides-Extra'),
  259. ('requires_dist', 'Requires-Dist')):
  260. if not key in metadata:
  261. continue
  262. field = metadata[key]
  263. for line in field[1].splitlines():
  264. line = line.strip()
  265. if not line:
  266. continue
  267. yield (title, line)
  268. def add_requirements(self, metadata_path):
  269. """Add additional requirements from setup.cfg to file metadata_path"""
  270. additional = list(self.setupcfg_requirements())
  271. if not additional: return
  272. pkg_info = read_pkg_info(metadata_path)
  273. if 'Provides-Extra' in pkg_info or 'Requires-Dist' in pkg_info:
  274. warnings.warn('setup.cfg requirements overwrite values from setup.py')
  275. del pkg_info['Provides-Extra']
  276. del pkg_info['Requires-Dist']
  277. for k, v in additional:
  278. pkg_info[k] = v
  279. write_pkg_info(metadata_path, pkg_info)
  280. def egg2dist(self, egginfo_path, distinfo_path):
  281. """Convert an .egg-info directory into a .dist-info directory"""
  282. def adios(p):
  283. """Appropriately delete directory, file or link."""
  284. if os.path.exists(p) and not os.path.islink(p) and os.path.isdir(p):
  285. shutil.rmtree(p)
  286. elif os.path.exists(p):
  287. os.unlink(p)
  288. adios(distinfo_path)
  289. if not os.path.exists(egginfo_path):
  290. # There is no egg-info. This is probably because the egg-info
  291. # file/directory is not named matching the distribution name used
  292. # to name the archive file. Check for this case and report
  293. # accordingly.
  294. import glob
  295. pat = os.path.join(os.path.dirname(egginfo_path), '*.egg-info')
  296. possible = glob.glob(pat)
  297. err = "Egg metadata expected at %s but not found" % (egginfo_path,)
  298. if possible:
  299. alt = os.path.basename(possible[0])
  300. err += " (%s found - possible misnamed archive file?)" % (alt,)
  301. raise ValueError(err)
  302. if os.path.isfile(egginfo_path):
  303. # .egg-info is a single file
  304. pkginfo_path = egginfo_path
  305. pkg_info = self._pkginfo_to_metadata(egginfo_path, egginfo_path)
  306. os.mkdir(distinfo_path)
  307. else:
  308. # .egg-info is a directory
  309. pkginfo_path = os.path.join(egginfo_path, 'PKG-INFO')
  310. pkg_info = self._pkginfo_to_metadata(egginfo_path, pkginfo_path)
  311. # ignore common egg metadata that is useless to wheel
  312. shutil.copytree(egginfo_path, distinfo_path,
  313. ignore=lambda x, y: set(('PKG-INFO',
  314. 'requires.txt',
  315. 'SOURCES.txt',
  316. 'not-zip-safe',)))
  317. # delete dependency_links if it is only whitespace
  318. dependency_links_path = os.path.join(distinfo_path, 'dependency_links.txt')
  319. with open(dependency_links_path, 'r') as dependency_links_file:
  320. dependency_links = dependency_links_file.read().strip()
  321. if not dependency_links:
  322. adios(dependency_links_path)
  323. write_pkg_info(os.path.join(distinfo_path, 'METADATA'), pkg_info)
  324. # XXX deprecated. Still useful for current distribute/setuptools.
  325. metadata_path = os.path.join(distinfo_path, 'METADATA')
  326. self.add_requirements(metadata_path)
  327. # XXX intentionally a different path than the PEP.
  328. metadata_json_path = os.path.join(distinfo_path, 'metadata.json')
  329. pymeta = pkginfo_to_dict(metadata_path,
  330. distribution=self.distribution)
  331. if 'description' in pymeta:
  332. description_filename = 'DESCRIPTION.rst'
  333. description_text = pymeta.pop('description')
  334. description_path = os.path.join(distinfo_path,
  335. description_filename)
  336. with open(description_path, "wb") as description_file:
  337. description_file.write(description_text.encode('utf-8'))
  338. pymeta['extensions']['python.details']['document_names']['description'] = description_filename
  339. # XXX heuristically copy any LICENSE/LICENSE.txt?
  340. license = self.license_file()
  341. if license:
  342. license_filename = 'LICENSE.txt'
  343. shutil.copy(license, os.path.join(self.distinfo_dir, license_filename))
  344. pymeta['extensions']['python.details']['document_names']['license'] = license_filename
  345. with open(metadata_json_path, "w") as metadata_json:
  346. json.dump(pymeta, metadata_json, sort_keys=True)
  347. adios(egginfo_path)
  348. def write_record(self, bdist_dir, distinfo_dir):
  349. from wheel.util import urlsafe_b64encode
  350. record_path = os.path.join(distinfo_dir, 'RECORD')
  351. record_relpath = os.path.relpath(record_path, bdist_dir)
  352. def walk():
  353. for dir, dirs, files in os.walk(bdist_dir):
  354. dirs.sort()
  355. for f in sorted(files):
  356. yield os.path.join(dir, f)
  357. def skip(path):
  358. """Wheel hashes every possible file."""
  359. return (path == record_relpath)
  360. with open_for_csv(record_path, 'w+') as record_file:
  361. writer = csv.writer(record_file)
  362. for path in walk():
  363. relpath = os.path.relpath(path, bdist_dir)
  364. if skip(relpath):
  365. hash = ''
  366. size = ''
  367. else:
  368. with open(path, 'rb') as f:
  369. data = f.read()
  370. digest = hashlib.sha256(data).digest()
  371. hash = 'sha256=' + native(urlsafe_b64encode(digest))
  372. size = len(data)
  373. record_path = os.path.relpath(
  374. path, bdist_dir).replace(os.path.sep, '/')
  375. writer.writerow((record_path, hash, size))