codekingpro/portable-devtools
114k
1"""2Create a wheel (.whl) distribution.3 4A wheel is a built archive format.5"""6 7from __future__ import annotations8 9import os10import re11import shutil12import stat13import struct14import sys15import sysconfig16import warnings17from email.generator import BytesGenerator, Generator18from email.policy import EmailPolicy19from glob import iglob20from shutil import rmtree21from zipfile import ZIP_DEFLATED, ZIP_STORED22 23import setuptools24from setuptools import Command25 26from . import __version__ as wheel_version27from .macosx_libfile import calculate_macosx_platform_tag28from .metadata import pkginfo_to_metadata29from .util import log30from .vendored.packaging import tags31from .vendored.packaging import version as _packaging_version32from .wheelfile import WheelFile33 34 35def safe_name(name):36 """Convert an arbitrary string to a standard distribution name37 Any runs of non-alphanumeric/. characters are replaced with a single '-'.38 """39 return re.sub("[^A-Za-z0-9.]+", "-", name)40 41 42def safe_version(version):43 """44 Convert an arbitrary string to a standard version string45 """46 try:47 # normalize the version48 return str(_packaging_version.Version(version))49 except _packaging_version.InvalidVersion:50 version = version.replace(" ", ".")51 return re.sub("[^A-Za-z0-9.]+", "-", version)52 53 54setuptools_major_version = int(setuptools.__version__.split(".")[0])55 56PY_LIMITED_API_PATTERN = r"cp3\d"57 58 59def _is_32bit_interpreter():60 return struct.calcsize("P") == 461 62 63def python_tag():64 return f"py{sys.version_info[0]}"65 66 67def get_platform(archive_root):68 """Return our platform name 'win32', 'linux_x86_64'"""69 result = sysconfig.get_platform()70 if result.startswith("macosx") and archive_root is not None:71 result = calculate_macosx_platform_tag(archive_root, result)72 elif _is_32bit_interpreter():73 if result == "linux-x86_64":74 # pip pull request #349775 result = "linux-i686"76 elif result == "linux-aarch64":77 # packaging pull request #23478 # TODO armv8l, packaging pull request #690 => this did not land79 # in pip/packaging yet80 result = "linux-armv7l"81 82 return result.replace("-", "_")83 84 85def get_flag(var, fallback, expected=True, warn=True):86 """Use a fallback value for determining SOABI flags if the needed config87 var is unset or unavailable."""88 val = sysconfig.get_config_var(var)89 if val is None:90 if warn:91 warnings.warn(92 f"Config variable '{var}' is unset, Python ABI tag may " "be incorrect",93 RuntimeWarning,94 stacklevel=2,95 )96 return fallback97 return val == expected98 99 100def get_abi_tag():101 """Return the ABI tag based on SOABI (if available) or emulate SOABI (PyPy2)."""102 soabi = sysconfig.get_config_var("SOABI")103 impl = tags.interpreter_name()104 if not soabi and impl in ("cp", "pp") and hasattr(sys, "maxunicode"):105 d = ""106 m = ""107 u = ""108 if get_flag("Py_DEBUG", hasattr(sys, "gettotalrefcount"), warn=(impl == "cp")):109 d = "d"110 111 if get_flag(112 "WITH_PYMALLOC",113 impl == "cp",114 warn=(impl == "cp" and sys.version_info < (3, 8)),115 ) and sys.version_info < (3, 8):116 m = "m"117 118 abi = f"{impl}{tags.interpreter_version()}{d}{m}{u}"119 elif soabi and impl == "cp" and soabi.startswith("cpython"):120 # non-Windows121 abi = "cp" + soabi.split("-")[1]122 elif soabi and impl == "cp" and soabi.startswith("cp"):123 # Windows124 abi = soabi.split("-")[0]125 elif soabi and impl == "pp":126 # we want something like pypy36-pp73127 abi = "-".join(soabi.split("-")[:2])128 abi = abi.replace(".", "_").replace("-", "_")129 elif soabi and impl == "graalpy":130 abi = "-".join(soabi.split("-")[:3])131 abi = abi.replace(".", "_").replace("-", "_")132 elif soabi:133 abi = soabi.replace(".", "_").replace("-", "_")134 else:135 abi = None136 137 return abi138 139 140def safer_name(name):141 return safe_name(name).replace("-", "_")142 143 144def safer_version(version):145 return safe_version(version).replace("-", "_")146 147 148def remove_readonly(func, path, excinfo):149 remove_readonly_exc(func, path, excinfo[1])150 151 152def remove_readonly_exc(func, path, exc):153 os.chmod(path, stat.S_IWRITE)154 func(path)155 156 157class bdist_wheel(Command):158 description = "create a wheel distribution"159 160 supported_compressions = {161 "stored": ZIP_STORED,162 "deflated": ZIP_DEFLATED,163 }164 165 user_options = [166 ("bdist-dir=", "b", "temporary directory for creating the distribution"),167 (168 "plat-name=",169 "p",170 "platform name to embed in generated filenames "171 "(default: %s)" % get_platform(None),172 ),173 (174 "keep-temp",175 "k",176 "keep the pseudo-installation tree around after "177 "creating the distribution archive",178 ),179 ("dist-dir=", "d", "directory to put final built distributions in"),180 ("skip-build", None, "skip rebuilding everything (for testing/debugging)"),181 (182 "relative",183 None,184 "build the archive using relative paths " "(default: false)",185 ),186 (187 "owner=",188 "u",189 "Owner name used when creating a tar file" " [default: current user]",190 ),191 (192 "group=",193 "g",194 "Group name used when creating a tar file" " [default: current group]",195 ),196 ("universal", None, "make a universal wheel" " (default: false)"),197 (198 "compression=",199 None,200 "zipfile compression (one of: {})" " (default: 'deflated')".format(201 ", ".join(supported_compressions)202 ),203 ),204 (205 "python-tag=",206 None,207 "Python implementation compatibility tag"208 " (default: '%s')" % (python_tag()),209 ),210 (211 "build-number=",212 None,213 "Build number for this particular version. "214 "As specified in PEP-0427, this must start with a digit. "215 "[default: None]",216 ),217 (218 "py-limited-api=",219 None,220 "Python tag (cp32|cp33|cpNN) for abi3 wheel tag" " (default: false)",221 ),222 ]223 224 boolean_options = ["keep-temp", "skip-build", "relative", "universal"]225 226 def initialize_options(self):227 self.bdist_dir = None228 self.data_dir = None229 self.plat_name = None230 self.plat_tag = None231 self.format = "zip"232 self.keep_temp = False233 self.dist_dir = None234 self.egginfo_dir = None235 self.root_is_pure = None236 self.skip_build = None237 self.relative = False238 self.owner = None239 self.group = None240 self.universal = False241 self.compression = "deflated"242 self.python_tag = python_tag()243 self.build_number = None244 self.py_limited_api = False245 self.plat_name_supplied = False246 247 def finalize_options(self):248 if self.bdist_dir is None:249 bdist_base = self.get_finalized_command("bdist").bdist_base250 self.bdist_dir = os.path.join(bdist_base, "wheel")251 252 egg_info = self.distribution.get_command_obj("egg_info")253 egg_info.ensure_finalized() # needed for correct `wheel_dist_name`254 255 self.data_dir = self.wheel_dist_name + ".data"256 self.plat_name_supplied = self.plat_name is not None257 258 try:259 self.compression = self.supported_compressions[self.compression]260 except KeyError:261 raise ValueError(f"Unsupported compression: {self.compression}") from None262 263 need_options = ("dist_dir", "plat_name", "skip_build")264 265 self.set_undefined_options("bdist", *zip(need_options, need_options))266 267 self.root_is_pure = not (268 self.distribution.has_ext_modules() or self.distribution.has_c_libraries()269 )270 271 if self.py_limited_api and not re.match(272 PY_LIMITED_API_PATTERN, self.py_limited_api273 ):274 raise ValueError("py-limited-api must match '%s'" % PY_LIMITED_API_PATTERN)275 276 # Support legacy [wheel] section for setting universal277 wheel = self.distribution.get_option_dict("wheel")278 if "universal" in wheel:279 # please don't define this in your global configs280 log.warning(281 "The [wheel] section is deprecated. Use [bdist_wheel] instead.",282 )283 val = wheel["universal"][1].strip()284 if val.lower() in ("1", "true", "yes"):285 self.universal = True286 287 if self.build_number is not None and not self.build_number[:1].isdigit():288 raise ValueError("Build tag (build-number) must start with a digit.")289 290 @property291 def wheel_dist_name(self):292 """Return distribution full name with - replaced with _"""293 components = (294 safer_name(self.distribution.get_name()),295 safer_version(self.distribution.get_version()),296 )297 if self.build_number:298 components += (self.build_number,)299 return "-".join(components)300 301 def get_tag(self):302 # bdist sets self.plat_name if unset, we should only use it for purepy303 # wheels if the user supplied it.304 if self.plat_name_supplied:305 plat_name = self.plat_name306 elif self.root_is_pure:307 plat_name = "any"308 else:309 # macosx contains system version in platform name so need special handle310 if self.plat_name and not self.plat_name.startswith("macosx"):311 plat_name = self.plat_name312 else:313 # on macosx always limit the platform name to comply with any314 # c-extension modules in bdist_dir, since the user can specify315 # a higher MACOSX_DEPLOYMENT_TARGET via tools like CMake316 317 # on other platforms, and on macosx if there are no c-extension318 # modules, use the default platform name.319 plat_name = get_platform(self.bdist_dir)320 321 if _is_32bit_interpreter():322 if plat_name in ("linux-x86_64", "linux_x86_64"):323 plat_name = "linux_i686"324 if plat_name in ("linux-aarch64", "linux_aarch64"):325 # TODO armv8l, packaging pull request #690 => this did not land326 # in pip/packaging yet327 plat_name = "linux_armv7l"328 329 plat_name = (330 plat_name.lower().replace("-", "_").replace(".", "_").replace(" ", "_")331 )332 333 if self.root_is_pure:334 if self.universal:335 impl = "py2.py3"336 else:337 impl = self.python_tag338 tag = (impl, "none", plat_name)339 else:340 impl_name = tags.interpreter_name()341 impl_ver = tags.interpreter_version()342 impl = impl_name + impl_ver343 # We don't work on CPython 3.1, 3.0.344 if self.py_limited_api and (impl_name + impl_ver).startswith("cp3"):345 impl = self.py_limited_api346 abi_tag = "abi3"347 else:348 abi_tag = str(get_abi_tag()).lower()349 tag = (impl, abi_tag, plat_name)350 # issue gh-374: allow overriding plat_name351 supported_tags = [352 (t.interpreter, t.abi, plat_name) for t in tags.sys_tags()353 ]354 assert (355 tag in supported_tags356 ), f"would build wheel with unsupported tag {tag}"357 return tag358 359 def run(self):360 build_scripts = self.reinitialize_command("build_scripts")361 build_scripts.executable = "python"362 build_scripts.force = True363 364 build_ext = self.reinitialize_command("build_ext")365 build_ext.inplace = False366 367 if not self.skip_build:368 self.run_command("build")369 370 install = self.reinitialize_command("install", reinit_subcommands=True)371 install.root = self.bdist_dir372 install.compile = False373 install.skip_build = self.skip_build374 install.warn_dir = False375 376 # A wheel without setuptools scripts is more cross-platform.377 # Use the (undocumented) `no_ep` option to setuptools'378 # install_scripts command to avoid creating entry point scripts.379 install_scripts = self.reinitialize_command("install_scripts")380 install_scripts.no_ep = True381 382 # Use a custom scheme for the archive, because we have to decide383 # at installation time which scheme to use.384 for key in ("headers", "scripts", "data", "purelib", "platlib"):385 setattr(install, "install_" + key, os.path.join(self.data_dir, key))386 387 basedir_observed = ""388 389 if os.name == "nt":390 # win32 barfs if any of these are ''; could be '.'?391 # (distutils.command.install:change_roots bug)392 basedir_observed = os.path.normpath(os.path.join(self.data_dir, ".."))393 self.install_libbase = self.install_lib = basedir_observed394 395 setattr(396 install,397 "install_purelib" if self.root_is_pure else "install_platlib",398 basedir_observed,399 )400 401 log.info(f"installing to {self.bdist_dir}")402 403 self.run_command("install")404 405 impl_tag, abi_tag, plat_tag = self.get_tag()406 archive_basename = f"{self.wheel_dist_name}-{impl_tag}-{abi_tag}-{plat_tag}"407 if not self.relative:408 archive_root = self.bdist_dir409 else:410 archive_root = os.path.join(411 self.bdist_dir, self._ensure_relative(install.install_base)412 )413 414 self.set_undefined_options("install_egg_info", ("target", "egginfo_dir"))415 distinfo_dirname = (416 f"{safer_name(self.distribution.get_name())}-"417 f"{safer_version(self.distribution.get_version())}.dist-info"418 )419 distinfo_dir = os.path.join(self.bdist_dir, distinfo_dirname)420 self.egg2dist(self.egginfo_dir, distinfo_dir)421 422 self.write_wheelfile(distinfo_dir)423 424 # Make the archive425 if not os.path.exists(self.dist_dir):426 os.makedirs(self.dist_dir)427 428 wheel_path = os.path.join(self.dist_dir, archive_basename + ".whl")429 with WheelFile(wheel_path, "w", self.compression) as wf:430 wf.write_files(archive_root)431 432 # Add to 'Distribution.dist_files' so that the "upload" command works433 getattr(self.distribution, "dist_files", []).append(434 (435 "bdist_wheel",436 "{}.{}".format(*sys.version_info[:2]), # like 3.7437 wheel_path,438 )439 )440 441 if not self.keep_temp:442 log.info(f"removing {self.bdist_dir}")443 if not self.dry_run:444 if sys.version_info < (3, 12):445 rmtree(self.bdist_dir, onerror=remove_readonly)446 else:447 rmtree(self.bdist_dir, onexc=remove_readonly_exc)448 449 def write_wheelfile(450 self, wheelfile_base, generator="bdist_wheel (" + wheel_version + ")"451 ):452 from email.message import Message453 454 msg = Message()455 msg["Wheel-Version"] = "1.0" # of the spec456 msg["Generator"] = generator457 msg["Root-Is-Purelib"] = str(self.root_is_pure).lower()458 if self.build_number is not None:459 msg["Build"] = self.build_number460 461 # Doesn't work for bdist_wininst462 impl_tag, abi_tag, plat_tag = self.get_tag()463 for impl in impl_tag.split("."):464 for abi in abi_tag.split("."):465 for plat in plat_tag.split("."):466 msg["Tag"] = "-".join((impl, abi, plat))467 468 wheelfile_path = os.path.join(wheelfile_base, "WHEEL")469 log.info(f"creating {wheelfile_path}")470 with open(wheelfile_path, "wb") as f:471 BytesGenerator(f, maxheaderlen=0).flatten(msg)472 473 def _ensure_relative(self, path):474 # copied from dir_util, deleted475 drive, path = os.path.splitdrive(path)476 if path[0:1] == os.sep:477 path = drive + path[1:]478 return path479 480 @property481 def license_paths(self):482 if setuptools_major_version >= 57:483 # Setuptools has resolved any patterns to actual file names484 return self.distribution.metadata.license_files or ()485 486 files = set()487 metadata = self.distribution.get_option_dict("metadata")488 if setuptools_major_version >= 42:489 # Setuptools recognizes the license_files option but does not do globbing490 patterns = self.distribution.metadata.license_files491 else:492 # Prior to those, wheel is entirely responsible for handling license files493 if "license_files" in metadata:494 patterns = metadata["license_files"][1].split()495 else:496 patterns = ()497 498 if "license_file" in metadata:499 warnings.warn(500 'The "license_file" option is deprecated. Use "license_files" instead.',501 DeprecationWarning,502 stacklevel=2,503 )504 files.add(metadata["license_file"][1])505 506 if not files and not patterns and not isinstance(patterns, list):507 patterns = ("LICEN[CS]E*", "COPYING*", "NOTICE*", "AUTHORS*")508 509 for pattern in patterns:510 for path in iglob(pattern):511 if path.endswith("~"):512 log.debug(513 f'ignoring license file "{path}" as it looks like a backup'514 )515 continue516 517 if path not in files and os.path.isfile(path):518 log.info(519 f'adding license file "{path}" (matched pattern "{pattern}")'520 )521 files.add(path)522 523 return files524 525 def egg2dist(self, egginfo_path, distinfo_path):526 """Convert an .egg-info directory into a .dist-info directory"""527 528 def adios(p):529 """Appropriately delete directory, file or link."""530 if os.path.exists(p) and not os.path.islink(p) and os.path.isdir(p):531 shutil.rmtree(p)532 elif os.path.exists(p):533 os.unlink(p)534 535 adios(distinfo_path)536 537 if not os.path.exists(egginfo_path):538 # There is no egg-info. This is probably because the egg-info539 # file/directory is not named matching the distribution name used540 # to name the archive file. Check for this case and report541 # accordingly.542 import glob543 544 pat = os.path.join(os.path.dirname(egginfo_path), "*.egg-info")545 possible = glob.glob(pat)546 err = f"Egg metadata expected at {egginfo_path} but not found"547 if possible:548 alt = os.path.basename(possible[0])549 err += f" ({alt} found - possible misnamed archive file?)"550 551 raise ValueError(err)552 553 if os.path.isfile(egginfo_path):554 # .egg-info is a single file555 pkginfo_path = egginfo_path556 pkg_info = pkginfo_to_metadata(egginfo_path, egginfo_path)557 os.mkdir(distinfo_path)558 else:559 # .egg-info is a directory560 pkginfo_path = os.path.join(egginfo_path, "PKG-INFO")561 pkg_info = pkginfo_to_metadata(egginfo_path, pkginfo_path)562 563 # ignore common egg metadata that is useless to wheel564 shutil.copytree(565 egginfo_path,566 distinfo_path,567 ignore=lambda x, y: {568 "PKG-INFO",569 "requires.txt",570 "SOURCES.txt",571 "not-zip-safe",572 },573 )574 575 # delete dependency_links if it is only whitespace576 dependency_links_path = os.path.join(distinfo_path, "dependency_links.txt")577 with open(dependency_links_path, encoding="utf-8") as dependency_links_file:578 dependency_links = dependency_links_file.read().strip()579 if not dependency_links:580 adios(dependency_links_path)581 582 pkg_info_path = os.path.join(distinfo_path, "METADATA")583 serialization_policy = EmailPolicy(584 utf8=True,585 mangle_from_=False,586 max_line_length=0,587 )588 with open(pkg_info_path, "w", encoding="utf-8") as out:589 Generator(out, policy=serialization_policy).flatten(pkg_info)590 591 for license_path in self.license_paths:592 filename = os.path.basename(license_path)593 shutil.copy(license_path, os.path.join(distinfo_path, filename))594 595 adios(egginfo_path)596 