1""" 2Create a wheel (.whl) distribution. 3 4A wheel is a built archive format. 5""" 6 7import distutils 8import os 9import shutil 10import stat 11import sys 12import re 13import warnings 14from collections import OrderedDict 15from distutils.core import Command 16from distutils import log as logger 17from io import BytesIO 18from glob import iglob 19from shutil import rmtree 20from sysconfig import get_config_var 21from zipfile import ZIP_DEFLATED, ZIP_STORED 22 23import pkg_resources 24 25from .pkginfo import write_pkg_info 26from .macosx_libfile import calculate_macosx_platform_tag 27from .metadata import pkginfo_to_metadata 28from .vendored.packaging import tags 29from .wheelfile import WheelFile 30from . import __version__ as wheel_version 31 32if sys.version_info < (3,): 33 from email.generator import Generator as BytesGenerator 34else: 35 from email.generator import BytesGenerator 36 37safe_name = pkg_resources.safe_name 38safe_version = pkg_resources.safe_version 39 40PY_LIMITED_API_PATTERN = r'cp3\d' 41 42 43def python_tag(): 44 return 'py{}'.format(sys.version_info[0]) 45 46 47def get_platform(archive_root): 48 """Return our platform name 'win32', 'linux_x86_64'""" 49 # XXX remove distutils dependency 50 result = distutils.util.get_platform() 51 if result.startswith("macosx") and archive_root is not None: 52 result = calculate_macosx_platform_tag(archive_root, result) 53 if result == "linux_x86_64" and sys.maxsize == 2147483647: 54 # pip pull request #3497 55 result = "linux_i686" 56 return result 57 58 59def get_flag(var, fallback, expected=True, warn=True): 60 """Use a fallback value for determining SOABI flags if the needed config 61 var is unset or unavailable.""" 62 val = get_config_var(var) 63 if val is None: 64 if warn: 65 warnings.warn("Config variable '{0}' is unset, Python ABI tag may " 66 "be incorrect".format(var), RuntimeWarning, 2) 67 return fallback 68 return val == expected 69 70 71def get_abi_tag(): 72 """Return the ABI tag based on SOABI (if available) or emulate SOABI 73 (CPython 2, PyPy).""" 74 soabi = get_config_var('SOABI') 75 impl = tags.interpreter_name() 76 if not soabi and impl in ('cp', 'pp') and hasattr(sys, 'maxunicode'): 77 d = '' 78 m = '' 79 u = '' 80 if get_flag('Py_DEBUG', 81 hasattr(sys, 'gettotalrefcount'), 82 warn=(impl == 'cp')): 83 d = 'd' 84 if get_flag('WITH_PYMALLOC', 85 impl == 'cp', 86 warn=(impl == 'cp' and 87 sys.version_info < (3, 8))) \ 88 and sys.version_info < (3, 8): 89 m = 'm' 90 if get_flag('Py_UNICODE_SIZE', 91 sys.maxunicode == 0x10ffff, 92 expected=4, 93 warn=(impl == 'cp' and 94 sys.version_info < (3, 3))) \ 95 and sys.version_info < (3, 3): 96 u = 'u' 97 abi = '%s%s%s%s%s' % (impl, tags.interpreter_version(), d, m, u) 98 elif soabi and soabi.startswith('cpython-'): 99 abi = 'cp' + soabi.split('-')[1] 100 elif soabi and soabi.startswith('pypy-'): 101 # we want something like pypy36-pp73 102 abi = '-'.join(soabi.split('-')[:2]) 103 abi = abi.replace('.', '_').replace('-', '_') 104 elif soabi: 105 abi = soabi.replace('.', '_').replace('-', '_') 106 else: 107 abi = None 108 return abi 109 110 111def safer_name(name): 112 return safe_name(name).replace('-', '_') 113 114 115def safer_version(version): 116 return safe_version(version).replace('-', '_') 117 118 119def remove_readonly(func, path, excinfo): 120 print(str(excinfo[1])) 121 os.chmod(path, stat.S_IWRITE) 122 func(path) 123 124 125class bdist_wheel(Command): 126 127 description = 'create a wheel distribution' 128 129 supported_compressions = OrderedDict([ 130 ('stored', ZIP_STORED), 131 ('deflated', ZIP_DEFLATED) 132 ]) 133 134 user_options = [('bdist-dir=', 'b', 135 "temporary directory for creating the distribution"), 136 ('plat-name=', 'p', 137 "platform name to embed in generated filenames " 138 "(default: %s)" % get_platform(None)), 139 ('keep-temp', 'k', 140 "keep the pseudo-installation tree around after " + 141 "creating the distribution archive"), 142 ('dist-dir=', 'd', 143 "directory to put final built distributions in"), 144 ('skip-build', None, 145 "skip rebuilding everything (for testing/debugging)"), 146 ('relative', None, 147 "build the archive using relative paths " 148 "(default: false)"), 149 ('owner=', 'u', 150 "Owner name used when creating a tar file" 151 " [default: current user]"), 152 ('group=', 'g', 153 "Group name used when creating a tar file" 154 " [default: current group]"), 155 ('universal', None, 156 "make a universal wheel" 157 " (default: false)"), 158 ('compression=', None, 159 "zipfile compression (one of: {})" 160 " (default: 'deflated')" 161 .format(', '.join(supported_compressions))), 162 ('python-tag=', None, 163 "Python implementation compatibility tag" 164 " (default: '%s')" % (python_tag())), 165 ('build-number=', None, 166 "Build number for this particular version. " 167 "As specified in PEP-0427, this must start with a digit. " 168 "[default: None]"), 169 ('py-limited-api=', None, 170 "Python tag (cp32|cp33|cpNN) for abi3 wheel tag" 171 " (default: false)"), 172 ] 173 174 boolean_options = ['keep-temp', 'skip-build', 'relative', 'universal'] 175 176 def initialize_options(self): 177 self.bdist_dir = None 178 self.data_dir = None 179 self.plat_name = None 180 self.plat_tag = None 181 self.format = 'zip' 182 self.keep_temp = False 183 self.dist_dir = None 184 self.egginfo_dir = None 185 self.root_is_pure = None 186 self.skip_build = None 187 self.relative = False 188 self.owner = None 189 self.group = None 190 self.universal = False 191 self.compression = 'deflated' 192 self.python_tag = python_tag() 193 self.build_number = None 194 self.py_limited_api = False 195 self.plat_name_supplied = False 196 197 def finalize_options(self): 198 if self.bdist_dir is None: 199 bdist_base = self.get_finalized_command('bdist').bdist_base 200 self.bdist_dir = os.path.join(bdist_base, 'wheel') 201 202 self.data_dir = self.wheel_dist_name + '.data' 203 self.plat_name_supplied = self.plat_name is not None 204 205 try: 206 self.compression = self.supported_compressions[self.compression] 207 except KeyError: 208 raise ValueError('Unsupported compression: {}'.format(self.compression)) 209 210 need_options = ('dist_dir', 'plat_name', 'skip_build') 211 212 self.set_undefined_options('bdist', 213 *zip(need_options, need_options)) 214 215 self.root_is_pure = not (self.distribution.has_ext_modules() 216 or self.distribution.has_c_libraries()) 217 218 if self.py_limited_api and not re.match(PY_LIMITED_API_PATTERN, self.py_limited_api): 219 raise ValueError("py-limited-api must match '%s'" % PY_LIMITED_API_PATTERN) 220 221 # Support legacy [wheel] section for setting universal 222 wheel = self.distribution.get_option_dict('wheel') 223 if 'universal' in wheel: 224 # please don't define this in your global configs 225 logger.warn('The [wheel] section is deprecated. Use [bdist_wheel] instead.') 226 val = wheel['universal'][1].strip() 227 if val.lower() in ('1', 'true', 'yes'): 228 self.universal = True 229 230 if self.build_number is not None and not self.build_number[:1].isdigit(): 231 raise ValueError("Build tag (build-number) must start with a digit.") 232 233 @property 234 def wheel_dist_name(self): 235 """Return distribution full name with - replaced with _""" 236 components = (safer_name(self.distribution.get_name()), 237 safer_version(self.distribution.get_version())) 238 if self.build_number: 239 components += (self.build_number,) 240 return '-'.join(components) 241 242 def get_tag(self): 243 # bdist sets self.plat_name if unset, we should only use it for purepy 244 # wheels if the user supplied it. 245 if self.plat_name_supplied: 246 plat_name = self.plat_name 247 elif self.root_is_pure: 248 plat_name = 'any' 249 else: 250 # macosx contains system version in platform name so need special handle 251 if self.plat_name and not self.plat_name.startswith("macosx"): 252 plat_name = self.plat_name 253 else: 254 # on macosx always limit the platform name to comply with any 255 # c-extension modules in bdist_dir, since the user can specify 256 # a higher MACOSX_DEPLOYMENT_TARGET via tools like CMake 257 258 # on other platforms, and on macosx if there are no c-extension 259 # modules, use the default platform name. 260 plat_name = get_platform(self.bdist_dir) 261 262 if plat_name in ('linux-x86_64', 'linux_x86_64') and sys.maxsize == 2147483647: 263 plat_name = 'linux_i686' 264 265 plat_name = plat_name.lower().replace('-', '_').replace('.', '_') 266 267 if self.root_is_pure: 268 if self.universal: 269 impl = 'py2.py3' 270 else: 271 impl = self.python_tag 272 tag = (impl, 'none', plat_name) 273 else: 274 impl_name = tags.interpreter_name() 275 impl_ver = tags.interpreter_version() 276 impl = impl_name + impl_ver 277 # We don't work on CPython 3.1, 3.0. 278 if self.py_limited_api and (impl_name + impl_ver).startswith('cp3'): 279 impl = self.py_limited_api 280 abi_tag = 'abi3' 281 else: 282 abi_tag = str(get_abi_tag()).lower() 283 tag = (impl, abi_tag, plat_name) 284 # issue gh-374: allow overriding plat_name 285 supported_tags = [(t.interpreter, t.abi, plat_name) 286 for t in tags.sys_tags()] 287 assert tag in supported_tags, "would build wheel with unsupported tag {}".format(tag) 288 return tag 289 290 def run(self): 291 build_scripts = self.reinitialize_command('build_scripts') 292 build_scripts.executable = 'python' 293 build_scripts.force = True 294 295 build_ext = self.reinitialize_command('build_ext') 296 build_ext.inplace = False 297 298 if not self.skip_build: 299 self.run_command('build') 300 301 install = self.reinitialize_command('install', 302 reinit_subcommands=True) 303 install.root = self.bdist_dir 304 install.compile = False 305 install.skip_build = self.skip_build 306 install.warn_dir = False 307 308 # A wheel without setuptools scripts is more cross-platform. 309 # Use the (undocumented) `no_ep` option to setuptools' 310 # install_scripts command to avoid creating entry point scripts. 311 install_scripts = self.reinitialize_command('install_scripts') 312 install_scripts.no_ep = True 313 314 # Use a custom scheme for the archive, because we have to decide 315 # at installation time which scheme to use. 316 for key in ('headers', 'scripts', 'data', 'purelib', 'platlib'): 317 setattr(install, 318 'install_' + key, 319 os.path.join(self.data_dir, key)) 320 321 basedir_observed = '' 322 323 if os.name == 'nt': 324 # win32 barfs if any of these are ''; could be '.'? 325 # (distutils.command.install:change_roots bug) 326 basedir_observed = os.path.normpath(os.path.join(self.data_dir, '..')) 327 self.install_libbase = self.install_lib = basedir_observed 328 329 setattr(install, 330 'install_purelib' if self.root_is_pure else 'install_platlib', 331 basedir_observed) 332 333 logger.info("installing to %s", self.bdist_dir) 334 335 self.run_command('install') 336 337 impl_tag, abi_tag, plat_tag = self.get_tag() 338 archive_basename = "{}-{}-{}-{}".format(self.wheel_dist_name, impl_tag, abi_tag, plat_tag) 339 if not self.relative: 340 archive_root = self.bdist_dir 341 else: 342 archive_root = os.path.join( 343 self.bdist_dir, 344 self._ensure_relative(install.install_base)) 345 346 self.set_undefined_options('install_egg_info', ('target', 'egginfo_dir')) 347 distinfo_dirname = '{}-{}.dist-info'.format( 348 safer_name(self.distribution.get_name()), 349 safer_version(self.distribution.get_version())) 350 distinfo_dir = os.path.join(self.bdist_dir, distinfo_dirname) 351 self.egg2dist(self.egginfo_dir, distinfo_dir) 352 353 self.write_wheelfile(distinfo_dir) 354 355 # Make the archive 356 if not os.path.exists(self.dist_dir): 357 os.makedirs(self.dist_dir) 358 359 wheel_path = os.path.join(self.dist_dir, archive_basename + '.whl') 360 with WheelFile(wheel_path, 'w', self.compression) as wf: 361 wf.write_files(archive_root) 362 363 # Add to 'Distribution.dist_files' so that the "upload" command works 364 getattr(self.distribution, 'dist_files', []).append( 365 ('bdist_wheel', 366 '{}.{}'.format(*sys.version_info[:2]), # like 3.7 367 wheel_path)) 368 369 if not self.keep_temp: 370 logger.info('removing %s', self.bdist_dir) 371 if not self.dry_run: 372 rmtree(self.bdist_dir, onerror=remove_readonly) 373 374 def write_wheelfile(self, wheelfile_base, generator='bdist_wheel (' + wheel_version + ')'): 375 from email.message import Message 376 377 # Workaround for Python 2.7 for when "generator" is unicode 378 if sys.version_info < (3,) and not isinstance(generator, str): 379 generator = generator.encode('utf-8') 380 381 msg = Message() 382 msg['Wheel-Version'] = '1.0' # of the spec 383 msg['Generator'] = generator 384 msg['Root-Is-Purelib'] = str(self.root_is_pure).lower() 385 if self.build_number is not None: 386 msg['Build'] = self.build_number 387 388 # Doesn't work for bdist_wininst 389 impl_tag, abi_tag, plat_tag = self.get_tag() 390 for impl in impl_tag.split('.'): 391 for abi in abi_tag.split('.'): 392 for plat in plat_tag.split('.'): 393 msg['Tag'] = '-'.join((impl, abi, plat)) 394 395 wheelfile_path = os.path.join(wheelfile_base, 'WHEEL') 396 logger.info('creating %s', wheelfile_path) 397 buffer = BytesIO() 398 BytesGenerator(buffer, maxheaderlen=0).flatten(msg) 399 with open(wheelfile_path, 'wb') as f: 400 f.write(buffer.getvalue().replace(b'\r\n', b'\r')) 401 402 def _ensure_relative(self, path): 403 # copied from dir_util, deleted 404 drive, path = os.path.splitdrive(path) 405 if path[0:1] == os.sep: 406 path = drive + path[1:] 407 return path 408 409 @property 410 def license_paths(self): 411 metadata = self.distribution.get_option_dict('metadata') 412 files = set() 413 patterns = sorted({ 414 option for option in metadata.get('license_files', ('', ''))[1].split() 415 }) 416 417 if 'license_file' in metadata: 418 warnings.warn('The "license_file" option is deprecated. Use ' 419 '"license_files" instead.', DeprecationWarning) 420 files.add(metadata['license_file'][1]) 421 422 if 'license_file' not in metadata and 'license_files' not in metadata: 423 patterns = ('LICEN[CS]E*', 'COPYING*', 'NOTICE*', 'AUTHORS*') 424 425 for pattern in patterns: 426 for path in iglob(pattern): 427 if path.endswith('~'): 428 logger.debug('ignoring license file "%s" as it looks like a backup', path) 429 continue 430 431 if path not in files and os.path.isfile(path): 432 logger.info('adding license file "%s" (matched pattern "%s")', path, pattern) 433 files.add(path) 434 435 return files 436 437 def egg2dist(self, egginfo_path, distinfo_path): 438 """Convert an .egg-info directory into a .dist-info directory""" 439 def adios(p): 440 """Appropriately delete directory, file or link.""" 441 if os.path.exists(p) and not os.path.islink(p) and os.path.isdir(p): 442 shutil.rmtree(p) 443 elif os.path.exists(p): 444 os.unlink(p) 445 446 adios(distinfo_path) 447 448 if not os.path.exists(egginfo_path): 449 # There is no egg-info. This is probably because the egg-info 450 # file/directory is not named matching the distribution name used 451 # to name the archive file. Check for this case and report 452 # accordingly. 453 import glob 454 pat = os.path.join(os.path.dirname(egginfo_path), '*.egg-info') 455 possible = glob.glob(pat) 456 err = "Egg metadata expected at %s but not found" % (egginfo_path,) 457 if possible: 458 alt = os.path.basename(possible[0]) 459 err += " (%s found - possible misnamed archive file?)" % (alt,) 460 461 raise ValueError(err) 462 463 if os.path.isfile(egginfo_path): 464 # .egg-info is a single file 465 pkginfo_path = egginfo_path 466 pkg_info = pkginfo_to_metadata(egginfo_path, egginfo_path) 467 os.mkdir(distinfo_path) 468 else: 469 # .egg-info is a directory 470 pkginfo_path = os.path.join(egginfo_path, 'PKG-INFO') 471 pkg_info = pkginfo_to_metadata(egginfo_path, pkginfo_path) 472 473 # ignore common egg metadata that is useless to wheel 474 shutil.copytree(egginfo_path, distinfo_path, 475 ignore=lambda x, y: {'PKG-INFO', 'requires.txt', 'SOURCES.txt', 476 'not-zip-safe'} 477 ) 478 479 # delete dependency_links if it is only whitespace 480 dependency_links_path = os.path.join(distinfo_path, 'dependency_links.txt') 481 with open(dependency_links_path, 'r') as dependency_links_file: 482 dependency_links = dependency_links_file.read().strip() 483 if not dependency_links: 484 adios(dependency_links_path) 485 486 write_pkg_info(os.path.join(distinfo_path, 'METADATA'), pkg_info) 487 488 for license_path in self.license_paths: 489 filename = os.path.basename(license_path) 490 shutil.copy(license_path, os.path.join(distinfo_path, filename)) 491 492 adios(egginfo_path) 493