From 0add7d6c5fe6c03d7bec22d7d18f51d8a519862c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 4 Aug 2024 17:40:59 +0300 Subject: [PATCH] Deprecated bdist_wheel and updated the README (#631) --- README.rst | 23 +- docs/news.rst | 4 +- src/wheel/_bdist_wheel.py | 604 ++++++++++++++++++++++++++++++++++ src/wheel/bdist_wheel.py | 611 +---------------------------------- src/wheel/cli/convert.py | 2 +- tests/test_bdist_wheel.py | 13 +- tests/test_macosx_libfile.py | 2 +- 7 files changed, 642 insertions(+), 617 deletions(-) create mode 100644 src/wheel/_bdist_wheel.py diff --git a/README.rst b/README.rst index 1cf194ec..ea16e654 100644 --- a/README.rst +++ b/README.rst @@ -1,19 +1,24 @@ wheel ===== -This library is the reference implementation of the Python wheel packaging -standard, as defined in `PEP 427`_. +This is a command line tool for manipulating Python wheel files, as defined in +`PEP 427`_. It contains the following functionality: -It has two different roles: +* Convert ``.egg`` archives into ``.whl`` +* Unpack wheel archives +* Repack wheel archives +* Add or remove tags in existing wheel archives -#. A setuptools_ extension for building wheels that provides the - ``bdist_wheel`` setuptools command -#. A command line tool for working with wheel files +.. _PEP 427: https://www.python.org/dev/peps/pep-0427/ + +Historical note +--------------- -It should be noted that wheel is **not** intended to be used as a library, and -as such there is no stable, public API. +This project used to contain the implementation of the setuptools_ ``bdist_wheel`` +command, but as of setuptools v70.1, it no longer needs ``wheel`` installed for that to +work. Thus, you should install this **only** if you intend to use the ``wheel`` command +line tool! -.. _PEP 427: https://www.python.org/dev/peps/pep-0427/ .. _setuptools: https://pypi.org/project/setuptools/ Documentation diff --git a/docs/news.rst b/docs/news.rst index 75da05ba..e7301b95 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -3,7 +3,9 @@ Release Notes **UNRELEASED** -- Canonicalize requirements in METADATA file (PR by Wim Jeantine-Glenn) +- Canonicalized requirements in METADATA file (PR by Wim Jeantine-Glenn) +- Deprecated the ``bdist_wheel`` module, as the code was migrated to ``setuptools`` + itself **0.43.0 (2024-03-11)** diff --git a/src/wheel/_bdist_wheel.py b/src/wheel/_bdist_wheel.py new file mode 100644 index 00000000..3cc7165e --- /dev/null +++ b/src/wheel/_bdist_wheel.py @@ -0,0 +1,604 @@ +""" +Create a wheel (.whl) distribution. + +A wheel is a built archive format. +""" + +from __future__ import annotations + +import os +import re +import shutil +import stat +import struct +import sys +import sysconfig +import warnings +from email.generator import BytesGenerator, Generator +from email.policy import EmailPolicy +from glob import iglob +from shutil import rmtree +from typing import TYPE_CHECKING, Callable, Iterable, Literal, Sequence, cast +from zipfile import ZIP_DEFLATED, ZIP_STORED + +import setuptools +from setuptools import Command + +from . import __version__ as wheel_version +from .metadata import pkginfo_to_metadata +from .util import log +from .vendored.packaging import tags +from .vendored.packaging import version as _packaging_version +from .wheelfile import WheelFile + +if TYPE_CHECKING: + import types + + +def safe_name(name: str) -> str: + """Convert an arbitrary string to a standard distribution name + Any runs of non-alphanumeric/. characters are replaced with a single '-'. + """ + return re.sub("[^A-Za-z0-9.]+", "-", name) + + +def safe_version(version: str) -> str: + """ + Convert an arbitrary string to a standard version string + """ + try: + # normalize the version + 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(setuptools.__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[0]}" + + +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 .macosx_libfile import calculate_macosx_platform_tag + + result = calculate_macosx_platform_tag(archive_root, result) + elif _is_32bit_interpreter(): + if result == "linux-x86_64": + # pip pull request #3497 + result = "linux-i686" + elif result == "linux-aarch64": + # packaging pull request #234 + # TODO armv8l, packaging pull request #690 => this did not land + # in pip/packaging yet + 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 = "" + m = "" + u = "" + if get_flag("Py_DEBUG", hasattr(sys, "gettotalrefcount"), warn=(impl == "cp")): + d = "d" + + if get_flag( + "WITH_PYMALLOC", + impl == "cp", + warn=(impl == "cp" and sys.version_info < (3, 8)), + ) and sys.version_info < (3, 8): + m = "m" + + abi = f"{impl}{tags.interpreter_version()}{d}{m}{u}" + elif soabi and impl == "cp" and soabi.startswith("cpython"): + # non-Windows + abi = "cp" + soabi.split("-")[1] + elif soabi and impl == "cp" and soabi.startswith("cp"): + # Windows + abi = soabi.split("-")[0] + elif soabi and impl == "pp": + # we want something like pypy36-pp73 + 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_name(name: str) -> str: + return safe_name(name).replace("-", "_") + + +def safer_version(version: str) -> str: + return safe_version(version).replace("-", "_") + + +def remove_readonly( + func: Callable[..., object], + path: str, + excinfo: tuple[type[Exception], Exception, types.TracebackType], +) -> None: + remove_readonly_exc(func, path, excinfo[1]) + + +def remove_readonly_exc(func: Callable[..., object], path: str, exc: Exception) -> None: + os.chmod(path, stat.S_IWRITE) + func(path) + + +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, "make a universal wheel (default: false)"), + ( + "compression=", + None, + "zipfile compression (one of: {}) (default: 'deflated')".format( + ", ".join(supported_compressions) + ), + ), + ( + "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)", + ), + ] + + boolean_options = ["keep-temp", "skip-build", "relative", "universal"] + + def initialize_options(self): + self.bdist_dir: str = None + self.data_dir = None + self.plat_name: str | None = None + self.plat_tag = None + self.format = "zip" + self.keep_temp = False + self.dist_dir: str | None = None + self.egginfo_dir = None + self.root_is_pure: bool | None = None + self.skip_build = None + self.relative = False + self.owner = None + self.group = None + self.universal: bool = False + self.compression: str | int = "deflated" + self.python_tag: str = 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): + if self.bdist_dir is None: + bdist_base = self.get_finalized_command("bdist").bdist_base + self.bdist_dir = os.path.join(bdist_base, "wheel") + + egg_info = self.distribution.get_command_obj("egg_info") + egg_info.ensure_finalized() # needed for correct `wheel_dist_name` + + self.data_dir = self.wheel_dist_name + ".data" + self.plat_name_supplied = self.plat_name is not None + + try: + self.compression = self.supported_compressions[self.compression] + except KeyError: + raise ValueError(f"Unsupported compression: {self.compression}") from None + + 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() + ) + + if self.py_limited_api and not re.match( + PY_LIMITED_API_PATTERN, self.py_limited_api + ): + raise ValueError(f"py-limited-api must match '{PY_LIMITED_API_PATTERN}'") + + # Support legacy [wheel] section for setting universal + wheel = self.distribution.get_option_dict("wheel") + if "universal" in wheel: + # please don't define this in your global configs + log.warning( + "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.build_number is not None and not self.build_number[:1].isdigit(): + raise ValueError("Build tag (build-number) must start with a digit.") + + @property + def wheel_dist_name(self): + """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 += (self.build_number,) + return "-".join(components) + + def get_tag(self) -> tuple[str, str, str]: + # bdist sets self.plat_name if unset, we should only use it for purepy + # wheels if the user supplied it. + if self.plat_name_supplied: + plat_name = cast(str, self.plat_name) + elif self.root_is_pure: + plat_name = "any" + else: + # macosx contains system version in platform name so need special handle + if self.plat_name and not self.plat_name.startswith("macosx"): + plat_name = self.plat_name + else: + # on macosx always limit the platform name to comply with any + # c-extension modules in bdist_dir, since the user can specify + # a higher MACOSX_DEPLOYMENT_TARGET via tools like CMake + + # on other platforms, and on macosx if there are no c-extension + # modules, use the default platform name. + 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"): + # TODO armv8l, packaging pull request #690 => this did not land + # in pip/packaging yet + 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 + # We don't work on CPython 3.1, 3.0. + 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) + # issue gh-374: allow overriding 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 + + # A wheel without setuptools scripts is more cross-platform. + # Use the (undocumented) `no_ep` option to setuptools' + # install_scripts command to avoid creating entry point scripts. + install_scripts = self.reinitialize_command("install_scripts") + install_scripts.no_ep = True + + # Use a custom scheme for the archive, because we have to decide + # at installation time which scheme to use. + 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": + # win32 barfs if any of these are ''; could be '.'? + # (distutils.command.install:change_roots bug) + 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) + self.egg2dist(self.egginfo_dir, distinfo_dir) + + self.write_wheelfile(distinfo_dir) + + # Make the archive + 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.compression) as wf: + wf.write_files(archive_root) + + # Add to 'Distribution.dist_files' so that the "upload" command works + getattr(self.distribution, "dist_files", []).append( + ( + "bdist_wheel", + "{}.{}".format(*sys.version_info[:2]), # like 3.7 + wheel_path, + ) + ) + + if not self.keep_temp: + log.info(f"removing {self.bdist_dir}") + if not self.dry_run: + if sys.version_info < (3, 12): + rmtree(self.bdist_dir, onerror=remove_readonly) + else: + rmtree(self.bdist_dir, onexc=remove_readonly_exc) + + def write_wheelfile( + self, wheelfile_base: str, generator: str = f"bdist_wheel ({wheel_version})" + ): + from email.message import Message + + msg = Message() + msg["Wheel-Version"] = "1.0" # of the spec + 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 + + # Doesn't work for bdist_wininst + 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: + # copied from dir_util, deleted + 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: + # Setuptools has resolved any patterns to actual file names + return self.distribution.metadata.license_files or () + + files: set[str] = set() + metadata = self.distribution.get_option_dict("metadata") + if setuptools_major_version >= 42: + # Setuptools recognizes the license_files option but does not do globbing + patterns = cast(Sequence[str], self.distribution.metadata.license_files) + else: + # Prior to those, wheel is entirely responsible for handling license files + 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): + """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): + # There is no egg-info. This is probably because the egg-info + # file/directory is not named matching the distribution name used + # to name the archive file. Check for this case and report + # accordingly. + 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) + + if os.path.isfile(egginfo_path): + # .egg-info is a single file + pkg_info = pkginfo_to_metadata(egginfo_path, egginfo_path) + os.mkdir(distinfo_path) + else: + # .egg-info is a directory + pkginfo_path = os.path.join(egginfo_path, "PKG-INFO") + pkg_info = pkginfo_to_metadata(egginfo_path, pkginfo_path) + + # ignore common egg metadata that is useless to wheel + shutil.copytree( + egginfo_path, + distinfo_path, + ignore=lambda x, y: { + "PKG-INFO", + "requires.txt", + "SOURCES.txt", + "not-zip-safe", + }, + ) + + # delete dependency_links if it is only whitespace + 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) + + pkg_info_path = os.path.join(distinfo_path, "METADATA") + serialization_policy = EmailPolicy( + utf8=True, + mangle_from_=False, + max_line_length=0, + ) + with open(pkg_info_path, "w", encoding="utf-8") as out: + Generator(out, policy=serialization_policy).flatten(pkg_info) + + for license_path in self.license_paths: + filename = os.path.basename(license_path) + shutil.copy(license_path, os.path.join(distinfo_path, filename)) + + adios(egginfo_path) diff --git a/src/wheel/bdist_wheel.py b/src/wheel/bdist_wheel.py index 3cc7165e..05d93eee 100644 --- a/src/wheel/bdist_wheel.py +++ b/src/wheel/bdist_wheel.py @@ -1,604 +1,11 @@ -""" -Create a wheel (.whl) distribution. +from warnings import warn -A wheel is a built archive format. -""" +from ._bdist_wheel import bdist_wheel as bdist_wheel -from __future__ import annotations - -import os -import re -import shutil -import stat -import struct -import sys -import sysconfig -import warnings -from email.generator import BytesGenerator, Generator -from email.policy import EmailPolicy -from glob import iglob -from shutil import rmtree -from typing import TYPE_CHECKING, Callable, Iterable, Literal, Sequence, cast -from zipfile import ZIP_DEFLATED, ZIP_STORED - -import setuptools -from setuptools import Command - -from . import __version__ as wheel_version -from .metadata import pkginfo_to_metadata -from .util import log -from .vendored.packaging import tags -from .vendored.packaging import version as _packaging_version -from .wheelfile import WheelFile - -if TYPE_CHECKING: - import types - - -def safe_name(name: str) -> str: - """Convert an arbitrary string to a standard distribution name - Any runs of non-alphanumeric/. characters are replaced with a single '-'. - """ - return re.sub("[^A-Za-z0-9.]+", "-", name) - - -def safe_version(version: str) -> str: - """ - Convert an arbitrary string to a standard version string - """ - try: - # normalize the version - 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(setuptools.__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[0]}" - - -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 .macosx_libfile import calculate_macosx_platform_tag - - result = calculate_macosx_platform_tag(archive_root, result) - elif _is_32bit_interpreter(): - if result == "linux-x86_64": - # pip pull request #3497 - result = "linux-i686" - elif result == "linux-aarch64": - # packaging pull request #234 - # TODO armv8l, packaging pull request #690 => this did not land - # in pip/packaging yet - 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 = "" - m = "" - u = "" - if get_flag("Py_DEBUG", hasattr(sys, "gettotalrefcount"), warn=(impl == "cp")): - d = "d" - - if get_flag( - "WITH_PYMALLOC", - impl == "cp", - warn=(impl == "cp" and sys.version_info < (3, 8)), - ) and sys.version_info < (3, 8): - m = "m" - - abi = f"{impl}{tags.interpreter_version()}{d}{m}{u}" - elif soabi and impl == "cp" and soabi.startswith("cpython"): - # non-Windows - abi = "cp" + soabi.split("-")[1] - elif soabi and impl == "cp" and soabi.startswith("cp"): - # Windows - abi = soabi.split("-")[0] - elif soabi and impl == "pp": - # we want something like pypy36-pp73 - 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_name(name: str) -> str: - return safe_name(name).replace("-", "_") - - -def safer_version(version: str) -> str: - return safe_version(version).replace("-", "_") - - -def remove_readonly( - func: Callable[..., object], - path: str, - excinfo: tuple[type[Exception], Exception, types.TracebackType], -) -> None: - remove_readonly_exc(func, path, excinfo[1]) - - -def remove_readonly_exc(func: Callable[..., object], path: str, exc: Exception) -> None: - os.chmod(path, stat.S_IWRITE) - func(path) - - -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, "make a universal wheel (default: false)"), - ( - "compression=", - None, - "zipfile compression (one of: {}) (default: 'deflated')".format( - ", ".join(supported_compressions) - ), - ), - ( - "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)", - ), - ] - - boolean_options = ["keep-temp", "skip-build", "relative", "universal"] - - def initialize_options(self): - self.bdist_dir: str = None - self.data_dir = None - self.plat_name: str | None = None - self.plat_tag = None - self.format = "zip" - self.keep_temp = False - self.dist_dir: str | None = None - self.egginfo_dir = None - self.root_is_pure: bool | None = None - self.skip_build = None - self.relative = False - self.owner = None - self.group = None - self.universal: bool = False - self.compression: str | int = "deflated" - self.python_tag: str = 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): - if self.bdist_dir is None: - bdist_base = self.get_finalized_command("bdist").bdist_base - self.bdist_dir = os.path.join(bdist_base, "wheel") - - egg_info = self.distribution.get_command_obj("egg_info") - egg_info.ensure_finalized() # needed for correct `wheel_dist_name` - - self.data_dir = self.wheel_dist_name + ".data" - self.plat_name_supplied = self.plat_name is not None - - try: - self.compression = self.supported_compressions[self.compression] - except KeyError: - raise ValueError(f"Unsupported compression: {self.compression}") from None - - 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() - ) - - if self.py_limited_api and not re.match( - PY_LIMITED_API_PATTERN, self.py_limited_api - ): - raise ValueError(f"py-limited-api must match '{PY_LIMITED_API_PATTERN}'") - - # Support legacy [wheel] section for setting universal - wheel = self.distribution.get_option_dict("wheel") - if "universal" in wheel: - # please don't define this in your global configs - log.warning( - "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.build_number is not None and not self.build_number[:1].isdigit(): - raise ValueError("Build tag (build-number) must start with a digit.") - - @property - def wheel_dist_name(self): - """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 += (self.build_number,) - return "-".join(components) - - def get_tag(self) -> tuple[str, str, str]: - # bdist sets self.plat_name if unset, we should only use it for purepy - # wheels if the user supplied it. - if self.plat_name_supplied: - plat_name = cast(str, self.plat_name) - elif self.root_is_pure: - plat_name = "any" - else: - # macosx contains system version in platform name so need special handle - if self.plat_name and not self.plat_name.startswith("macosx"): - plat_name = self.plat_name - else: - # on macosx always limit the platform name to comply with any - # c-extension modules in bdist_dir, since the user can specify - # a higher MACOSX_DEPLOYMENT_TARGET via tools like CMake - - # on other platforms, and on macosx if there are no c-extension - # modules, use the default platform name. - 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"): - # TODO armv8l, packaging pull request #690 => this did not land - # in pip/packaging yet - 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 - # We don't work on CPython 3.1, 3.0. - 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) - # issue gh-374: allow overriding 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 - - # A wheel without setuptools scripts is more cross-platform. - # Use the (undocumented) `no_ep` option to setuptools' - # install_scripts command to avoid creating entry point scripts. - install_scripts = self.reinitialize_command("install_scripts") - install_scripts.no_ep = True - - # Use a custom scheme for the archive, because we have to decide - # at installation time which scheme to use. - 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": - # win32 barfs if any of these are ''; could be '.'? - # (distutils.command.install:change_roots bug) - 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) - self.egg2dist(self.egginfo_dir, distinfo_dir) - - self.write_wheelfile(distinfo_dir) - - # Make the archive - 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.compression) as wf: - wf.write_files(archive_root) - - # Add to 'Distribution.dist_files' so that the "upload" command works - getattr(self.distribution, "dist_files", []).append( - ( - "bdist_wheel", - "{}.{}".format(*sys.version_info[:2]), # like 3.7 - wheel_path, - ) - ) - - if not self.keep_temp: - log.info(f"removing {self.bdist_dir}") - if not self.dry_run: - if sys.version_info < (3, 12): - rmtree(self.bdist_dir, onerror=remove_readonly) - else: - rmtree(self.bdist_dir, onexc=remove_readonly_exc) - - def write_wheelfile( - self, wheelfile_base: str, generator: str = f"bdist_wheel ({wheel_version})" - ): - from email.message import Message - - msg = Message() - msg["Wheel-Version"] = "1.0" # of the spec - 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 - - # Doesn't work for bdist_wininst - 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: - # copied from dir_util, deleted - 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: - # Setuptools has resolved any patterns to actual file names - return self.distribution.metadata.license_files or () - - files: set[str] = set() - metadata = self.distribution.get_option_dict("metadata") - if setuptools_major_version >= 42: - # Setuptools recognizes the license_files option but does not do globbing - patterns = cast(Sequence[str], self.distribution.metadata.license_files) - else: - # Prior to those, wheel is entirely responsible for handling license files - 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): - """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): - # There is no egg-info. This is probably because the egg-info - # file/directory is not named matching the distribution name used - # to name the archive file. Check for this case and report - # accordingly. - 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) - - if os.path.isfile(egginfo_path): - # .egg-info is a single file - pkg_info = pkginfo_to_metadata(egginfo_path, egginfo_path) - os.mkdir(distinfo_path) - else: - # .egg-info is a directory - pkginfo_path = os.path.join(egginfo_path, "PKG-INFO") - pkg_info = pkginfo_to_metadata(egginfo_path, pkginfo_path) - - # ignore common egg metadata that is useless to wheel - shutil.copytree( - egginfo_path, - distinfo_path, - ignore=lambda x, y: { - "PKG-INFO", - "requires.txt", - "SOURCES.txt", - "not-zip-safe", - }, - ) - - # delete dependency_links if it is only whitespace - 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) - - pkg_info_path = os.path.join(distinfo_path, "METADATA") - serialization_policy = EmailPolicy( - utf8=True, - mangle_from_=False, - max_line_length=0, - ) - with open(pkg_info_path, "w", encoding="utf-8") as out: - Generator(out, policy=serialization_policy).flatten(pkg_info) - - for license_path in self.license_paths: - filename = os.path.basename(license_path) - shutil.copy(license_path, os.path.join(distinfo_path, filename)) - - adios(egginfo_path) +warn( + "The 'wheel' package is no longer the canonical location of the 'bdist_wheel' " + "command, and will be removed in a future release. Please update to setuptools " + "v70.1 or later which contains an integrated version of this command.", + DeprecationWarning, + stacklevel=1, +) diff --git a/src/wheel/cli/convert.py b/src/wheel/cli/convert.py index 46fdf1eb..157a4a80 100644 --- a/src/wheel/cli/convert.py +++ b/src/wheel/cli/convert.py @@ -7,7 +7,7 @@ import zipfile from glob import iglob -from ..bdist_wheel import bdist_wheel +from .._bdist_wheel import bdist_wheel from ..wheelfile import WheelFile from . import WheelError diff --git a/tests/test_bdist_wheel.py b/tests/test_bdist_wheel.py index 84ead451..bb1457a8 100644 --- a/tests/test_bdist_wheel.py +++ b/tests/test_bdist_wheel.py @@ -16,7 +16,7 @@ import pytest import setuptools -from wheel.bdist_wheel import ( +from wheel._bdist_wheel import ( bdist_wheel, get_abi_tag, remove_readonly, @@ -439,6 +439,13 @@ def _fake_import(name: str, *args, **kwargs): if module.startswith("wheel"): monkeypatch.delitem(sys.modules, module) - from wheel import bdist_wheel + from wheel import _bdist_wheel - assert bdist_wheel + assert _bdist_wheel + + +def test_deprecated_import() -> None: + with pytest.warns(DeprecationWarning): + from wheel import bdist_wheel + + assert issubclass(bdist_wheel.bdist_wheel, setuptools.Command) diff --git a/tests/test_macosx_libfile.py b/tests/test_macosx_libfile.py index 762d8362..5dcf4108 100644 --- a/tests/test_macosx_libfile.py +++ b/tests/test_macosx_libfile.py @@ -5,7 +5,7 @@ import sysconfig import pytest -from wheel.bdist_wheel import get_platform +from wheel._bdist_wheel import get_platform from wheel.macosx_libfile import extract_macosx_min_system_version