bdist_wheel.py 17 KB

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