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 struct13import sys14import sysconfig15import warnings16from collections.abc import Iterable, Sequence17from email.generator import BytesGenerator18from glob import iglob19from typing import Literal, cast20from zipfile import ZIP_DEFLATED, ZIP_STORED21 22from packaging import tags, version as _packaging_version23from wheel.wheelfile import WheelFile24 25from .. import Command, __version__, _shutil26from .._core_metadata import _safe_license_file27from .._normalization import safer_name28from ..warnings import SetuptoolsDeprecationWarning29from .egg_info import egg_info as egg_info_cls30 31from distutils import log32 33 34def safe_version(version: str) -> str:35 """36 Convert an arbitrary string to a standard version string37 """38 try:39 # normalize the version40 return str(_packaging_version.Version(version))41 except _packaging_version.InvalidVersion:42 version = version.replace(" ", ".")43 return re.sub("[^A-Za-z0-9.]+", "-", version)44 45 46setuptools_major_version = int(__version__.split(".")[0])47 48PY_LIMITED_API_PATTERN = r"cp3\d"49 50 51def _is_32bit_interpreter() -> bool:52 return struct.calcsize("P") == 453 54 55def python_tag() -> str:56 return f"py{sys.version_info.major}"57 58 59def get_platform(archive_root: str | None) -> str:60 """Return our platform name 'win32', 'linux_x86_64'"""61 result = sysconfig.get_platform()62 if result.startswith("macosx") and archive_root is not None: # pragma: no cover63 from wheel.macosx_libfile import calculate_macosx_platform_tag64 65 result = calculate_macosx_platform_tag(archive_root, result)66 elif _is_32bit_interpreter():67 if result == "linux-x86_64":68 # pip pull request #349769 result = "linux-i686"70 elif result == "linux-aarch64":71 # packaging pull request #23472 # TODO armv8l, packaging pull request #690 => this did not land73 # in pip/packaging yet74 result = "linux-armv7l"75 76 return result.replace("-", "_")77 78 79def get_flag(80 var: str, fallback: bool, expected: bool = True, warn: bool = True81) -> bool:82 """Use a fallback value for determining SOABI flags if the needed config83 var is unset or unavailable."""84 val = sysconfig.get_config_var(var)85 if val is None:86 if warn:87 warnings.warn(88 f"Config variable '{var}' is unset, Python ABI tag may be incorrect",89 RuntimeWarning,90 stacklevel=2,91 )92 return fallback93 return val == expected94 95 96def get_abi_tag() -> str | None:97 """Return the ABI tag based on SOABI (if available) or emulate SOABI (PyPy2)."""98 soabi: str = sysconfig.get_config_var("SOABI")99 impl = tags.interpreter_name()100 if not soabi and impl in ("cp", "pp") and hasattr(sys, "maxunicode"):101 d = ""102 u = ""103 if get_flag("Py_DEBUG", hasattr(sys, "gettotalrefcount"), warn=(impl == "cp")):104 d = "d"105 106 abi = f"{impl}{tags.interpreter_version()}{d}{u}"107 elif soabi and impl == "cp" and soabi.startswith("cpython"):108 # non-Windows109 abi = "cp" + soabi.split("-")[1]110 elif soabi and impl == "cp" and soabi.startswith("cp"):111 # Windows112 abi = soabi.split("-")[0]113 if hasattr(sys, "gettotalrefcount"):114 # using debug build; append "d" flag115 abi += "d"116 elif soabi and impl == "pp":117 # we want something like pypy36-pp73118 abi = "-".join(soabi.split("-")[:2])119 abi = abi.replace(".", "_").replace("-", "_")120 elif soabi and impl == "graalpy":121 abi = "-".join(soabi.split("-")[:3])122 abi = abi.replace(".", "_").replace("-", "_")123 elif soabi:124 abi = soabi.replace(".", "_").replace("-", "_")125 else:126 abi = None127 128 return abi129 130 131def safer_version(version: str) -> str:132 return safe_version(version).replace("-", "_")133 134 135class bdist_wheel(Command):136 description = "create a wheel distribution"137 138 supported_compressions = {139 "stored": ZIP_STORED,140 "deflated": ZIP_DEFLATED,141 }142 143 user_options = [144 ("bdist-dir=", "b", "temporary directory for creating the distribution"),145 (146 "plat-name=",147 "p",148 "platform name to embed in generated filenames "149 f"[default: {get_platform(None)}]",150 ),151 (152 "keep-temp",153 "k",154 "keep the pseudo-installation tree around after "155 "creating the distribution archive",156 ),157 ("dist-dir=", "d", "directory to put final built distributions in"),158 ("skip-build", None, "skip rebuilding everything (for testing/debugging)"),159 (160 "relative",161 None,162 "build the archive using relative paths [default: false]",163 ),164 (165 "owner=",166 "u",167 "Owner name used when creating a tar file [default: current user]",168 ),169 (170 "group=",171 "g",172 "Group name used when creating a tar file [default: current group]",173 ),174 ("universal", None, "*DEPRECATED* make a universal wheel [default: false]"),175 (176 "compression=",177 None,178 f"zipfile compression (one of: {', '.join(supported_compressions)}) [default: 'deflated']",179 ),180 (181 "python-tag=",182 None,183 f"Python implementation compatibility tag [default: '{python_tag()}']",184 ),185 (186 "build-number=",187 None,188 "Build number for this particular version. "189 "As specified in PEP-0427, this must start with a digit. "190 "[default: None]",191 ),192 (193 "py-limited-api=",194 None,195 "Python tag (cp32|cp33|cpNN) for abi3 wheel tag [default: false]",196 ),197 (198 "dist-info-dir=",199 None,200 "directory where a pre-generated dist-info can be found (e.g. as a "201 "result of calling the PEP517 'prepare_metadata_for_build_wheel' "202 "method)",203 ),204 ]205 206 boolean_options = ["keep-temp", "skip-build", "relative", "universal"]207 208 def initialize_options(self) -> None:209 self.bdist_dir: str | None = None210 self.data_dir = ""211 self.plat_name: str | None = None212 self.plat_tag: str | None = None213 self.format = "zip"214 self.keep_temp = False215 self.dist_dir: str | None = None216 self.dist_info_dir = None217 self.egginfo_dir: str | None = None218 self.root_is_pure: bool | None = None219 self.skip_build = False220 self.relative = False221 self.owner = None222 self.group = None223 self.universal = False224 self.compression: str | int = "deflated"225 self.python_tag = python_tag()226 self.build_number: str | None = None227 self.py_limited_api: str | Literal[False] = False228 self.plat_name_supplied = False229 230 def finalize_options(self) -> None:231 if not self.bdist_dir:232 bdist_base = self.get_finalized_command("bdist").bdist_base233 self.bdist_dir = os.path.join(bdist_base, "wheel")234 235 if self.dist_info_dir is None:236 egg_info = cast(egg_info_cls, self.distribution.get_command_obj("egg_info"))237 egg_info.ensure_finalized() # needed for correct `wheel_dist_name`238 239 self.data_dir = self.wheel_dist_name + ".data"240 self.plat_name_supplied = bool(self.plat_name)241 242 need_options = ("dist_dir", "plat_name", "skip_build")243 244 self.set_undefined_options("bdist", *zip(need_options, need_options))245 246 self.root_is_pure = not (247 self.distribution.has_ext_modules() or self.distribution.has_c_libraries()248 )249 250 self._validate_py_limited_api()251 252 # Support legacy [wheel] section for setting universal253 wheel = self.distribution.get_option_dict("wheel")254 if "universal" in wheel: # pragma: no cover255 # please don't define this in your global configs256 log.warn("The [wheel] section is deprecated. Use [bdist_wheel] instead.")257 val = wheel["universal"][1].strip()258 if val.lower() in ("1", "true", "yes"):259 self.universal = True260 261 if self.universal:262 SetuptoolsDeprecationWarning.emit(263 "bdist_wheel.universal is deprecated",264 """265 With Python 2.7 end-of-life, support for building universal wheels266 (i.e., wheels that support both Python 2 and Python 3)267 is being obviated.268 Please discontinue using this option, or if you still need it,269 file an issue with pypa/setuptools describing your use case.270 """,271 due_date=(2025, 8, 30), # Introduced in 2024-08-30272 )273 274 if self.build_number is not None and not self.build_number[:1].isdigit():275 raise ValueError("Build tag (build-number) must start with a digit.")276 277 def _validate_py_limited_api(self) -> None:278 if not self.py_limited_api:279 return280 281 if not re.match(PY_LIMITED_API_PATTERN, self.py_limited_api):282 raise ValueError(f"py-limited-api must match '{PY_LIMITED_API_PATTERN}'")283 284 if sysconfig.get_config_var("Py_GIL_DISABLED"):285 raise ValueError(286 f"`py_limited_api={self.py_limited_api!r}` not supported. "287 "`Py_LIMITED_API` is currently incompatible with "288 "`Py_GIL_DISABLED`. "289 "See https://github.com/python/cpython/issues/111506."290 )291 292 @property293 def wheel_dist_name(self) -> str:294 """Return distribution full name with - replaced with _"""295 components = [296 safer_name(self.distribution.get_name()),297 safer_version(self.distribution.get_version()),298 ]299 if self.build_number:300 components.append(self.build_number)301 return "-".join(components)302 303 def get_tag(self) -> tuple[str, str, str]:304 # bdist sets self.plat_name if unset, we should only use it for purepy305 # wheels if the user supplied it.306 if self.plat_name_supplied and self.plat_name:307 plat_name = self.plat_name308 elif self.root_is_pure:309 plat_name = "any"310 else:311 # macosx contains system version in platform name so need special handle312 if self.plat_name and not self.plat_name.startswith("macosx"):313 plat_name = self.plat_name314 else:315 # on macosx always limit the platform name to comply with any316 # c-extension modules in bdist_dir, since the user can specify317 # a higher MACOSX_DEPLOYMENT_TARGET via tools like CMake318 319 # on other platforms, and on macosx if there are no c-extension320 # modules, use the default platform name.321 plat_name = get_platform(self.bdist_dir)322 323 if _is_32bit_interpreter():324 if plat_name in ("linux-x86_64", "linux_x86_64"):325 plat_name = "linux_i686"326 if plat_name in ("linux-aarch64", "linux_aarch64"):327 # TODO armv8l, packaging pull request #690 => this did not land328 # in pip/packaging yet329 plat_name = "linux_armv7l"330 331 plat_name = (332 plat_name.lower().replace("-", "_").replace(".", "_").replace(" ", "_")333 )334 335 if self.root_is_pure:336 if self.universal:337 impl = "py2.py3"338 else:339 impl = self.python_tag340 tag = (impl, "none", plat_name)341 else:342 impl_name = tags.interpreter_name()343 impl_ver = tags.interpreter_version()344 impl = impl_name + impl_ver345 # We don't work on CPython 3.1, 3.0.346 if self.py_limited_api and (impl_name + impl_ver).startswith("cp3"):347 impl = self.py_limited_api348 abi_tag = "abi3"349 else:350 abi_tag = str(get_abi_tag()).lower()351 tag = (impl, abi_tag, plat_name)352 # issue gh-374: allow overriding plat_name353 supported_tags = [354 (t.interpreter, t.abi, plat_name) for t in tags.sys_tags()355 ]356 assert tag in supported_tags, (357 f"would build wheel with unsupported tag {tag}"358 )359 return tag360 361 def run(self):362 build_scripts = self.reinitialize_command("build_scripts")363 build_scripts.executable = "python"364 build_scripts.force = True365 366 build_ext = self.reinitialize_command("build_ext")367 build_ext.inplace = False368 369 if not self.skip_build:370 self.run_command("build")371 372 install = self.reinitialize_command("install", reinit_subcommands=True)373 install.root = self.bdist_dir374 install.compile = False375 install.skip_build = self.skip_build376 install.warn_dir = False377 378 # A wheel without setuptools scripts is more cross-platform.379 # Use the (undocumented) `no_ep` option to setuptools'380 # install_scripts command to avoid creating entry point scripts.381 install_scripts = self.reinitialize_command("install_scripts")382 install_scripts.no_ep = True383 384 # Use a custom scheme for the archive, because we have to decide385 # at installation time which scheme to use.386 for key in ("headers", "scripts", "data", "purelib", "platlib"):387 setattr(install, "install_" + key, os.path.join(self.data_dir, key))388 389 basedir_observed = ""390 391 if os.name == "nt":392 # win32 barfs if any of these are ''; could be '.'?393 # (distutils.command.install:change_roots bug)394 basedir_observed = os.path.normpath(os.path.join(self.data_dir, ".."))395 self.install_libbase = self.install_lib = basedir_observed396 397 setattr(398 install,399 "install_purelib" if self.root_is_pure else "install_platlib",400 basedir_observed,401 )402 403 log.info(f"installing to {self.bdist_dir}")404 405 self.run_command("install")406 407 impl_tag, abi_tag, plat_tag = self.get_tag()408 archive_basename = f"{self.wheel_dist_name}-{impl_tag}-{abi_tag}-{plat_tag}"409 if not self.relative:410 archive_root = self.bdist_dir411 else:412 archive_root = os.path.join(413 self.bdist_dir, self._ensure_relative(install.install_base)414 )415 416 self.set_undefined_options("install_egg_info", ("target", "egginfo_dir"))417 distinfo_dirname = (418 f"{safer_name(self.distribution.get_name())}-"419 f"{safer_version(self.distribution.get_version())}.dist-info"420 )421 distinfo_dir = os.path.join(self.bdist_dir, distinfo_dirname)422 if self.dist_info_dir:423 # Use the given dist-info directly.424 log.debug(f"reusing {self.dist_info_dir}")425 shutil.copytree(self.dist_info_dir, distinfo_dir)426 # Egg info is still generated, so remove it now to avoid it getting427 # copied into the wheel.428 _shutil.rmtree(self.egginfo_dir)429 else:430 # Convert the generated egg-info into dist-info.431 self.egg2dist(self.egginfo_dir, distinfo_dir)432 433 self.write_wheelfile(distinfo_dir)434 435 # Make the archive436 if not os.path.exists(self.dist_dir):437 os.makedirs(self.dist_dir)438 439 wheel_path = os.path.join(self.dist_dir, archive_basename + ".whl")440 with WheelFile(wheel_path, "w", self._zip_compression()) as wf:441 wf.write_files(archive_root)442 443 # Add to 'Distribution.dist_files' so that the "upload" command works444 getattr(self.distribution, "dist_files", []).append((445 "bdist_wheel",446 f"{sys.version_info.major}.{sys.version_info.minor}",447 wheel_path,448 ))449 450 if not self.keep_temp:451 log.info(f"removing {self.bdist_dir}")452 _shutil.rmtree(self.bdist_dir)453 454 def write_wheelfile(455 self, wheelfile_base: str, generator: str = f"setuptools ({__version__})"456 ) -> None:457 from email.message import Message458 459 msg = Message()460 msg["Wheel-Version"] = "1.0" # of the spec461 msg["Generator"] = generator462 msg["Root-Is-Purelib"] = str(self.root_is_pure).lower()463 if self.build_number is not None:464 msg["Build"] = self.build_number465 466 # Doesn't work for bdist_wininst467 impl_tag, abi_tag, plat_tag = self.get_tag()468 for impl in impl_tag.split("."):469 for abi in abi_tag.split("."):470 for plat in plat_tag.split("."):471 msg["Tag"] = "-".join((impl, abi, plat))472 473 wheelfile_path = os.path.join(wheelfile_base, "WHEEL")474 log.info(f"creating {wheelfile_path}")475 with open(wheelfile_path, "wb") as f:476 BytesGenerator(f, maxheaderlen=0).flatten(msg)477 478 def _ensure_relative(self, path: str) -> str:479 # copied from dir_util, deleted480 drive, path = os.path.splitdrive(path)481 if path[0:1] == os.sep:482 path = drive + path[1:]483 return path484 485 @property486 def license_paths(self) -> Iterable[str]:487 if setuptools_major_version >= 57:488 # Setuptools has resolved any patterns to actual file names489 return self.distribution.metadata.license_files or ()490 491 files = set[str]()492 metadata = self.distribution.get_option_dict("metadata")493 if setuptools_major_version >= 42:494 # Setuptools recognizes the license_files option but does not do globbing495 patterns = cast(Sequence[str], self.distribution.metadata.license_files)496 else:497 # Prior to those, wheel is entirely responsible for handling license files498 if "license_files" in metadata:499 patterns = metadata["license_files"][1].split()500 else:501 patterns = ()502 503 if "license_file" in metadata:504 warnings.warn(505 'The "license_file" option is deprecated. Use "license_files" instead.',506 DeprecationWarning,507 stacklevel=2,508 )509 files.add(metadata["license_file"][1])510 511 if not files and not patterns and not isinstance(patterns, list):512 patterns = ("LICEN[CS]E*", "COPYING*", "NOTICE*", "AUTHORS*")513 514 for pattern in patterns:515 for path in iglob(pattern):516 if path.endswith("~"):517 log.debug(518 f'ignoring license file "{path}" as it looks like a backup'519 )520 continue521 522 if path not in files and os.path.isfile(path):523 log.info(524 f'adding license file "{path}" (matched pattern "{pattern}")'525 )526 files.add(path)527 528 return files529 530 def egg2dist(self, egginfo_path: str, distinfo_path: str) -> None:531 """Convert an .egg-info directory into a .dist-info directory"""532 533 def adios(p: str) -> None:534 """Appropriately delete directory, file or link."""535 if os.path.exists(p) and not os.path.islink(p) and os.path.isdir(p):536 _shutil.rmtree(p)537 elif os.path.exists(p):538 os.unlink(p)539 540 adios(distinfo_path)541 542 if not os.path.exists(egginfo_path):543 # There is no egg-info. This is probably because the egg-info544 # file/directory is not named matching the distribution name used545 # to name the archive file. Check for this case and report546 # accordingly.547 import glob548 549 pat = os.path.join(os.path.dirname(egginfo_path), "*.egg-info")550 possible = glob.glob(pat)551 err = f"Egg metadata expected at {egginfo_path} but not found"552 if possible:553 alt = os.path.basename(possible[0])554 err += f" ({alt} found - possible misnamed archive file?)"555 556 raise ValueError(err)557 558 # .egg-info is a directory559 pkginfo_path = os.path.join(egginfo_path, "PKG-INFO")560 561 # ignore common egg metadata that is useless to wheel562 shutil.copytree(563 egginfo_path,564 distinfo_path,565 ignore=lambda x, y: {566 "PKG-INFO",567 "requires.txt",568 "SOURCES.txt",569 "not-zip-safe",570 },571 )572 573 # delete dependency_links if it is only whitespace574 dependency_links_path = os.path.join(distinfo_path, "dependency_links.txt")575 with open(dependency_links_path, encoding="utf-8") as dependency_links_file:576 dependency_links = dependency_links_file.read().strip()577 if not dependency_links:578 adios(dependency_links_path)579 580 metadata_path = os.path.join(distinfo_path, "METADATA")581 shutil.copy(pkginfo_path, metadata_path)582 583 licenses_folder_path = os.path.join(distinfo_path, "licenses")584 for license_path in self.license_paths:585 safe_path = _safe_license_file(license_path)586 dist_info_license_path = os.path.join(licenses_folder_path, safe_path)587 os.makedirs(os.path.dirname(dist_info_license_path), exist_ok=True)588 shutil.copy(license_path, dist_info_license_path)589 590 adios(egginfo_path)591 592 def _zip_compression(self) -> int:593 if (594 isinstance(self.compression, int)595 and self.compression in self.supported_compressions.values()596 ):597 return self.compression598 599 compression = self.supported_compressions.get(str(self.compression))600 if compression is not None:601 return compression602 603 raise ValueError(f"Unsupported compression: {self.compression!r}")604 