|
|
""" |
|
|
Create a wheel (.whl) distribution. |
|
|
|
|
|
A wheel is a built archive format. |
|
|
""" |
|
|
|
|
|
from __future__ import annotations |
|
|
|
|
|
import os |
|
|
import re |
|
|
import shutil |
|
|
import struct |
|
|
import sys |
|
|
import sysconfig |
|
|
import warnings |
|
|
from collections.abc import Iterable, Sequence |
|
|
from email.generator import BytesGenerator |
|
|
from glob import iglob |
|
|
from typing import Literal, cast |
|
|
from zipfile import ZIP_DEFLATED, ZIP_STORED |
|
|
|
|
|
from packaging import tags, version as _packaging_version |
|
|
from wheel.wheelfile import WheelFile |
|
|
|
|
|
from .. import Command, __version__, _shutil |
|
|
from .._core_metadata import _safe_license_file |
|
|
from .._normalization import safer_name |
|
|
from ..warnings import SetuptoolsDeprecationWarning |
|
|
from .egg_info import egg_info as egg_info_cls |
|
|
|
|
|
from distutils import log |
|
|
|
|
|
|
|
|
def safe_version(version: str) -> str: |
|
|
""" |
|
|
Convert an arbitrary string to a standard version string |
|
|
""" |
|
|
try: |
|
|
|
|
|
return str(_packaging_version.Version(version)) |
|
|
except _packaging_version.InvalidVersion: |
|
|
version = version.replace(" ", ".") |
|
|
return re.sub("[^A-Za-z0-9.]+", "-", version) |
|
|
|
|
|
|
|
|
setuptools_major_version = int(__version__.split(".")[0]) |
|
|
|
|
|
PY_LIMITED_API_PATTERN = r"cp3\d" |
|
|
|
|
|
|
|
|
def _is_32bit_interpreter() -> bool: |
|
|
return struct.calcsize("P") == 4 |
|
|
|
|
|
|
|
|
def python_tag() -> str: |
|
|
return f"py{sys.version_info.major}" |
|
|
|
|
|
|
|
|
def get_platform(archive_root: str | None) -> str: |
|
|
"""Return our platform name 'win32', 'linux_x86_64'""" |
|
|
result = sysconfig.get_platform() |
|
|
if result.startswith("macosx") and archive_root is not None: |
|
|
from wheel.macosx_libfile import calculate_macosx_platform_tag |
|
|
|
|
|
result = calculate_macosx_platform_tag(archive_root, result) |
|
|
elif _is_32bit_interpreter(): |
|
|
if result == "linux-x86_64": |
|
|
|
|
|
result = "linux-i686" |
|
|
elif result == "linux-aarch64": |
|
|
|
|
|
|
|
|
|
|
|
result = "linux-armv7l" |
|
|
|
|
|
return result.replace("-", "_") |
|
|
|
|
|
|
|
|
def get_flag( |
|
|
var: str, fallback: bool, expected: bool = True, warn: bool = True |
|
|
) -> bool: |
|
|
"""Use a fallback value for determining SOABI flags if the needed config |
|
|
var is unset or unavailable.""" |
|
|
val = sysconfig.get_config_var(var) |
|
|
if val is None: |
|
|
if warn: |
|
|
warnings.warn( |
|
|
f"Config variable '{var}' is unset, Python ABI tag may be incorrect", |
|
|
RuntimeWarning, |
|
|
stacklevel=2, |
|
|
) |
|
|
return fallback |
|
|
return val == expected |
|
|
|
|
|
|
|
|
def get_abi_tag() -> str | None: |
|
|
"""Return the ABI tag based on SOABI (if available) or emulate SOABI (PyPy2).""" |
|
|
soabi: str = sysconfig.get_config_var("SOABI") |
|
|
impl = tags.interpreter_name() |
|
|
if not soabi and impl in ("cp", "pp") and hasattr(sys, "maxunicode"): |
|
|
d = "" |
|
|
u = "" |
|
|
if get_flag("Py_DEBUG", hasattr(sys, "gettotalrefcount"), warn=(impl == "cp")): |
|
|
d = "d" |
|
|
|
|
|
abi = f"{impl}{tags.interpreter_version()}{d}{u}" |
|
|
elif soabi and impl == "cp" and soabi.startswith("cpython"): |
|
|
|
|
|
abi = "cp" + soabi.split("-")[1] |
|
|
elif soabi and impl == "cp" and soabi.startswith("cp"): |
|
|
|
|
|
abi = soabi.split("-")[0] |
|
|
if hasattr(sys, "gettotalrefcount"): |
|
|
|
|
|
abi += "d" |
|
|
elif soabi and impl == "pp": |
|
|
|
|
|
abi = "-".join(soabi.split("-")[:2]) |
|
|
abi = abi.replace(".", "_").replace("-", "_") |
|
|
elif soabi and impl == "graalpy": |
|
|
abi = "-".join(soabi.split("-")[:3]) |
|
|
abi = abi.replace(".", "_").replace("-", "_") |
|
|
elif soabi: |
|
|
abi = soabi.replace(".", "_").replace("-", "_") |
|
|
else: |
|
|
abi = None |
|
|
|
|
|
return abi |
|
|
|
|
|
|
|
|
def safer_version(version: str) -> str: |
|
|
return safe_version(version).replace("-", "_") |
|
|
|
|
|
|
|
|
class bdist_wheel(Command): |
|
|
description = "create a wheel distribution" |
|
|
|
|
|
supported_compressions = { |
|
|
"stored": ZIP_STORED, |
|
|
"deflated": ZIP_DEFLATED, |
|
|
} |
|
|
|
|
|
user_options = [ |
|
|
("bdist-dir=", "b", "temporary directory for creating the distribution"), |
|
|
( |
|
|
"plat-name=", |
|
|
"p", |
|
|
"platform name to embed in generated filenames " |
|
|
f"[default: {get_platform(None)}]", |
|
|
), |
|
|
( |
|
|
"keep-temp", |
|
|
"k", |
|
|
"keep the pseudo-installation tree around after " |
|
|
"creating the distribution archive", |
|
|
), |
|
|
("dist-dir=", "d", "directory to put final built distributions in"), |
|
|
("skip-build", None, "skip rebuilding everything (for testing/debugging)"), |
|
|
( |
|
|
"relative", |
|
|
None, |
|
|
"build the archive using relative paths [default: false]", |
|
|
), |
|
|
( |
|
|
"owner=", |
|
|
"u", |
|
|
"Owner name used when creating a tar file [default: current user]", |
|
|
), |
|
|
( |
|
|
"group=", |
|
|
"g", |
|
|
"Group name used when creating a tar file [default: current group]", |
|
|
), |
|
|
("universal", None, "*DEPRECATED* make a universal wheel [default: false]"), |
|
|
( |
|
|
"compression=", |
|
|
None, |
|
|
f"zipfile compression (one of: {', '.join(supported_compressions)}) [default: 'deflated']", |
|
|
), |
|
|
( |
|
|
"python-tag=", |
|
|
None, |
|
|
f"Python implementation compatibility tag [default: '{python_tag()}']", |
|
|
), |
|
|
( |
|
|
"build-number=", |
|
|
None, |
|
|
"Build number for this particular version. " |
|
|
"As specified in PEP-0427, this must start with a digit. " |
|
|
"[default: None]", |
|
|
), |
|
|
( |
|
|
"py-limited-api=", |
|
|
None, |
|
|
"Python tag (cp32|cp33|cpNN) for abi3 wheel tag [default: false]", |
|
|
), |
|
|
( |
|
|
"dist-info-dir=", |
|
|
None, |
|
|
"directory where a pre-generated dist-info can be found (e.g. as a " |
|
|
"result of calling the PEP517 'prepare_metadata_for_build_wheel' " |
|
|
"method)", |
|
|
), |
|
|
] |
|
|
|
|
|
boolean_options = ["keep-temp", "skip-build", "relative", "universal"] |
|
|
|
|
|
def initialize_options(self) -> None: |
|
|
self.bdist_dir: str | None = None |
|
|
self.data_dir = "" |
|
|
self.plat_name: str | None = None |
|
|
self.plat_tag: str | None = None |
|
|
self.format = "zip" |
|
|
self.keep_temp = False |
|
|
self.dist_dir: str | None = None |
|
|
self.dist_info_dir = None |
|
|
self.egginfo_dir: str | None = None |
|
|
self.root_is_pure: bool | None = None |
|
|
self.skip_build = False |
|
|
self.relative = False |
|
|
self.owner = None |
|
|
self.group = None |
|
|
self.universal = False |
|
|
self.compression: str | int = "deflated" |
|
|
self.python_tag = python_tag() |
|
|
self.build_number: str | None = None |
|
|
self.py_limited_api: str | Literal[False] = False |
|
|
self.plat_name_supplied = False |
|
|
|
|
|
def finalize_options(self) -> None: |
|
|
if not self.bdist_dir: |
|
|
bdist_base = self.get_finalized_command("bdist").bdist_base |
|
|
self.bdist_dir = os.path.join(bdist_base, "wheel") |
|
|
|
|
|
if self.dist_info_dir is None: |
|
|
egg_info = cast(egg_info_cls, self.distribution.get_command_obj("egg_info")) |
|
|
egg_info.ensure_finalized() |
|
|
|
|
|
self.data_dir = self.wheel_dist_name + ".data" |
|
|
self.plat_name_supplied = bool(self.plat_name) |
|
|
|
|
|
need_options = ("dist_dir", "plat_name", "skip_build") |
|
|
|
|
|
self.set_undefined_options("bdist", *zip(need_options, need_options)) |
|
|
|
|
|
self.root_is_pure = not ( |
|
|
self.distribution.has_ext_modules() or self.distribution.has_c_libraries() |
|
|
) |
|
|
|
|
|
self._validate_py_limited_api() |
|
|
|
|
|
|
|
|
wheel = self.distribution.get_option_dict("wheel") |
|
|
if "universal" in wheel: |
|
|
|
|
|
log.warn("The [wheel] section is deprecated. Use [bdist_wheel] instead.") |
|
|
val = wheel["universal"][1].strip() |
|
|
if val.lower() in ("1", "true", "yes"): |
|
|
self.universal = True |
|
|
|
|
|
if self.universal: |
|
|
SetuptoolsDeprecationWarning.emit( |
|
|
"bdist_wheel.universal is deprecated", |
|
|
""" |
|
|
With Python 2.7 end-of-life, support for building universal wheels |
|
|
(i.e., wheels that support both Python 2 and Python 3) |
|
|
is being obviated. |
|
|
Please discontinue using this option, or if you still need it, |
|
|
file an issue with pypa/setuptools describing your use case. |
|
|
""", |
|
|
due_date=(2025, 8, 30), |
|
|
) |
|
|
|
|
|
if self.build_number is not None and not self.build_number[:1].isdigit(): |
|
|
raise ValueError("Build tag (build-number) must start with a digit.") |
|
|
|
|
|
def _validate_py_limited_api(self) -> None: |
|
|
if not self.py_limited_api: |
|
|
return |
|
|
|
|
|
if not re.match(PY_LIMITED_API_PATTERN, self.py_limited_api): |
|
|
raise ValueError(f"py-limited-api must match '{PY_LIMITED_API_PATTERN}'") |
|
|
|
|
|
if sysconfig.get_config_var("Py_GIL_DISABLED"): |
|
|
raise ValueError( |
|
|
f"`py_limited_api={self.py_limited_api!r}` not supported. " |
|
|
"`Py_LIMITED_API` is currently incompatible with " |
|
|
"`Py_GIL_DISABLED`. " |
|
|
"See https://github.com/python/cpython/issues/111506." |
|
|
) |
|
|
|
|
|
@property |
|
|
def wheel_dist_name(self) -> str: |
|
|
"""Return distribution full name with - replaced with _""" |
|
|
components = [ |
|
|
safer_name(self.distribution.get_name()), |
|
|
safer_version(self.distribution.get_version()), |
|
|
] |
|
|
if self.build_number: |
|
|
components.append(self.build_number) |
|
|
return "-".join(components) |
|
|
|
|
|
def get_tag(self) -> tuple[str, str, str]: |
|
|
|
|
|
|
|
|
if self.plat_name_supplied and self.plat_name: |
|
|
plat_name = self.plat_name |
|
|
elif self.root_is_pure: |
|
|
plat_name = "any" |
|
|
else: |
|
|
|
|
|
if self.plat_name and not self.plat_name.startswith("macosx"): |
|
|
plat_name = self.plat_name |
|
|
else: |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
plat_name = get_platform(self.bdist_dir) |
|
|
|
|
|
if _is_32bit_interpreter(): |
|
|
if plat_name in ("linux-x86_64", "linux_x86_64"): |
|
|
plat_name = "linux_i686" |
|
|
if plat_name in ("linux-aarch64", "linux_aarch64"): |
|
|
|
|
|
|
|
|
plat_name = "linux_armv7l" |
|
|
|
|
|
plat_name = ( |
|
|
plat_name.lower().replace("-", "_").replace(".", "_").replace(" ", "_") |
|
|
) |
|
|
|
|
|
if self.root_is_pure: |
|
|
if self.universal: |
|
|
impl = "py2.py3" |
|
|
else: |
|
|
impl = self.python_tag |
|
|
tag = (impl, "none", plat_name) |
|
|
else: |
|
|
impl_name = tags.interpreter_name() |
|
|
impl_ver = tags.interpreter_version() |
|
|
impl = impl_name + impl_ver |
|
|
|
|
|
if self.py_limited_api and (impl_name + impl_ver).startswith("cp3"): |
|
|
impl = self.py_limited_api |
|
|
abi_tag = "abi3" |
|
|
else: |
|
|
abi_tag = str(get_abi_tag()).lower() |
|
|
tag = (impl, abi_tag, plat_name) |
|
|
|
|
|
supported_tags = [ |
|
|
(t.interpreter, t.abi, plat_name) for t in tags.sys_tags() |
|
|
] |
|
|
assert tag in supported_tags, ( |
|
|
f"would build wheel with unsupported tag {tag}" |
|
|
) |
|
|
return tag |
|
|
|
|
|
def run(self): |
|
|
build_scripts = self.reinitialize_command("build_scripts") |
|
|
build_scripts.executable = "python" |
|
|
build_scripts.force = True |
|
|
|
|
|
build_ext = self.reinitialize_command("build_ext") |
|
|
build_ext.inplace = False |
|
|
|
|
|
if not self.skip_build: |
|
|
self.run_command("build") |
|
|
|
|
|
install = self.reinitialize_command("install", reinit_subcommands=True) |
|
|
install.root = self.bdist_dir |
|
|
install.compile = False |
|
|
install.skip_build = self.skip_build |
|
|
install.warn_dir = False |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
install_scripts = self.reinitialize_command("install_scripts") |
|
|
install_scripts.no_ep = True |
|
|
|
|
|
|
|
|
|
|
|
for key in ("headers", "scripts", "data", "purelib", "platlib"): |
|
|
setattr(install, "install_" + key, os.path.join(self.data_dir, key)) |
|
|
|
|
|
basedir_observed = "" |
|
|
|
|
|
if os.name == "nt": |
|
|
|
|
|
|
|
|
basedir_observed = os.path.normpath(os.path.join(self.data_dir, "..")) |
|
|
self.install_libbase = self.install_lib = basedir_observed |
|
|
|
|
|
setattr( |
|
|
install, |
|
|
"install_purelib" if self.root_is_pure else "install_platlib", |
|
|
basedir_observed, |
|
|
) |
|
|
|
|
|
log.info(f"installing to {self.bdist_dir}") |
|
|
|
|
|
self.run_command("install") |
|
|
|
|
|
impl_tag, abi_tag, plat_tag = self.get_tag() |
|
|
archive_basename = f"{self.wheel_dist_name}-{impl_tag}-{abi_tag}-{plat_tag}" |
|
|
if not self.relative: |
|
|
archive_root = self.bdist_dir |
|
|
else: |
|
|
archive_root = os.path.join( |
|
|
self.bdist_dir, self._ensure_relative(install.install_base) |
|
|
) |
|
|
|
|
|
self.set_undefined_options("install_egg_info", ("target", "egginfo_dir")) |
|
|
distinfo_dirname = ( |
|
|
f"{safer_name(self.distribution.get_name())}-" |
|
|
f"{safer_version(self.distribution.get_version())}.dist-info" |
|
|
) |
|
|
distinfo_dir = os.path.join(self.bdist_dir, distinfo_dirname) |
|
|
if self.dist_info_dir: |
|
|
|
|
|
log.debug(f"reusing {self.dist_info_dir}") |
|
|
shutil.copytree(self.dist_info_dir, distinfo_dir) |
|
|
|
|
|
|
|
|
_shutil.rmtree(self.egginfo_dir) |
|
|
else: |
|
|
|
|
|
self.egg2dist(self.egginfo_dir, distinfo_dir) |
|
|
|
|
|
self.write_wheelfile(distinfo_dir) |
|
|
|
|
|
|
|
|
if not os.path.exists(self.dist_dir): |
|
|
os.makedirs(self.dist_dir) |
|
|
|
|
|
wheel_path = os.path.join(self.dist_dir, archive_basename + ".whl") |
|
|
with WheelFile(wheel_path, "w", self._zip_compression()) as wf: |
|
|
wf.write_files(archive_root) |
|
|
|
|
|
|
|
|
getattr(self.distribution, "dist_files", []).append(( |
|
|
"bdist_wheel", |
|
|
f"{sys.version_info.major}.{sys.version_info.minor}", |
|
|
wheel_path, |
|
|
)) |
|
|
|
|
|
if not self.keep_temp: |
|
|
log.info(f"removing {self.bdist_dir}") |
|
|
if not self.dry_run: |
|
|
_shutil.rmtree(self.bdist_dir) |
|
|
|
|
|
def write_wheelfile( |
|
|
self, wheelfile_base: str, generator: str = f"setuptools ({__version__})" |
|
|
) -> None: |
|
|
from email.message import Message |
|
|
|
|
|
msg = Message() |
|
|
msg["Wheel-Version"] = "1.0" |
|
|
msg["Generator"] = generator |
|
|
msg["Root-Is-Purelib"] = str(self.root_is_pure).lower() |
|
|
if self.build_number is not None: |
|
|
msg["Build"] = self.build_number |
|
|
|
|
|
|
|
|
impl_tag, abi_tag, plat_tag = self.get_tag() |
|
|
for impl in impl_tag.split("."): |
|
|
for abi in abi_tag.split("."): |
|
|
for plat in plat_tag.split("."): |
|
|
msg["Tag"] = "-".join((impl, abi, plat)) |
|
|
|
|
|
wheelfile_path = os.path.join(wheelfile_base, "WHEEL") |
|
|
log.info(f"creating {wheelfile_path}") |
|
|
with open(wheelfile_path, "wb") as f: |
|
|
BytesGenerator(f, maxheaderlen=0).flatten(msg) |
|
|
|
|
|
def _ensure_relative(self, path: str) -> str: |
|
|
|
|
|
drive, path = os.path.splitdrive(path) |
|
|
if path[0:1] == os.sep: |
|
|
path = drive + path[1:] |
|
|
return path |
|
|
|
|
|
@property |
|
|
def license_paths(self) -> Iterable[str]: |
|
|
if setuptools_major_version >= 57: |
|
|
|
|
|
return self.distribution.metadata.license_files or () |
|
|
|
|
|
files = set[str]() |
|
|
metadata = self.distribution.get_option_dict("metadata") |
|
|
if setuptools_major_version >= 42: |
|
|
|
|
|
patterns = cast(Sequence[str], self.distribution.metadata.license_files) |
|
|
else: |
|
|
|
|
|
if "license_files" in metadata: |
|
|
patterns = metadata["license_files"][1].split() |
|
|
else: |
|
|
patterns = () |
|
|
|
|
|
if "license_file" in metadata: |
|
|
warnings.warn( |
|
|
'The "license_file" option is deprecated. Use "license_files" instead.', |
|
|
DeprecationWarning, |
|
|
stacklevel=2, |
|
|
) |
|
|
files.add(metadata["license_file"][1]) |
|
|
|
|
|
if not files and not patterns and not isinstance(patterns, list): |
|
|
patterns = ("LICEN[CS]E*", "COPYING*", "NOTICE*", "AUTHORS*") |
|
|
|
|
|
for pattern in patterns: |
|
|
for path in iglob(pattern): |
|
|
if path.endswith("~"): |
|
|
log.debug( |
|
|
f'ignoring license file "{path}" as it looks like a backup' |
|
|
) |
|
|
continue |
|
|
|
|
|
if path not in files and os.path.isfile(path): |
|
|
log.info( |
|
|
f'adding license file "{path}" (matched pattern "{pattern}")' |
|
|
) |
|
|
files.add(path) |
|
|
|
|
|
return files |
|
|
|
|
|
def egg2dist(self, egginfo_path: str, distinfo_path: str) -> None: |
|
|
"""Convert an .egg-info directory into a .dist-info directory""" |
|
|
|
|
|
def adios(p: str) -> None: |
|
|
"""Appropriately delete directory, file or link.""" |
|
|
if os.path.exists(p) and not os.path.islink(p) and os.path.isdir(p): |
|
|
_shutil.rmtree(p) |
|
|
elif os.path.exists(p): |
|
|
os.unlink(p) |
|
|
|
|
|
adios(distinfo_path) |
|
|
|
|
|
if not os.path.exists(egginfo_path): |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import glob |
|
|
|
|
|
pat = os.path.join(os.path.dirname(egginfo_path), "*.egg-info") |
|
|
possible = glob.glob(pat) |
|
|
err = f"Egg metadata expected at {egginfo_path} but not found" |
|
|
if possible: |
|
|
alt = os.path.basename(possible[0]) |
|
|
err += f" ({alt} found - possible misnamed archive file?)" |
|
|
|
|
|
raise ValueError(err) |
|
|
|
|
|
|
|
|
pkginfo_path = os.path.join(egginfo_path, "PKG-INFO") |
|
|
|
|
|
|
|
|
shutil.copytree( |
|
|
egginfo_path, |
|
|
distinfo_path, |
|
|
ignore=lambda x, y: { |
|
|
"PKG-INFO", |
|
|
"requires.txt", |
|
|
"SOURCES.txt", |
|
|
"not-zip-safe", |
|
|
}, |
|
|
) |
|
|
|
|
|
|
|
|
dependency_links_path = os.path.join(distinfo_path, "dependency_links.txt") |
|
|
with open(dependency_links_path, encoding="utf-8") as dependency_links_file: |
|
|
dependency_links = dependency_links_file.read().strip() |
|
|
if not dependency_links: |
|
|
adios(dependency_links_path) |
|
|
|
|
|
metadata_path = os.path.join(distinfo_path, "METADATA") |
|
|
shutil.copy(pkginfo_path, metadata_path) |
|
|
|
|
|
licenses_folder_path = os.path.join(distinfo_path, "licenses") |
|
|
for license_path in self.license_paths: |
|
|
safe_path = _safe_license_file(license_path) |
|
|
dist_info_license_path = os.path.join(licenses_folder_path, safe_path) |
|
|
os.makedirs(os.path.dirname(dist_info_license_path), exist_ok=True) |
|
|
shutil.copy(license_path, dist_info_license_path) |
|
|
|
|
|
adios(egginfo_path) |
|
|
|
|
|
def _zip_compression(self) -> int: |
|
|
if ( |
|
|
isinstance(self.compression, int) |
|
|
and self.compression in self.supported_compressions.values() |
|
|
): |
|
|
return self.compression |
|
|
|
|
|
compression = self.supported_compressions.get(str(self.compression)) |
|
|
if compression is not None: |
|
|
return compression |
|
|
|
|
|
raise ValueError(f"Unsupported compression: {self.compression!r}") |
|
|
|