From a95e8d00da1918b14e103b32502d3721726e5253 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sat, 13 Dec 2025 17:04:55 +0000 Subject: [PATCH 01/21] Begin Android support --- src/auditwheel/architecture.py | 1 + src/auditwheel/lddtree.py | 56 ++++++++++++++--------- src/auditwheel/libc.py | 6 ++- src/auditwheel/main.py | 4 -- src/auditwheel/main_repair.py | 41 ++++++++++++++++- src/auditwheel/policy/__init__.py | 1 + src/auditwheel/policy/android-policy.json | 26 +++++++++++ src/auditwheel/repair.py | 17 ++++--- src/auditwheel/wheel_abi.py | 17 +++++-- src/auditwheel/wheeltools.py | 2 + 10 files changed, 133 insertions(+), 38 deletions(-) create mode 100644 src/auditwheel/policy/android-policy.json diff --git a/src/auditwheel/architecture.py b/src/auditwheel/architecture.py index 4a506e8d..fc29f3ee 100644 --- a/src/auditwheel/architecture.py +++ b/src/auditwheel/architecture.py @@ -11,6 +11,7 @@ class Architecture(Enum): value: str aarch64 = "aarch64" + arm64_v8a = "arm64_v8a" armv7l = "armv7l" i686 = "i686" loongarch64 = "loongarch64" diff --git a/src/auditwheel/lddtree.py b/src/auditwheel/lddtree.py index 44785791..879362a7 100644 --- a/src/auditwheel/lddtree.py +++ b/src/auditwheel/lddtree.py @@ -480,14 +480,17 @@ def ldd( libc = Libc.MUSL if soname.startswith("ld-musl-") else Libc.GLIBC if ldpaths is None: ldpaths = load_ld_paths(libc).copy() - # XXX: Should read it and scan for /lib paths. - ldpaths["interp"] = [ - normpath(root + os.path.dirname(interp)), - normpath( - root + prefix + "/usr" + os.path.dirname(interp).lstrip(prefix) - ), - ] - log.debug(" ldpaths[interp] = %s", ldpaths["interp"]) + # XXX: Should read it and scan for /lib paths. + ldpaths["interp"] = [ + normpath(root + os.path.dirname(interp)), + normpath( + root + + prefix + + "/usr" + + os.path.dirname(interp).lstrip(prefix) + ), + ] + log.debug(" ldpaths[interp] = %s", ldpaths["interp"]) break # Parse the ELF's dynamic tags. @@ -513,19 +516,22 @@ def ldd( if _first: # get the libc based on dependencies + def set_libc(new_libc: Libc) -> None: + nonlocal libc + if libc is None: + libc = new_libc + if libc != new_libc: + msg = f"found a dependency on {new_libc} but the libc is already set to {libc}" + raise InvalidLibc(msg) + for soname in needed: if soname.startswith(("libc.musl-", "ld-musl-")): - if libc is None: - libc = Libc.MUSL - if libc != Libc.MUSL: - msg = f"found a dependency on MUSL but the libc is already set to {libc}" - raise InvalidLibc(msg) + set_libc(Libc.MUSL) elif soname == "libc.so.6" or soname.startswith(("ld-linux-", "ld64.so.")): - if libc is None: - libc = Libc.GLIBC - if libc != Libc.GLIBC: - msg = f"found a dependency on GLIBC but the libc is already set to {libc}" - raise InvalidLibc(msg) + set_libc(Libc.GLIBC) + elif soname == "libc.so": + set_libc(Libc.ANDROID) + if libc is None: # try the filename as a last resort if path.name.endswith(("-arm-linux-musleabihf.so", "-linux-musl.so")): @@ -569,12 +575,18 @@ def ldd( continue # special case for libpython, see https://github.com/pypa/auditwheel/issues/589 - # we want to return the dependency to be able to remove it later on but - # we don't want to analyze it for symbol versions nor do we want to analyze its - # dependencies as it will be removed. + # On Linux we want to return the dependency to be able to remove it later on. + # + # On Android linking with libpython is normal, but we don't want to return it as + # this will make the wheel appear to have external references, requiring it to + # have an API level of at least 24 (see main_repair.execute). + # + # Either way, we don't want to analyze it for symbol versions, nor do we want to + # analyze its dependencies. if LIBPYTHON_RE.match(soname): log.info("Skip %s resolution", soname) - _all_libs[soname] = DynamicLibrary(soname, None, None) + if libc != Libc.ANDROID: + _all_libs[soname] = DynamicLibrary(soname, None, None) continue realpath, fullpath = find_lib(platform, soname, all_ldpaths, root) diff --git a/src/auditwheel/libc.py b/src/auditwheel/libc.py index 82682df5..705921ec 100644 --- a/src/auditwheel/libc.py +++ b/src/auditwheel/libc.py @@ -22,6 +22,7 @@ class LibcVersion: class Libc(Enum): value: str + ANDROID = "android" GLIBC = "glibc" MUSL = "musl" @@ -31,7 +32,10 @@ def __str__(self) -> str: def get_current_version(self) -> LibcVersion: if self == Libc.MUSL: return _get_musl_version(_find_musl_libc()) - return _get_glibc_version() + if self == Libc.GLIBC: + return _get_glibc_version() + msg = f"can't determine version of libc '{self}'" + raise InvalidLibc(msg) @staticmethod def detect() -> Libc: diff --git a/src/auditwheel/main.py b/src/auditwheel/main.py index 758b8970..3d9350cf 100644 --- a/src/auditwheel/main.py +++ b/src/auditwheel/main.py @@ -13,10 +13,6 @@ def main() -> int | None: - if sys.platform != "linux": - print("Error: This tool only supports Linux") - return 1 - location = pathlib.Path(auditwheel.__file__).parent.resolve() version = "auditwheel {} installed at {} (python {}.{})".format( metadata.version("auditwheel"), location, *sys.version_info diff --git a/src/auditwheel/main_repair.py b/src/auditwheel/main_repair.py index 8068e437..5d672c42 100644 --- a/src/auditwheel/main_repair.py +++ b/src/auditwheel/main_repair.py @@ -2,9 +2,12 @@ import argparse import logging +import re import zlib from pathlib import Path +from packaging.utils import parse_wheel_filename + from auditwheel.architecture import Architecture from auditwheel.error import NonPlatformWheel, WheelToolsError from auditwheel.libc import Libc @@ -26,6 +29,8 @@ def configure_parser(sub_parsers) -> None: # type: ignore[no-untyped-def] These are the possible target platform tags, as specified by PEP 600. Note that old, pre-PEP 600 tags are still usable and are listed as aliases below. +- auto (determine platform tag from wheel content) +- current (use the wheel's existing platform tag) """ for p in policies: epilog += f"- {p.name}" @@ -67,6 +72,12 @@ def configure_parser(sub_parsers) -> None: # type: ignore[no-untyped-def] choices=policy_names, default="auto", ) + parser.add_argument( + "--ldpaths", + dest="LDPATHS", + help="Colon-delimited list of paths to search for external libraries. This " + "replaces the default list; to add to the default list, use LD_LIBRARY_PATH.", + ) parser.add_argument( "-L", "--lib-sdir", @@ -187,7 +198,13 @@ def execute(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: try: wheel_abi = analyze_wheel_abi( - libc, arch, wheel_file, exclude, args.DISABLE_ISA_EXT_CHECK, True + libc, + arch, + wheel_file, + exclude, + args.DISABLE_ISA_EXT_CHECK, + True, + args.LDPATHS, ) except NonPlatformWheel as e: logger.info(e.message) @@ -200,10 +217,32 @@ def execute(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: plat = policies.lowest.name else: plat = wheel_abi.overall_policy.name + elif plat_base == "current": + plats = list({t.platform for t in parse_wheel_filename(wheel_filename)[3]}) + if len(plats) != 1: + msg = ( + f'"{wheel_file}" has {len(plats)} platform tags, but ' + f"`--plat current` requires it to have exactly one." + ) + parser.error(msg) + plat = plats[0] else: plat = f"{plat_base}_{policies.architecture.value}" requested_policy = policies.get_policy_by_name(plat) + # https://android.googlesource.com/platform/bionic/+/refs/heads/main/android-changes-for-ndk-developers.md + if libc == Libc.ANDROID and wheel_abi.full_external_refs: + match = re.match(r"android_(\d+)", plat) + assert match is not None + if int(match[1]) < 24: + msg = ( + f'cannot repair "{wheel_file}" because it requires external ' + f'libraries, but the API level of "{plat}" is too low to support ' + f"DT_RUNPATH. If using cibuildwheel, set the environment variable " + f"ANDROID_API_LEVEL to 24 or higher." + ) + parser.error(msg) + if requested_policy > wheel_abi.sym_policy: msg = ( f'cannot repair "{wheel_file}" to "{plat}" ABI because of the ' diff --git a/src/auditwheel/policy/__init__.py b/src/auditwheel/policy/__init__.py index 29a743c7..45df4f04 100644 --- a/src/auditwheel/policy/__init__.py +++ b/src/auditwheel/policy/__init__.py @@ -22,6 +22,7 @@ logger = logging.getLogger(__name__) _POLICY_JSON_MAP = { + Libc.ANDROID: _HERE / "android-policy.json", Libc.GLIBC: _HERE / "manylinux-policy.json", Libc.MUSL: _HERE / "musllinux-policy.json", } diff --git a/src/auditwheel/policy/android-policy.json b/src/auditwheel/policy/android-policy.json new file mode 100644 index 00000000..c8a7fdb1 --- /dev/null +++ b/src/auditwheel/policy/android-policy.json @@ -0,0 +1,26 @@ +[ + { + "name": "linux", + "aliases": [], + "priority": -9999, + "symbol_versions": {}, + "lib_whitelist": [], + "blacklist": {} + }, + { + "name": "android_21", + "aliases": [], + "priority": -21, + "symbol_versions": {"x86_64": {}, "arm64_v8a": {}}, + "lib_whitelist": ["libandroid.so", "libc.so", "libcrypto_python.so", "libdl.so", "libEGL.so", "libGLESv1_CM.so", "libGLESv2.so", "libjnigraphics.so", "liblog.so", "libm.so", "libmediandk.so", "libOpenMAXAL.so", "libOpenSLES.so", "libsqlite3_python.so", "libssl_python.so", "libz.so"], + "blacklist": {} + }, + { + "name": "android_24", + "aliases": [], + "priority": -24, + "symbol_versions": {"x86_64": {}, "arm64_v8a": {}}, + "lib_whitelist": ["libandroid.so", "libc.so", "libcamera2ndk.so", "libcrypto_python.so", "libdl.so", "libEGL.so", "libGLESv1_CM.so", "libGLESv2.so", "libjnigraphics.so", "liblog.so", "libm.so", "libmediandk.so", "libOpenMAXAL.so", "libOpenSLES.so", "libsqlite3_python.so", "libssl_python.so", "libvulkan.so", "libz.so"], + "blacklist": {} + } +] diff --git a/src/auditwheel/repair.py b/src/auditwheel/repair.py index da39bc2b..058ad6e9 100644 --- a/src/auditwheel/repair.py +++ b/src/auditwheel/repair.py @@ -19,6 +19,7 @@ from .elfutils import elf_read_dt_needed, elf_read_rpaths from .hashfile import hashfile from .lddtree import LIBPYTHON_RE +from .libc import Libc from .policy import get_replace_platforms from .tools import is_subdir, unique_by_index from .wheel_abi import WheelAbIInfo @@ -71,14 +72,16 @@ def repair_wheel( ext_libs = v[abis[0]].libs replacements: list[tuple[str, str]] = [] for soname, src_path in ext_libs.items(): - # Handle libpython dependencies by removing them + # libpython dependencies are forbidden on Linux, but required on Android. if LIBPYTHON_RE.match(soname): - logger.warning( - "Removing %s dependency from %s. Linking with libpython is forbidden for manylinux/musllinux wheels.", - soname, - str(fn), - ) - patcher.remove_needed(fn, soname) + if wheel_abi.policies.libc in [Libc.GLIBC, Libc.MUSL]: + logger.warning( + "Removing %s dependency from %s. Linking with libpython is " + "forbidden for manylinux/musllinux wheels.", + soname, + str(fn), + ) + patcher.remove_needed(fn, soname) continue if src_path is None: diff --git a/src/auditwheel/wheel_abi.py b/src/auditwheel/wheel_abi.py index 8180c074..dd6ef0e9 100644 --- a/src/auditwheel/wheel_abi.py +++ b/src/auditwheel/wheel_abi.py @@ -21,7 +21,7 @@ ) from .error import InvalidLibc, NonPlatformWheel from .genericpkgctx import InGenericPkgCtx -from .lddtree import DynamicExecutable, ldd +from .lddtree import DynamicExecutable, ldd, parse_ld_paths from .libc import Libc from .policy import ExternalReference, Policy, WheelPolicies @@ -59,6 +59,7 @@ def get_wheel_elfdata( architecture: Architecture | None, wheel_fn: Path, exclude: frozenset[str], + ldpaths: str | None, ) -> WheelElfData: full_elftree: dict[Path, DynamicExecutable] = {} nonpy_elftree: dict[Path, DynamicExecutable] = {} @@ -87,7 +88,16 @@ def get_wheel_elfdata( # to fail and there's no need to do further checks if not shared_libraries_in_purelib: log.debug("processing: %s", fn) - elftree = ldd(fn, exclude=exclude) + + elftree = ldd( + fn, + exclude=exclude, + ldpaths=( + {"conf": parse_ld_paths(ldpaths, ""), "env": [], "interp": []} + if ldpaths + else None + ), + ) try: elf_arch = elftree.platform.baseline_architecture @@ -324,8 +334,9 @@ def analyze_wheel_abi( exclude: frozenset[str], disable_isa_ext_check: bool, allow_graft: bool, + ldpaths: str | None = None, ) -> WheelAbIInfo: - data = get_wheel_elfdata(libc, architecture, wheel_fn, exclude) + data = get_wheel_elfdata(libc, architecture, wheel_fn, exclude, ldpaths) policies = data.policies elftree_by_fn = data.full_elftree external_refs_by_fn = data.full_external_refs diff --git a/src/auditwheel/wheeltools.py b/src/auditwheel/wheeltools.py index 38b3d11d..ec2f1450 100644 --- a/src/auditwheel/wheeltools.py +++ b/src/auditwheel/wheeltools.py @@ -324,6 +324,8 @@ def get_wheel_libc(filename: str) -> Libc: result: set[Libc] = set() _, _, _, in_tags = parse_wheel_filename(filename) for tag in in_tags: + if "android" in tag.platform: + result.add(Libc.ANDROID) if "musllinux_" in tag.platform: result.add(Libc.MUSL) if "manylinux" in tag.platform: From a35924fdb963e796b2cb6edd7ea5e174403411c8 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sun, 14 Dec 2025 00:44:58 +0000 Subject: [PATCH 02/21] Improve API level auto-detection --- src/auditwheel/lddtree.py | 9 +++- src/auditwheel/libc.py | 10 ++++- src/auditwheel/main_repair.py | 49 +++++---------------- src/auditwheel/policy/__init__.py | 53 +++++++++++++++++++++-- src/auditwheel/policy/android-policy.json | 25 ++++++++--- src/auditwheel/wheel_abi.py | 26 +++++++++-- src/auditwheel/wheeltools.py | 23 ++++++---- 7 files changed, 132 insertions(+), 63 deletions(-) diff --git a/src/auditwheel/lddtree.py b/src/auditwheel/lddtree.py index 879362a7..88c05496 100644 --- a/src/auditwheel/lddtree.py +++ b/src/auditwheel/lddtree.py @@ -521,7 +521,10 @@ def set_libc(new_libc: Libc) -> None: if libc is None: libc = new_libc if libc != new_libc: - msg = f"found a dependency on {new_libc} but the libc is already set to {libc}" + msg = ( + f"found a dependency on {new_libc} but the libc is already set " + f"to {libc}" + ) raise InvalidLibc(msg) for soname in needed: @@ -542,6 +545,8 @@ def set_libc(new_libc: Libc) -> None: valid_python = tuple(f"3{minor}" for minor in range(11, 100)) if soabi[0] == "cpython" and soabi[1].startswith(valid_python): libc = Libc.GLIBC + elif path.name.endswith("-linux-android.so"): + libc = Libc.ANDROID if ldpaths is None: ldpaths = load_ld_paths(libc).copy() @@ -579,7 +584,7 @@ def set_libc(new_libc: Libc) -> None: # # On Android linking with libpython is normal, but we don't want to return it as # this will make the wheel appear to have external references, requiring it to - # have an API level of at least 24 (see main_repair.execute). + # have an API level of at least 24 (see wheel_abi.analyze_wheel_abi). # # Either way, we don't want to analyze it for symbol versions, nor do we want to # analyze its dependencies. diff --git a/src/auditwheel/libc.py b/src/auditwheel/libc.py index 705921ec..43e6ec10 100644 --- a/src/auditwheel/libc.py +++ b/src/auditwheel/libc.py @@ -22,9 +22,9 @@ class LibcVersion: class Libc(Enum): value: str - ANDROID = "android" GLIBC = "glibc" MUSL = "musl" + ANDROID = "android" def __str__(self) -> str: return self.value @@ -48,6 +48,14 @@ def detect() -> Libc: logger.debug("Falling back to GNU libc") return Libc.GLIBC + @property + def tag_prefix(self) -> str: + return { + Libc.GLIBC: "manylinux", + Libc.MUSL: "musllinux", + Libc.ANDROID: "android", + }[self] + def _find_musl_libc() -> Path: try: diff --git a/src/auditwheel/main_repair.py b/src/auditwheel/main_repair.py index 5d672c42..5d42d058 100644 --- a/src/auditwheel/main_repair.py +++ b/src/auditwheel/main_repair.py @@ -2,12 +2,9 @@ import argparse import logging -import re import zlib from pathlib import Path -from packaging.utils import parse_wheel_filename - from auditwheel.architecture import Architecture from auditwheel.error import NonPlatformWheel, WheelToolsError from auditwheel.libc import Libc @@ -29,8 +26,6 @@ def configure_parser(sub_parsers) -> None: # type: ignore[no-untyped-def] These are the possible target platform tags, as specified by PEP 600. Note that old, pre-PEP 600 tags are still usable and are listed as aliases below. -- auto (determine platform tag from wheel content) -- current (use the wheel's existing platform tag) """ for p in policies: epilog += f"- {p.name}" @@ -178,18 +173,16 @@ def execute(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: logger.debug("The libc could not be deduced from the wheel filename") libc = None - if plat_base.startswith("manylinux"): - if libc is None: - libc = Libc.GLIBC - if libc != Libc.GLIBC: - msg = f"can't repair wheel {wheel_filename} with {libc.name} libc to a wheel targeting GLIBC" - parser.error(msg) - elif plat_base.startswith("musllinux"): - if libc is None: - libc = Libc.MUSL - if libc != Libc.MUSL: - msg = f"can't repair wheel {wheel_filename} with {libc.name} libc to a wheel targeting MUSL" - parser.error(msg) + for lc in Libc: + if plat_base.startswith(lc.tag_prefix): + if libc is None: + libc = lc + if libc != lc: + msg = ( + f"can't repair wheel {wheel_filename} with {libc.name} libc " + f"to a wheel targeting {lc.name}" + ) + parser.error(msg) logger.info("Repairing %s", wheel_filename) @@ -217,32 +210,10 @@ def execute(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: plat = policies.lowest.name else: plat = wheel_abi.overall_policy.name - elif plat_base == "current": - plats = list({t.platform for t in parse_wheel_filename(wheel_filename)[3]}) - if len(plats) != 1: - msg = ( - f'"{wheel_file}" has {len(plats)} platform tags, but ' - f"`--plat current` requires it to have exactly one." - ) - parser.error(msg) - plat = plats[0] else: plat = f"{plat_base}_{policies.architecture.value}" requested_policy = policies.get_policy_by_name(plat) - # https://android.googlesource.com/platform/bionic/+/refs/heads/main/android-changes-for-ndk-developers.md - if libc == Libc.ANDROID and wheel_abi.full_external_refs: - match = re.match(r"android_(\d+)", plat) - assert match is not None - if int(match[1]) < 24: - msg = ( - f'cannot repair "{wheel_file}" because it requires external ' - f'libraries, but the API level of "{plat}" is too low to support ' - f"DT_RUNPATH. If using cibuildwheel, set the environment variable " - f"ANDROID_API_LEVEL to 24 or higher." - ) - parser.error(msg) - if requested_policy > wheel_abi.sym_policy: msg = ( f'cannot repair "{wheel_file}" to "{plat}" ABI because of the ' diff --git a/src/auditwheel/policy/__init__.py b/src/auditwheel/policy/__init__.py index 45df4f04..737daf97 100644 --- a/src/auditwheel/policy/__init__.py +++ b/src/auditwheel/policy/__init__.py @@ -5,10 +5,12 @@ import re from collections import defaultdict from collections.abc import Generator, Iterable -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from typing import Any +from packaging.utils import parse_wheel_filename + from ..architecture import Architecture from ..elfutils import filter_undefined_symbols from ..error import InvalidLibc @@ -22,9 +24,11 @@ logger = logging.getLogger(__name__) _POLICY_JSON_MAP = { - Libc.ANDROID: _HERE / "android-policy.json", Libc.GLIBC: _HERE / "manylinux-policy.json", Libc.MUSL: _HERE / "musllinux-policy.json", + # Android whitelists are based on + # https://developer.android.com/ndk/guides/stable_apis. + Libc.ANDROID: _HERE / "android-policy.json", } @@ -56,6 +60,7 @@ def __init__( *, libc: Libc, arch: Architecture, + wheel_fn: Path | None = None, musl_policy: str | None = None, ) -> None: if libc != Libc.MUSL and musl_policy is not None: @@ -117,9 +122,42 @@ def __init__( self._policies = [self._policies[0], self._policies[1]] assert len(self._policies) == 2, self._policies + elif self._libc_variant == Libc.ANDROID: + # Pick the policy with the highest API level that's less than or equal to + # the wheel's existing tag. + assert wheel_fn is not None + plats = list({t.platform for t in parse_wheel_filename(wheel_fn.name)[3]}) + if len(plats) != 1: + msg = "Android wheels must have exactly one platform tag" + raise ValueError(msg) + api_level = tag_api_level(plats[0]) + + valid_policies = [ + p + for p in self._policies + if p.name.startswith("android") and tag_api_level(p.name) <= api_level + ] + if not valid_policies: + msg = f"minimum supported platform tag is {self.lowest.name}" + raise ValueError(msg) + best_policy = max(valid_policies, key=lambda p: tag_api_level(p.name)) + + # It's unsafe to reduce the API level of the existing tag, so rename the + # policy to match it. + self._policies = [self.linux, replace(best_policy, name=plats[0])] + def __iter__(self) -> Generator[Policy]: yield from self._policies + def __len__(self) -> int: + return len(self._policies) + + def __getitem__(self, index: int) -> Policy: + return self._policies[index] + + def __setitem__(self, index: int, p: Policy) -> None: + self._policies[index] = p + @property def libc(self) -> Libc: return self._libc_variant @@ -312,7 +350,13 @@ def _fixup_musl_libc_soname( return frozenset(new_whitelist) -def get_replace_platforms(name: str) -> list[str]: +def tag_api_level(tag: str) -> int: + match = re.match(r"android_(\d+)", tag) + assert match is not None + return int(match[1]) + + +def get_replace_platforms(name: str) -> list[str | re.Pattern[str]]: """Extract platform tag replacement rules from policy >>> get_replace_platforms('linux_x86_64') @@ -331,6 +375,9 @@ def get_replace_platforms(name: str) -> list[str]: return ["linux_" + "_".join(name.split("_")[3:])] if name.startswith("musllinux_"): return ["linux_" + "_".join(name.split("_")[3:])] + if name.startswith("android_"): + # On Android it only makes sense to have one platform tag at a time. + return [re.compile(r"android_.+")] return ["linux_" + "_".join(name.split("_")[1:])] diff --git a/src/auditwheel/policy/android-policy.json b/src/auditwheel/policy/android-policy.json index c8a7fdb1..c095d1eb 100644 --- a/src/auditwheel/policy/android-policy.json +++ b/src/auditwheel/policy/android-policy.json @@ -2,7 +2,7 @@ { "name": "linux", "aliases": [], - "priority": -9999, + "priority": 0, "symbol_versions": {}, "lib_whitelist": [], "blacklist": {} @@ -10,7 +10,7 @@ { "name": "android_21", "aliases": [], - "priority": -21, + "priority": 79, "symbol_versions": {"x86_64": {}, "arm64_v8a": {}}, "lib_whitelist": ["libandroid.so", "libc.so", "libcrypto_python.so", "libdl.so", "libEGL.so", "libGLESv1_CM.so", "libGLESv2.so", "libjnigraphics.so", "liblog.so", "libm.so", "libmediandk.so", "libOpenMAXAL.so", "libOpenSLES.so", "libsqlite3_python.so", "libssl_python.so", "libz.so"], "blacklist": {} @@ -18,9 +18,24 @@ { "name": "android_24", "aliases": [], - "priority": -24, + "priority": 76, "symbol_versions": {"x86_64": {}, "arm64_v8a": {}}, "lib_whitelist": ["libandroid.so", "libc.so", "libcamera2ndk.so", "libcrypto_python.so", "libdl.so", "libEGL.so", "libGLESv1_CM.so", "libGLESv2.so", "libjnigraphics.so", "liblog.so", "libm.so", "libmediandk.so", "libOpenMAXAL.so", "libOpenSLES.so", "libsqlite3_python.so", "libssl_python.so", "libvulkan.so", "libz.so"], "blacklist": {} - } -] + }, + { + "name": "android_26", + "aliases": [], + "priority": 74, + "symbol_versions": {"x86_64": {}, "arm64_v8a": {}}, + "lib_whitelist": ["libaaudio.so", "libandroid.so", "libc.so", "libcamera2ndk.so", "libcrypto_python.so", "libdl.so", "libEGL.so", "libGLESv1_CM.so", "libGLESv2.so", "libjnigraphics.so", "liblog.so", "libm.so", "libmediandk.so", "libOpenMAXAL.so", "libOpenSLES.so", "libsqlite3_python.so", "libssl_python.so", "libsync.so", "libvulkan.so", "libz.so"], + "blacklist": {} + }, + { + "name": "android_27", + "aliases": [], + "priority": 73, + "symbol_versions": {"x86_64": {}, "arm64_v8a": {}}, + "lib_whitelist": ["libaaudio.so", "libandroid.so", "libc.so", "libcamera2ndk.so", "libcrypto_python.so", "libdl.so", "libEGL.so", "libGLESv1_CM.so", "libGLESv2.so", "libjnigraphics.so", "liblog.so", "libm.so", "libmediandk.so", "libneuralnetworks.so", "libOpenMAXAL.so", "libOpenSLES.so", "libsqlite3_python.so", "libssl_python.so", "libsync.so", "libvulkan.so", "libz.so"], + "blacklist": {} + }] diff --git a/src/auditwheel/wheel_abi.py b/src/auditwheel/wheel_abi.py index dd6ef0e9..163bc595 100644 --- a/src/auditwheel/wheel_abi.py +++ b/src/auditwheel/wheel_abi.py @@ -6,7 +6,7 @@ from collections import defaultdict from collections.abc import Mapping from copy import deepcopy -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from typing import TypeVar @@ -23,7 +23,7 @@ from .genericpkgctx import InGenericPkgCtx from .lddtree import DynamicExecutable, ldd, parse_ld_paths from .libc import Libc -from .policy import ExternalReference, Policy, WheelPolicies +from .policy import ExternalReference, Policy, WheelPolicies, tag_api_level log = logging.getLogger(__name__) @@ -59,7 +59,7 @@ def get_wheel_elfdata( architecture: Architecture | None, wheel_fn: Path, exclude: frozenset[str], - ldpaths: str | None, + ldpaths: str | None = None, ) -> WheelElfData: full_elftree: dict[Path, DynamicExecutable] = {} nonpy_elftree: dict[Path, DynamicExecutable] = {} @@ -122,7 +122,9 @@ def get_wheel_elfdata( continue if policies is None and libc is not None and architecture is not None: - policies = WheelPolicies(libc=libc, arch=architecture) + policies = WheelPolicies( + libc=libc, arch=architecture, wheel_fn=wheel_fn + ) platform_wheel = True @@ -395,6 +397,22 @@ def analyze_wheel_abi( if not allow_graft: overall_policy = min(overall_policy, ref_policy) + # https://android.googlesource.com/platform/bionic/+/refs/heads/main/android-changes-for-ndk-developers.md + if ( + libc == Libc.ANDROID + and external_libs + and tag_api_level(overall_policy.name) < 24 + ): + log.warning( + "%s requires external libraries, which requires DT_RUNPATH; " + "increasing its API level to 24.", + wheel_fn, + ) + assert overall_policy is policies[1] + overall_policy = policies[1] = replace( + overall_policy, name=f"android_24_{architecture}" + ) + return WheelAbIInfo( policies, external_refs_by_fn, diff --git a/src/auditwheel/wheeltools.py b/src/auditwheel/wheeltools.py index ec2f1450..6cbdd974 100644 --- a/src/auditwheel/wheeltools.py +++ b/src/auditwheel/wheeltools.py @@ -196,7 +196,9 @@ def iter_files(self) -> Generator[Path]: def add_platforms( - wheel_ctx: InWheelCtx, platforms: list[str], remove_platforms: Iterable[str] = () + wheel_ctx: InWheelCtx, + platforms: list[str], + remove_platforms: Iterable[str | re.Pattern[str]] = (), ) -> Path: """Add platform tags `platforms` to a wheel @@ -218,8 +220,6 @@ def add_platforms( msg = "This function should be called from wheel_ctx context manager" raise ValueError(msg) - to_remove = list(remove_platforms) # we might want to modify this, make a copy - definitely_not_purelib = False info_fname = _dist_info_dir(wheel_ctx.path) / "WHEEL" @@ -235,6 +235,14 @@ def add_platforms( _, _, _, in_tags = parse_wheel_filename(wheel_fname) original_fname_tags = sorted({tag.platform for tag in in_tags}) logger.info("Previous filename tags: %s", ", ".join(original_fname_tags)) + + to_remove: list[str] = [] + for rp in remove_platforms: + if isinstance(rp, re.Pattern): + to_remove += [tag for tag in original_fname_tags if rp.fullmatch(tag)] + else: + to_remove.append(rp) + fname_tags = [tag for tag in original_fname_tags if tag not in to_remove] fname_tags = unique_by_index(fname_tags + platforms) @@ -324,12 +332,9 @@ def get_wheel_libc(filename: str) -> Libc: result: set[Libc] = set() _, _, _, in_tags = parse_wheel_filename(filename) for tag in in_tags: - if "android" in tag.platform: - result.add(Libc.ANDROID) - if "musllinux_" in tag.platform: - result.add(Libc.MUSL) - if "manylinux" in tag.platform: - result.add(Libc.GLIBC) + for libc in Libc: + if tag.platform.startswith(libc.tag_prefix): + result.add(libc) if len(result) == 0: msg = "unknown libc used" raise WheelToolsError(msg) From d2a0db9599565f192eafc37007123953619c47ec Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Mon, 15 Dec 2025 10:11:58 +0000 Subject: [PATCH 03/21] Fix some tests --- src/auditwheel/main_repair.py | 12 ++++++------ src/auditwheel/wheel_abi.py | 3 +-- tests/integration/test_bundled_wheels.py | 2 ++ tests/unit/test_main.py | 16 ---------------- 4 files changed, 9 insertions(+), 24 deletions(-) diff --git a/src/auditwheel/main_repair.py b/src/auditwheel/main_repair.py index 5d42d058..1a2811d4 100644 --- a/src/auditwheel/main_repair.py +++ b/src/auditwheel/main_repair.py @@ -67,12 +67,6 @@ def configure_parser(sub_parsers) -> None: # type: ignore[no-untyped-def] choices=policy_names, default="auto", ) - parser.add_argument( - "--ldpaths", - dest="LDPATHS", - help="Colon-delimited list of paths to search for external libraries. This " - "replaces the default list; to add to the default list, use LD_LIBRARY_PATH.", - ) parser.add_argument( "-L", "--lib-sdir", @@ -116,6 +110,12 @@ def configure_parser(sub_parsers) -> None: # type: ignore[no-untyped-def] action="append", default=[], ) + parser.add_argument( + "--ldpaths", + dest="LDPATHS", + help="Colon-delimited list of paths to search for external libraries. This " + "replaces the default list; to add to the default list, use LD_LIBRARY_PATH.", + ) parser.add_argument( "--only-plat", dest="ONLY_PLAT", diff --git a/src/auditwheel/wheel_abi.py b/src/auditwheel/wheel_abi.py index 163bc595..01653fc2 100644 --- a/src/auditwheel/wheel_abi.py +++ b/src/auditwheel/wheel_abi.py @@ -88,13 +88,12 @@ def get_wheel_elfdata( # to fail and there's no need to do further checks if not shared_libraries_in_purelib: log.debug("processing: %s", fn) - elftree = ldd( fn, exclude=exclude, ldpaths=( {"conf": parse_ld_paths(ldpaths, ""), "env": [], "interp": []} - if ldpaths + if ldpaths is not None else None ), ) diff --git a/tests/integration/test_bundled_wheels.py b/tests/integration/test_bundled_wheels.py index b81a5a79..8799f5db 100644 --- a/tests/integration/test_bundled_wheels.py +++ b/tests/integration/test_bundled_wheels.py @@ -171,6 +171,7 @@ def test_wheel_source_date_epoch(timestamp, tmp_path, monkeypatch): WHEEL_DIR=wheel_output_path, WHEEL_FILE=[wheel_path], EXCLUDE=[], + LDPATHS=None, DISABLE_ISA_EXT_CHECK=False, ZIP_COMPRESSION_LEVEL=6, cmd="repair", @@ -199,6 +200,7 @@ def test_libpython(tmp_path, caplog): WHEEL_DIR=tmp_path, WHEEL_FILE=[wheel], EXCLUDE=[], + LDPATHS=None, DISABLE_ISA_EXT_CHECK=False, ZIP_COMPRESSION_LEVEL=6, cmd="repair", diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 3aaa4176..aa79d6af 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -8,23 +8,7 @@ from auditwheel.libc import Libc, LibcVersion from auditwheel.main import main -on_supported_platform = pytest.mark.skipif( - sys.platform != "linux", reason="requires Linux system" -) - - -def test_unsupported_platform(monkeypatch): - # GIVEN - monkeypatch.setattr(sys, "platform", "unsupported_platform") - - # WHEN - retval = main() - - # THEN - assert retval == 1 - -@on_supported_platform def test_help(monkeypatch, capsys): # GIVEN monkeypatch.setattr(sys, "argv", ["auditwheel"]) From 237874e08b4ca31da68384a1bf5f9d75300a762b Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Mon, 15 Dec 2025 14:15:21 +0000 Subject: [PATCH 04/21] Working with manual repair of numpy-1.26.2-0-cp313-cp313-android_24_arm64_v8a.whl --- src/auditwheel/lddtree.py | 16 ++++++++++++---- src/auditwheel/main_repair.py | 26 +++++++++++++++++++++++--- src/auditwheel/wheel_abi.py | 12 ++++++------ src/auditwheel/wheeltools.py | 6 +++--- 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/src/auditwheel/lddtree.py b/src/auditwheel/lddtree.py index 88c05496..c1311b5c 100644 --- a/src/auditwheel/lddtree.py +++ b/src/auditwheel/lddtree.py @@ -37,6 +37,8 @@ # Regex to match libpython shared library names LIBPYTHON_RE = re.compile(r"^libpython\d+\.\d+m?.so(\.\d)*$") +ORIGIN_RE = re.compile(r"\$(ORIGIN|\{ORIGIN\})") + @dataclass(frozen=True) class Platform: @@ -152,6 +154,10 @@ def _get_platform(elf: ELFFile) -> Platform: error_msg = "armv7l shall use hard-float" if error_msg is not None: base_arch = None + elif base_arch == Architecture.aarch64: # noqa: SIM102 + # Android uses a different platform tag for this architecture. + if elf.get_section_by_name(".note.android.ident"): + base_arch = Architecture.arm64_v8a return Platform( elf_osabi, @@ -240,8 +246,8 @@ def parse_ld_paths(str_ldpaths: str, path: str, root: str = "") -> list[str]: if ldpath == "": # The ldso treats "" paths as $PWD. ldpath_ = os.getcwd() - elif "$ORIGIN" in ldpath: - ldpath_ = ldpath.replace("$ORIGIN", os.path.dirname(os.path.abspath(path))) + elif re.search(ORIGIN_RE, ldpath): + ldpath_ = re.sub(ORIGIN_RE, os.path.dirname(os.path.abspath(path)), ldpath) else: ldpath_ = root + ldpath ldpaths.append(normpath(ldpath_)) @@ -589,8 +595,10 @@ def set_libc(new_libc: Libc) -> None: # Either way, we don't want to analyze it for symbol versions, nor do we want to # analyze its dependencies. if LIBPYTHON_RE.match(soname): - log.info("Skip %s resolution", soname) - if libc != Libc.ANDROID: + if libc == Libc.ANDROID: + _excluded_libs.add(soname) + else: + log.info("Skip %s resolution", soname) _all_libs[soname] = DynamicLibrary(soname, None, None) continue diff --git a/src/auditwheel/main_repair.py b/src/auditwheel/main_repair.py index 1a2811d4..8c0a5aa6 100644 --- a/src/auditwheel/main_repair.py +++ b/src/auditwheel/main_repair.py @@ -113,8 +113,9 @@ def configure_parser(sub_parsers) -> None: # type: ignore[no-untyped-def] parser.add_argument( "--ldpaths", dest="LDPATHS", - help="Colon-delimited list of paths to search for external libraries. This " - "replaces the default list; to add to the default list, use LD_LIBRARY_PATH.", + help="Colon-delimited list of directories to search for external libraries. " + "This replaces the default list; to add to the default, use LD_LIBRARY_PATH " + "instead.", ) parser.add_argument( "--only-plat", @@ -197,7 +198,7 @@ def execute(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: exclude, args.DISABLE_ISA_EXT_CHECK, True, - args.LDPATHS, + parse_ldpaths_arg(parser, args.LDPATHS), ) except NonPlatformWheel as e: logger.info(e.message) @@ -277,3 +278,22 @@ def execute(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: if out_wheel is not None: logger.info("\nFixed-up wheel written to %s", out_wheel) return 0 + + +# None of the special behavior of lddtree.parse_ld_paths is applicable to the --ldpaths +# option. +def parse_ldpaths_arg( + parser: argparse.ArgumentParser, ldpaths: str | None +) -> tuple[str, ...] | None: + if ldpaths is None: + return None + + result: list[str] = [] + for ldp_str in ldpaths.split(":"): + ldp_path = Path(ldp_str) + if (not ldp_str) or (not ldp_path.exists()): + msg = f"--ldpaths item {ldp_str!r} does not exist" + parser.error(msg) + result.append(str(ldp_path.absolute())) + + return tuple(result) diff --git a/src/auditwheel/wheel_abi.py b/src/auditwheel/wheel_abi.py index 01653fc2..e4f0666b 100644 --- a/src/auditwheel/wheel_abi.py +++ b/src/auditwheel/wheel_abi.py @@ -21,7 +21,7 @@ ) from .error import InvalidLibc, NonPlatformWheel from .genericpkgctx import InGenericPkgCtx -from .lddtree import DynamicExecutable, ldd, parse_ld_paths +from .lddtree import DynamicExecutable, ldd from .libc import Libc from .policy import ExternalReference, Policy, WheelPolicies, tag_api_level @@ -59,7 +59,7 @@ def get_wheel_elfdata( architecture: Architecture | None, wheel_fn: Path, exclude: frozenset[str], - ldpaths: str | None = None, + ldpaths: tuple[str, ...] | None = None, ) -> WheelElfData: full_elftree: dict[Path, DynamicExecutable] = {} nonpy_elftree: dict[Path, DynamicExecutable] = {} @@ -92,9 +92,9 @@ def get_wheel_elfdata( fn, exclude=exclude, ldpaths=( - {"conf": parse_ld_paths(ldpaths, ""), "env": [], "interp": []} - if ldpaths is not None - else None + None + if ldpaths is None + else {"conf": list(ldpaths), "env": [], "interp": []} ), ) @@ -335,7 +335,7 @@ def analyze_wheel_abi( exclude: frozenset[str], disable_isa_ext_check: bool, allow_graft: bool, - ldpaths: str | None = None, + ldpaths: tuple[str, ...] | None = None, ) -> WheelAbIInfo: data = get_wheel_elfdata(libc, architecture, wheel_fn, exclude, ldpaths) policies = data.policies diff --git a/src/auditwheel/wheeltools.py b/src/auditwheel/wheeltools.py index 6cbdd974..ddff6e39 100644 --- a/src/auditwheel/wheeltools.py +++ b/src/auditwheel/wheeltools.py @@ -273,10 +273,10 @@ def add_platforms( pyc_apis = unique_by_index(pyc_apis) # Add new platform tags for each Python version, C-API combination wanted_tags = ["-".join(tup) for tup in product(pyc_apis, platforms)] - new_tags = [tag for tag in wanted_tags if tag not in in_info_tags] unwanted_tags = ["-".join(tup) for tup in product(pyc_apis, to_remove)] - updated_tags = [tag for tag in in_info_tags if tag not in unwanted_tags] - updated_tags += new_tags + updated_tags = unique_by_index( + [tag for tag in in_info_tags if tag not in unwanted_tags] + wanted_tags + ) if updated_tags != in_info_tags: del info["Tag"] for tag in updated_tags: From 588758cd15891cfc7c3e76e5d5da7e327917dcdc Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Tue, 16 Dec 2025 17:53:42 +0000 Subject: [PATCH 05/21] Use LD_LIBRARY_PATHS variable even when --ldpaths is passed --- src/auditwheel/main_repair.py | 3 +-- src/auditwheel/wheel_abi.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/auditwheel/main_repair.py b/src/auditwheel/main_repair.py index 8c0a5aa6..46e01b64 100644 --- a/src/auditwheel/main_repair.py +++ b/src/auditwheel/main_repair.py @@ -114,8 +114,7 @@ def configure_parser(sub_parsers) -> None: # type: ignore[no-untyped-def] "--ldpaths", dest="LDPATHS", help="Colon-delimited list of directories to search for external libraries. " - "This replaces the default list; to add to the default, use LD_LIBRARY_PATH " - "instead.", + "This replaces the default list; to add to the default, use LD_LIBRARY_PATH.", ) parser.add_argument( "--only-plat", diff --git a/src/auditwheel/wheel_abi.py b/src/auditwheel/wheel_abi.py index e4f0666b..a950fb73 100644 --- a/src/auditwheel/wheel_abi.py +++ b/src/auditwheel/wheel_abi.py @@ -3,6 +3,7 @@ import functools import itertools import logging +import os from collections import defaultdict from collections.abc import Mapping from copy import deepcopy @@ -21,7 +22,7 @@ ) from .error import InvalidLibc, NonPlatformWheel from .genericpkgctx import InGenericPkgCtx -from .lddtree import DynamicExecutable, ldd +from .lddtree import DynamicExecutable, ldd, parse_ld_paths from .libc import Libc from .policy import ExternalReference, Policy, WheelPolicies, tag_api_level @@ -94,7 +95,14 @@ def get_wheel_elfdata( ldpaths=( None if ldpaths is None - else {"conf": list(ldpaths), "env": [], "interp": []} + else { + "conf": list(ldpaths), + "env": parse_ld_paths( + os.environ.get("LD_LIBRARY_PATH", ""), + path="", + ), + "interp": [], + } ), ) From 38a59c5b59ad7246a0f4949ce3f4e1ef5b701b84 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Tue, 16 Dec 2025 17:54:38 +0000 Subject: [PATCH 06/21] Use RUNPATH rather than RPATH --- src/auditwheel/main_repair.py | 2 +- src/auditwheel/patcher.py | 10 ++++++++-- src/auditwheel/repair.py | 1 + 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/auditwheel/main_repair.py b/src/auditwheel/main_repair.py index 46e01b64..d01ba7d4 100644 --- a/src/auditwheel/main_repair.py +++ b/src/auditwheel/main_repair.py @@ -261,7 +261,7 @@ def execute(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: *abis, ] - patcher = Patchelf() + patcher = Patchelf(libc) out_wheel = repair_wheel( wheel_abi, wheel_file, diff --git a/src/auditwheel/patcher.py b/src/auditwheel/patcher.py index 9f77c72d..f74d2dbd 100644 --- a/src/auditwheel/patcher.py +++ b/src/auditwheel/patcher.py @@ -6,6 +6,8 @@ from shutil import which from subprocess import CalledProcessError, check_call, check_output +from .libc import Libc + class ElfPatcher: def replace_needed(self, file_name: Path, *old_new_pairs: tuple[str, str]) -> None: @@ -46,7 +48,8 @@ def _verify_patchelf() -> None: class Patchelf(ElfPatcher): - def __init__(self) -> None: + def __init__(self, libc: Libc | None = None) -> None: + self.libc = libc _verify_patchelf() def replace_needed(self, file_name: Path, *old_new_pairs: tuple[str, str]) -> None: @@ -74,7 +77,10 @@ def set_soname(self, file_name: Path, new_so_name: str) -> None: def set_rpath(self, file_name: Path, rpath: str) -> None: check_call(["patchelf", "--remove-rpath", file_name]) - check_call(["patchelf", "--force-rpath", "--set-rpath", rpath, file_name]) + + # Android supports only RUNPATH, not RPATH. + extra_args = [] if self.libc == Libc.ANDROID else ["--force-rpath"] + check_call(["patchelf", *extra_args, "--set-rpath", rpath, file_name]) def get_rpath(self, file_name: Path) -> str: return ( diff --git a/src/auditwheel/repair.py b/src/auditwheel/repair.py index 058ad6e9..ab044071 100644 --- a/src/auditwheel/repair.py +++ b/src/auditwheel/repair.py @@ -121,6 +121,7 @@ def repair_wheel( replacements.append((n, soname_map[n][0])) if replacements: patcher.replace_needed(path, *replacements) + patcher.set_rpath(path, "$ORIGIN") if update_tags: output_wheel = add_platforms(ctx, abis, get_replace_platforms(abis[0])) From 2033e47db919b69badc2328aa1ea3c85c00cbdf3 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 4 Mar 2026 22:56:14 +0000 Subject: [PATCH 07/21] Rename and test android_api_level --- src/auditwheel/policy/__init__.py | 8 ++++---- src/auditwheel/wheel_abi.py | 4 ++-- tests/unit/test_policy.py | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/auditwheel/policy/__init__.py b/src/auditwheel/policy/__init__.py index 926024c4..188f8b97 100644 --- a/src/auditwheel/policy/__init__.py +++ b/src/auditwheel/policy/__init__.py @@ -132,17 +132,17 @@ def __init__( if len(plats) != 1: msg = "Android wheels must have exactly one platform tag" raise ValueError(msg) - api_level = tag_api_level(plats[0]) + api_level = android_api_level(plats[0]) valid_policies = [ p for p in self._policies - if p.name.startswith("android") and tag_api_level(p.name) <= api_level + if p.name.startswith("android") and android_api_level(p.name) <= api_level ] if not valid_policies: msg = f"minimum supported platform tag is {self.lowest.name}" raise ValueError(msg) - best_policy = max(valid_policies, key=lambda p: tag_api_level(p.name)) + best_policy = max(valid_policies, key=lambda p: android_api_level(p.name)) # It's unsafe to reduce the API level of the existing tag, so rename the # policy to match it. @@ -360,7 +360,7 @@ def _fixup_musl_libc_soname( return frozenset(new_whitelist) -def tag_api_level(tag: str) -> int: +def android_api_level(tag: str) -> int: match = re.match(r"android_(\d+)", tag) assert match is not None # noqa: S101 return int(match[1]) diff --git a/src/auditwheel/wheel_abi.py b/src/auditwheel/wheel_abi.py index 695c3f38..cf936aa8 100644 --- a/src/auditwheel/wheel_abi.py +++ b/src/auditwheel/wheel_abi.py @@ -22,7 +22,7 @@ from auditwheel.genericpkgctx import InGenericPkgCtx from auditwheel.lddtree import DynamicExecutable, ldd, parse_ld_paths from auditwheel.libc import Libc -from auditwheel.policy import ExternalReference, Policy, WheelPolicies, tag_api_level +from auditwheel.policy import ExternalReference, Policy, WheelPolicies, android_api_level if TYPE_CHECKING: from collections.abc import Mapping @@ -490,7 +490,7 @@ def analyze_wheel_abi( overall_policy = min(overall_policy, ref_policy) # https://android.googlesource.com/platform/bionic/+/refs/heads/main/android-changes-for-ndk-developers.md - if libc == Libc.ANDROID and external_libs and tag_api_level(overall_policy.name) < 24: + if libc == Libc.ANDROID and external_libs and android_api_level(overall_policy.name) < 24: log.warning( "%s requires external libraries, which requires DT_RUNPATH; " "increasing its API level to 24.", diff --git a/tests/unit/test_policy.py b/tests/unit/test_policy.py index 470058e7..e7732461 100644 --- a/tests/unit/test_policy.py +++ b/tests/unit/test_policy.py @@ -13,6 +13,7 @@ Policy, WheelPolicies, _validate_pep600_compliance, + android_api_level, get_replace_platforms, ) @@ -33,6 +34,23 @@ def raises(exception, match=None, escape=True): return pytest.raises(exception, match=match) +@pytest.mark.parametrize( + ("tag", "expected"), + [ + ("android_21_arm64_v8a", 21), + ("android_21_x86_64", 21), + ("android_9_x86_64", 9), + ("linux_aarch64", None), + ], +) +def test_android_api_level(tag, expected): + if expected is None: + with pytest.raises(AssertionError): + android_api_level(tag) + else: + assert android_api_level(tag) == expected + + @pytest.mark.parametrize( ("name", "expected"), [ From 3a5e1b2c7a32c1236620c7269aeb9416e3533d4a Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 4 Mar 2026 22:58:25 +0000 Subject: [PATCH 08/21] Revert unnecessary reformatting --- src/auditwheel/main_repair.py | 4 ++-- src/auditwheel/repair.py | 4 ++-- src/auditwheel/wheel_abi.py | 6 +----- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/auditwheel/main_repair.py b/src/auditwheel/main_repair.py index 0d80d7be..e2828279 100644 --- a/src/auditwheel/main_repair.py +++ b/src/auditwheel/main_repair.py @@ -192,8 +192,8 @@ def execute(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: libc = lc if libc != lc: msg = ( - f"can't repair wheel {wheel_filename} with {libc.name} libc " - f"to a wheel targeting {lc.name}" + f"can't repair wheel {wheel_filename} with {libc.name} libc to a wheel " + f"targeting {lc.name}" ) parser.error(msg) diff --git a/src/auditwheel/repair.py b/src/auditwheel/repair.py index c3de52bf..cc3a958c 100644 --- a/src/auditwheel/repair.py +++ b/src/auditwheel/repair.py @@ -79,8 +79,8 @@ def repair_wheel( if LIBPYTHON_RE.match(soname): if wheel_abi.policies.libc in [Libc.GLIBC, Libc.MUSL]: logger.warning( - "Removing %s dependency from %s. Linking with libpython is " - "forbidden for manylinux/musllinux wheels.", + "Removing %s dependency from %s. " + "Linking with libpython is forbidden for manylinux/musllinux wheels.", soname, str(fn), ) diff --git a/src/auditwheel/wheel_abi.py b/src/auditwheel/wheel_abi.py index cf936aa8..91662555 100644 --- a/src/auditwheel/wheel_abi.py +++ b/src/auditwheel/wheel_abi.py @@ -133,11 +133,7 @@ def get_wheel_elfdata( continue if policies is None and libc is not None and architecture is not None: - policies = WheelPolicies( - libc=libc, - arch=architecture, - wheel_fn=wheel_fn, - ) + policies = WheelPolicies(libc=libc, arch=architecture, wheel_fn=wheel_fn) platform_wheel = True From d4092fc662db1da3fa4733ea980ae8905d7f20f5 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 4 Mar 2026 23:15:20 +0000 Subject: [PATCH 09/21] Add tests --- src/auditwheel/lddtree.py | 10 ++++++-- src/auditwheel/main_repair.py | 25 +++---------------- src/auditwheel/patcher.py | 2 +- src/auditwheel/wheel_abi.py | 15 +++++------ tests/unit/test_elfpatcher.py | 38 +++++++++++++++------------- tests/unit/test_lddtree.py | 47 ++++++++++++++++++++++++++++++++++- tests/unit/test_libc.py | 18 ++++++++++++++ tests/unit/test_policy.py | 1 + tests/unit/test_repair.py | 9 ++++--- tests/unit/test_wheel_abi.py | 3 ++- tests/unit/test_wheeltools.py | 1 + 11 files changed, 112 insertions(+), 57 deletions(-) diff --git a/src/auditwheel/lddtree.py b/src/auditwheel/lddtree.py index 81cc8388..23b66d43 100644 --- a/src/auditwheel/lddtree.py +++ b/src/auditwheel/lddtree.py @@ -219,12 +219,12 @@ def dedupe(items: list[str]) -> list[str]: return [seen.setdefault(x, x) for x in items if x not in seen] -def parse_ld_paths(str_ldpaths: str, path: str, root: str = "") -> list[str]: +def parse_ld_paths(str_ldpaths: str, path: str = "", root: str = "") -> list[str]: """Parse the colon-delimited list of paths and apply ldso rules to each Note the special handling as dictated by the ldso: - Empty paths are equivalent to $PWD - - $ORIGIN is expanded to the path of the given file + - $ORIGIN is expanded to the directory containing the given file - (TODO) $LIB and friends Parameters @@ -241,12 +241,18 @@ def parse_ld_paths(str_ldpaths: str, path: str, root: str = "") -> list[str]: list of processed paths """ + if not str_ldpaths: + return [] + ldpaths: list[str] = [] for ldpath in str_ldpaths.split(":"): if ldpath == "": # The ldso treats "" paths as $PWD. ldpath_ = os.getcwd() elif re.search(ORIGIN_RE, ldpath): + if not path: + msg = "can't expand $ORIGIN without a path" + raise ValueError(msg) ldpath_ = re.sub(ORIGIN_RE, os.path.dirname(os.path.abspath(path)), ldpath) else: ldpath_ = root + ldpath diff --git a/src/auditwheel/main_repair.py b/src/auditwheel/main_repair.py index e2828279..27d51e69 100644 --- a/src/auditwheel/main_repair.py +++ b/src/auditwheel/main_repair.py @@ -116,6 +116,7 @@ def configure_parser(sub_parsers: Any) -> None: # noqa: ANN401 dest="LDPATHS", help="Colon-delimited list of directories to search for external libraries. " "This replaces the default list; to add to the default, use LD_LIBRARY_PATH.", + default="", ) parser.add_argument( "--only-plat", @@ -211,7 +212,7 @@ def execute(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: disable_isa_ext_check=args.DISABLE_ISA_EXT_CHECK, allow_graft=True, requested_policy_base_name=plat_base, - ldpaths=parse_ldpaths_arg(parser, args.LDPATHS), + args_ldpaths=args.LDPATHS, ) except NonPlatformWheelError as e: logger.info(e.message) @@ -281,7 +282,7 @@ def execute(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: *abis, ] - patcher = Patchelf(libc) + patcher = Patchelf(wheel_abi.policies.libc) out_wheel = repair_wheel( wheel_abi, wheel_file, @@ -297,23 +298,3 @@ def execute(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: if out_wheel is not None: logger.info("\nFixed-up wheel written to %s", out_wheel) return 0 - - -# None of the special behavior of lddtree.parse_ld_paths is applicable to the --ldpaths -# option. -def parse_ldpaths_arg( - parser: argparse.ArgumentParser, - ldpaths: str | None, -) -> tuple[str, ...] | None: - if ldpaths is None: - return None - - result: list[str] = [] - for ldp_str in ldpaths.split(":"): - ldp_path = Path(ldp_str) - if (not ldp_str) or (not ldp_path.exists()): - msg = f"--ldpaths item {ldp_str!r} does not exist" - parser.error(msg) - result.append(str(ldp_path.absolute())) - - return tuple(result) diff --git a/src/auditwheel/patcher.py b/src/auditwheel/patcher.py index a819648b..d4fd6166 100644 --- a/src/auditwheel/patcher.py +++ b/src/auditwheel/patcher.py @@ -51,7 +51,7 @@ def _verify_patchelf() -> None: class Patchelf(ElfPatcher): - def __init__(self, libc: Libc | None = None) -> None: + def __init__(self, libc: Libc) -> None: self.libc = libc _verify_patchelf() diff --git a/src/auditwheel/wheel_abi.py b/src/auditwheel/wheel_abi.py index 91662555..e833c879 100644 --- a/src/auditwheel/wheel_abi.py +++ b/src/auditwheel/wheel_abi.py @@ -64,7 +64,7 @@ def get_wheel_elfdata( architecture: Architecture | None, wheel_fn: Path, exclude: frozenset[str], - ldpaths: tuple[str, ...] | None = None, + args_ldpaths: str, ) -> WheelElfData: full_elftree: dict[Path, DynamicExecutable] = {} nonpy_elftree: dict[Path, DynamicExecutable] = {} @@ -98,13 +98,10 @@ def get_wheel_elfdata( exclude=exclude, ldpaths=( None - if ldpaths is None + if args_ldpaths == "" else { - "conf": list(ldpaths), - "env": parse_ld_paths( - os.environ.get("LD_LIBRARY_PATH", ""), - path="", - ), + "conf": parse_ld_paths(args_ldpaths), + "env": parse_ld_paths(os.environ.get("LD_LIBRARY_PATH", "")), "interp": [], } ), @@ -392,9 +389,9 @@ def analyze_wheel_abi( disable_isa_ext_check: bool, allow_graft: bool, requested_policy_base_name: str | None = None, - ldpaths: tuple[str, ...] | None = None, + args_ldpaths: str = "", ) -> WheelAbIInfo: - data = get_wheel_elfdata(libc, architecture, wheel_fn, exclude, ldpaths) + data = get_wheel_elfdata(libc, architecture, wheel_fn, exclude, args_ldpaths) policies = data.policies elftree_by_fn = data.full_elftree external_refs_by_fn = data.full_external_refs diff --git a/tests/unit/test_elfpatcher.py b/tests/unit/test_elfpatcher.py index 556489a1..7d8f34f7 100644 --- a/tests/unit/test_elfpatcher.py +++ b/tests/unit/test_elfpatcher.py @@ -6,6 +6,7 @@ import pytest +from auditwheel.libc import Libc from auditwheel.patcher import Patchelf @@ -13,7 +14,7 @@ def test_patchelf_unavailable(which): which.return_value = False with pytest.raises(ValueError, match="Cannot find required utility"): - Patchelf() + Patchelf(Libc.GLIBC) @patch("auditwheel.patcher.which") @@ -22,7 +23,7 @@ def test_patchelf_check_output_fail(check_output, which): which.return_value = True check_output.side_effect = CalledProcessError(1, "patchelf --version") with pytest.raises(ValueError, match="Could not call"): - Patchelf() + Patchelf(Libc.GLIBC) @patch("auditwheel.patcher.which") @@ -31,7 +32,7 @@ def test_patchelf_check_output_fail(check_output, which): def test_patchelf_version_check(check_output, which, version): which.return_value = True check_output.return_value.decode.return_value = f"patchelf {version}" - Patchelf() + Patchelf(Libc.GLIBC) @patch("auditwheel.patcher.which") @@ -41,17 +42,18 @@ def test_patchelf_version_check_fail(check_output, which, version): which.return_value = True check_output.return_value.decode.return_value = f"patchelf {version}" with pytest.raises(ValueError, match=f"patchelf {version} found"): - Patchelf() + Patchelf(Libc.GLIBC) +@pytest.mark.parametrize("libc", [Libc.GLIBC, Libc.MUSL, Libc.ANDROID]) @patch("auditwheel.patcher._verify_patchelf") @patch("auditwheel.patcher.check_output") @patch("auditwheel.patcher.check_call") class TestPatchElf: """ "Validate that patchelf is invoked with the correct arguments.""" - def test_replace_needed_one(self, check_call, _0, _1): # noqa: PT019 - patcher = Patchelf() + def test_replace_needed_one(self, check_call, _0, _1, libc): # noqa: PT019 + patcher = Patchelf(libc) filename = Path("test.so") soname_old = "TEST_OLD" soname_new = "TEST_NEW" @@ -60,8 +62,8 @@ def test_replace_needed_one(self, check_call, _0, _1): # noqa: PT019 ["patchelf", "--replace-needed", soname_old, soname_new, filename], ) - def test_replace_needed_multple(self, check_call, _0, _1): # noqa: PT019 - patcher = Patchelf() + def test_replace_needed_multple(self, check_call, _0, _1, libc): # noqa: PT019 + patcher = Patchelf(libc) filename = Path("test.so") replacements = [ ("TEST_OLD1", "TEST_NEW1"), @@ -79,8 +81,8 @@ def test_replace_needed_multple(self, check_call, _0, _1): # noqa: PT019 ], ) - def test_set_soname(self, check_call, _0, _1): # noqa: PT019 - patcher = Patchelf() + def test_set_soname(self, check_call, _0, _1, libc): # noqa: PT019 + patcher = Patchelf(libc) filename = Path("test.so") soname_new = "TEST_NEW" patcher.set_soname(filename, soname_new) @@ -88,21 +90,23 @@ def test_set_soname(self, check_call, _0, _1): # noqa: PT019 ["patchelf", "--set-soname", soname_new, filename], ) - def test_set_rpath(self, check_call, _0, _1): # noqa: PT019 - patcher = Patchelf() + def test_set_rpath(self, check_call, _0, _1, libc): # noqa: PT019 + patcher = Patchelf(libc) filename = Path("test.so") patcher.set_rpath(filename, "$ORIGIN/.lib") check_call_expected_args = [ call(["patchelf", "--remove-rpath", filename]), call( - ["patchelf", "--force-rpath", "--set-rpath", "$ORIGIN/.lib", filename], + ["patchelf"] + + ([] if libc == Libc.ANDROID else ["--force-rpath"]) + + ["--set-rpath", "$ORIGIN/.lib", filename], ), ] assert check_call.call_args_list == check_call_expected_args - def test_get_rpath(self, _0, check_output, _1): # noqa: PT019 - patcher = Patchelf() + def test_get_rpath(self, _0, check_output, _1, libc): # noqa: PT019 + patcher = Patchelf(libc) filename = Path("test.so") check_output.return_value = b"existing_rpath" result = patcher.get_rpath(filename) @@ -111,8 +115,8 @@ def test_get_rpath(self, _0, check_output, _1): # noqa: PT019 assert result == check_output.return_value.decode() assert check_output.call_args_list == check_output_expected_args - def test_remove_needed(self, check_call, _0, _1): # noqa: PT019 - patcher = Patchelf() + def test_remove_needed(self, check_call, _0, _1, libc): # noqa: PT019 + patcher = Patchelf(libc) filename = Path("test.so") soname_1 = "TEST_REM_1" soname_2 = "TEST_REM_2" diff --git a/tests/unit/test_lddtree.py b/tests/unit/test_lddtree.py index 6e9f0fde..19942c3b 100644 --- a/tests/unit/test_lddtree.py +++ b/tests/unit/test_lddtree.py @@ -1,9 +1,10 @@ +import os from pathlib import Path import pytest from auditwheel.architecture import Architecture -from auditwheel.lddtree import LIBPYTHON_RE, ldd +from auditwheel.lddtree import LIBPYTHON_RE, ldd, parse_ld_paths from auditwheel.libc import Libc from auditwheel.tools import zip2dir @@ -63,3 +64,47 @@ def test_libpython(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: assert libpython.platform is None assert libpython.realpath is None assert libpython.needed == () + + +def test_parse_ld_paths(): + here = str(HERE) + parent = str(HERE.parent) + + assert parse_ld_paths("") == [] + assert parse_ld_paths(f"{here}") == [here] + assert parse_ld_paths(f"{parent}") == [parent] + + # Order is preserved. + assert parse_ld_paths(f"{here}:{parent}") == [here, parent] + assert parse_ld_paths(f"{parent}:{here}") == [parent, here] + + # `..` references are normalized. + assert parse_ld_paths(f"{here}/..") == [parent] + + # Duplicate paths are deduplicated. + assert parse_ld_paths(f"{here}:{here}") == [here] + + # Empty paths are equivalent to $PWD. + cwd = str(Path.cwd()) + assert parse_ld_paths(":") == [cwd] + assert parse_ld_paths(f"{here}:") == [here, cwd] + assert parse_ld_paths(f":{here}") == [cwd, here] + + # Nonexistent paths are ignored. + assert parse_ld_paths("/nonexistent") == [] + assert parse_ld_paths(f"/nonexistent:{here}") == [here] + + +@pytest.mark.parametrize("origin", ["$ORIGIN", "${ORIGIN}"]) +def test_parse_ld_paths_origin(origin): + here = str(HERE) + parent = str(HERE.parent) + + with pytest.raises(ValueError, match=r"can't expand \$ORIGIN without a path"): + parse_ld_paths(origin) + + assert parse_ld_paths(origin, path=__file__) == [here] + assert parse_ld_paths(f"{origin}/..", path=__file__) == [parent] + + # Relative paths are made absolute. + assert parse_ld_paths(origin, path=os.path.relpath(__file__)) == [here] diff --git a/tests/unit/test_libc.py b/tests/unit/test_libc.py index 24ffefbd..1560dac8 100644 --- a/tests/unit/test_libc.py +++ b/tests/unit/test_libc.py @@ -82,3 +82,21 @@ def test_bad_glibc_version(monkeypatch, confstr): monkeypatch.setattr(os, "confstr", lambda _: confstr) with pytest.raises(InvalidLibcError): Libc.GLIBC.get_current_version() + + +# Android is cross-compiled, so autodetection is not possible. +def test_android_version(): + with pytest.raises(InvalidLibcError, match=r"can't determine version of libc 'android'"): + Libc.ANDROID.get_current_version() + + +@pytest.mark.parametrize( + ("libc", "expected"), + [ + (Libc.GLIBC, "manylinux"), + (Libc.MUSL, "musllinux"), + (Libc.ANDROID, "android"), + ], +) +def test_tag_prefix(libc, expected): + assert libc.tag_prefix == expected diff --git a/tests/unit/test_policy.py b/tests/unit/test_policy.py index e7732461..15e07678 100644 --- a/tests/unit/test_policy.py +++ b/tests/unit/test_policy.py @@ -58,6 +58,7 @@ def test_android_api_level(tag, expected): ("manylinux1_ppc64le", ["linux_ppc64le"]), ("manylinux2014_x86_64", ["linux_x86_64"]), ("manylinux_2_24_x86_64", ["linux_x86_64"]), + ("android_21_arm64_v8a", [re.compile(r"android_.+")]), ], ) def test_replacement_platform(name, expected): diff --git a/tests/unit/test_repair.py b/tests/unit/test_repair.py index 2a0df9d4..4f365b7a 100644 --- a/tests/unit/test_repair.py +++ b/tests/unit/test_repair.py @@ -3,6 +3,7 @@ from pathlib import Path from unittest.mock import call, patch +from auditwheel.libc import Libc from auditwheel.patcher import Patchelf from auditwheel.repair import append_rpath_within_wheel @@ -12,7 +13,7 @@ @patch("auditwheel.patcher.check_call") class TestRepair: def test_append_rpath(self, check_call, check_output, _): # noqa: PT019 - patcher = Patchelf() + patcher = Patchelf(Libc.GLIBC) # When a library has an existing RPATH entry within wheel_dir existing_rpath = b"$ORIGIN/.existinglibdir" check_output.return_value = existing_rpath @@ -41,7 +42,7 @@ def test_append_rpath(self, check_call, check_output, _): # noqa: PT019 assert check_call.call_args_list == check_call_expected_args def test_append_rpath_reject_outside_wheel(self, check_call, check_output, _): # noqa: PT019 - patcher = Patchelf() + patcher = Patchelf(Libc.GLIBC) # When a library has an existing RPATH entry outside wheel_dir existing_rpath = b"/outside/wheel/dir" check_output.return_value = existing_rpath @@ -70,7 +71,7 @@ def test_append_rpath_reject_outside_wheel(self, check_call, check_output, _): assert check_call.call_args_list == check_call_expected_args def test_append_rpath_ignore_duplicates(self, check_call, check_output, _): # noqa: PT019 - patcher = Patchelf() + patcher = Patchelf(Libc.GLIBC) # When a library has an existing RPATH entry and we try and append it again existing_rpath = b"$ORIGIN" check_output.return_value = existing_rpath @@ -93,7 +94,7 @@ def test_append_rpath_ignore_duplicates(self, check_call, check_output, _): # n assert check_call.call_args_list == check_call_expected_args def test_append_rpath_ignore_relative(self, check_call, check_output, _): # noqa: PT019 - patcher = Patchelf() + patcher = Patchelf(Libc.GLIBC) # When a library has an existing RPATH entry but it cannot be resolved # to an absolute path, it is eliminated existing_rpath = b"not/absolute" diff --git a/tests/unit/test_wheel_abi.py b/tests/unit/test_wheel_abi.py index 5ad88b1b..5b6034b9 100644 --- a/tests/unit/test_wheel_abi.py +++ b/tests/unit/test_wheel_abi.py @@ -61,7 +61,8 @@ def test_finds_shared_library_in_purelib( Libc.GLIBC, Architecture.x86_64, Path("/fakepath"), - frozenset(), + exclude=frozenset(), + args_ldpaths="", ) assert exec_info.value.args == (message,) diff --git a/tests/unit/test_wheeltools.py b/tests/unit/test_wheeltools.py index 7dba5a39..e8535354 100644 --- a/tests/unit/test_wheeltools.py +++ b/tests/unit/test_wheeltools.py @@ -61,6 +61,7 @@ def test_get_wheel_architecture_multiple(filename: str) -> None: ("foo-1.0-py3-none-manylinux1_x86_64.whl", Libc.GLIBC), ("foo-1.0-py3-none-manylinux1_x86_64.manylinux2010_x86_64.whl", Libc.GLIBC), ("foo-1.0-py3-none-musllinux_1_1_x86_64.whl", Libc.MUSL), + ("foo-1.0-py3-none-android_24_arm64_v8a.whl", Libc.ANDROID), ], ) def test_get_wheel_libc(filename: str, expected: Libc) -> None: From 5e54a3e58d42b80e2e3c1f8311165e5318d44edb Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Mon, 9 Mar 2026 18:54:44 +0000 Subject: [PATCH 10/21] Remove automatic increase to API level 24 --- src/auditwheel/architecture.py | 4 +- src/auditwheel/lddtree.py | 23 ++------ src/auditwheel/main_repair.py | 14 ++++- src/auditwheel/policy/__init__.py | 68 +++++++++-------------- src/auditwheel/policy/android-policy.json | 8 +-- src/auditwheel/repair.py | 19 +++---- src/auditwheel/wheel_abi.py | 17 +----- src/auditwheel/wheeltools.py | 48 ++++++++-------- tests/unit/test_policy.py | 19 ------- tests/unit/test_wheeltools.py | 38 ++++++++++++- 10 files changed, 124 insertions(+), 134 deletions(-) diff --git a/src/auditwheel/architecture.py b/src/auditwheel/architecture.py index 95aa4e21..ab1a292c 100644 --- a/src/auditwheel/architecture.py +++ b/src/auditwheel/architecture.py @@ -11,7 +11,7 @@ class Architecture(Enum): value: str aarch64 = "aarch64" - arm64_v8a = "arm64_v8a" + arm64_v8a = "arm64_v8a" # Android armv7l = "armv7l" i686 = "i686" loongarch64 = "loongarch64" @@ -29,6 +29,8 @@ def __str__(self) -> str: @property def baseline(self) -> Architecture: + if self.value.startswith("arm64"): + return Architecture.aarch64 if self.value.startswith("x86_64"): return Architecture.x86_64 return self diff --git a/src/auditwheel/lddtree.py b/src/auditwheel/lddtree.py index 23b66d43..36f9dcc0 100644 --- a/src/auditwheel/lddtree.py +++ b/src/auditwheel/lddtree.py @@ -37,6 +37,7 @@ # Regex to match libpython shared library names LIBPYTHON_RE = re.compile(r"^libpython\d+\.\d+m?.so(\.\d)*$") +# Regex to match ORIGIN references in rpaths. ORIGIN_RE = re.compile(r"\$(ORIGIN|\{ORIGIN\})") @@ -152,10 +153,6 @@ def _get_platform(elf: ELFFile) -> Platform: error_msg = "armv7l shall use hard-float" if error_msg is not None: base_arch = None - elif base_arch == Architecture.aarch64: # noqa: SIM102 - # Android uses a different platform tag for this architecture. - if elf.get_section_by_name(".note.android.ident"): - base_arch = Architecture.arm64_v8a return Platform( elf_osabi, @@ -594,20 +591,12 @@ def set_libc(new_libc: Libc) -> None: continue # special case for libpython, see https://github.com/pypa/auditwheel/issues/589 - # On Linux we want to return the dependency to be able to remove it later on. - # - # On Android linking with libpython is normal, but we don't want to return it as - # this will make the wheel appear to have external references, requiring it to - # have an API level of at least 24 (see wheel_abi.analyze_wheel_abi). - # - # Either way, we don't want to analyze it for symbol versions, nor do we want to - # analyze its dependencies. + # we want to return the dependency to be able to remove it later on but + # we don't want to analyze it for symbol versions nor do we want to analyze its + # dependencies as it will be removed. if LIBPYTHON_RE.match(soname): - if libc == Libc.ANDROID: - _excluded_libs.add(soname) - else: - log.info("Skip %s resolution", soname) - _all_libs[soname] = DynamicLibrary(soname, None, None) + log.info("Skip %s resolution", soname) + _all_libs[soname] = DynamicLibrary(soname, None, None) continue realpath, fullpath = find_lib(platform, soname, all_ldpaths, root) diff --git a/src/auditwheel/main_repair.py b/src/auditwheel/main_repair.py index 27d51e69..7a1afb42 100644 --- a/src/auditwheel/main_repair.py +++ b/src/auditwheel/main_repair.py @@ -9,11 +9,12 @@ from auditwheel.architecture import Architecture from auditwheel.error import NonPlatformWheelError, WheelToolsError +from auditwheel.lddtree import LIBPYTHON_RE from auditwheel.libc import Libc from auditwheel.patcher import Patchelf from auditwheel.policy import WheelPolicies from auditwheel.tools import EnvironmentDefault -from auditwheel.wheeltools import get_wheel_architecture, get_wheel_libc +from auditwheel.wheeltools import android_api_level, get_wheel_architecture, get_wheel_libc logger = logging.getLogger(__name__) @@ -235,6 +236,17 @@ def execute(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: plat = f"{plat_base}_{policies.architecture.value}" requested_policy = policies.get_policy_by_name(plat) + # On Android, grafted libraries are only supported on API level 24 or higher + # (https://android.googlesource.com/platform/bionic/+/refs/heads/main/android-changes-for-ndk-developers.md). + if libc == Libc.ANDROID and android_api_level(plat) < 24: + for soname in wheel_abi.external_refs[plat].libs: + if not LIBPYTHON_RE.match(soname): + msg = ( + "grafting external libraries requires RUNPATH, which requires " + "API level 24 or higher." + ) + parser.error(msg) + if requested_policy > wheel_abi.sym_policy: msg = ( f'cannot repair "{wheel_file}" to "{plat}" ABI because of the ' diff --git a/src/auditwheel/policy/__init__.py b/src/auditwheel/policy/__init__.py index 188f8b97..0d3e024a 100644 --- a/src/auditwheel/policy/__init__.py +++ b/src/auditwheel/policy/__init__.py @@ -8,13 +8,12 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from packaging.utils import parse_wheel_filename - from auditwheel.architecture import Architecture from auditwheel.elfutils import filter_undefined_symbols from auditwheel.error import InvalidLibcError from auditwheel.libc import Libc from auditwheel.tools import is_subdir +from auditwheel.wheeltools import android_api_level, get_wheel_platforms if TYPE_CHECKING: from collections.abc import Generator, Iterable @@ -29,8 +28,6 @@ _POLICY_JSON_MAP = { Libc.GLIBC: _HERE / "manylinux-policy.json", Libc.MUSL: _HERE / "musllinux-policy.json", - # Android whitelists are based on - # https://developer.android.com/ndk/guides/stable_apis. Libc.ANDROID: _HERE / "android-policy.json", } @@ -125,41 +122,37 @@ def __init__( assert len(self._policies) == 2, self._policies # noqa: S101 elif self._libc_variant == Libc.ANDROID: - # Pick the policy with the highest API level that's less than or equal to - # the wheel's existing tag. + # Every Android API level has its own platform tag. One or two new levels are created + # every year, so the policy file doesn't list them all. Instead, it only lists the + # levels in which the set of available libraries changed + # (https://developer.android.com/ndk/guides/stable_apis). + # + # Determine the wheel's API level. assert wheel_fn is not None # noqa: S101 - plats = list({t.platform for t in parse_wheel_filename(wheel_fn.name)[3]}) - if len(plats) != 1: - msg = "Android wheels must have exactly one platform tag" + platforms = get_wheel_platforms(wheel_fn.name) + if len(platforms) != 1: + msg = f"Android wheels must have exactly one platform tag, got {platforms}" raise ValueError(msg) - api_level = android_api_level(plats[0]) - - valid_policies = [ - p - for p in self._policies - if p.name.startswith("android") and android_api_level(p.name) <= api_level - ] - if not valid_policies: - msg = f"minimum supported platform tag is {self.lowest.name}" + platform = platforms.pop() + api_level = android_api_level(platform) + + # Pick the policy with the highest API level that's less than or equal to the wheel's + # existing tag. + for p in self._policies: + if p.name.startswith("android") and android_api_level(p.name) <= api_level: + # Rename the policy to match the existing tag, and remove all other policies. + self._policies = [self.linux, replace(p, name=platform)] + break + else: + msg = ( + f"no Android policies match the platform tag {platform!r}. The minimum " + f"supported API level is {android_api_level(self.highest.name)}." + ) raise ValueError(msg) - best_policy = max(valid_policies, key=lambda p: android_api_level(p.name)) - - # It's unsafe to reduce the API level of the existing tag, so rename the - # policy to match it. - self._policies = [self.linux, replace(best_policy, name=plats[0])] def __iter__(self) -> Generator[Policy]: yield from self._policies - def __len__(self) -> int: - return len(self._policies) - - def __getitem__(self, index: int) -> Policy: - return self._policies[index] - - def __setitem__(self, index: int, p: Policy) -> None: - self._policies[index] = p - @property def libc(self) -> Libc: return self._libc_variant @@ -360,13 +353,7 @@ def _fixup_musl_libc_soname( return frozenset(new_whitelist) -def android_api_level(tag: str) -> int: - match = re.match(r"android_(\d+)", tag) - assert match is not None # noqa: S101 - return int(match[1]) - - -def get_replace_platforms(name: str) -> list[str | re.Pattern[str]]: +def get_replace_platforms(name: str) -> list[str]: """Extract platform tag replacement rules from policy >>> get_replace_platforms('linux_x86_64') @@ -385,9 +372,6 @@ def get_replace_platforms(name: str) -> list[str | re.Pattern[str]]: return ["linux_" + "_".join(name.split("_")[3:])] if name.startswith("musllinux_"): return ["linux_" + "_".join(name.split("_")[3:])] - if name.startswith("android_"): - # On Android it only makes sense to have one platform tag at a time. - return [re.compile(r"android_.+")] return ["linux_" + "_".join(name.split("_")[1:])] diff --git a/src/auditwheel/policy/android-policy.json b/src/auditwheel/policy/android-policy.json index c095d1eb..7919f6dd 100644 --- a/src/auditwheel/policy/android-policy.json +++ b/src/auditwheel/policy/android-policy.json @@ -11,7 +11,7 @@ "name": "android_21", "aliases": [], "priority": 79, - "symbol_versions": {"x86_64": {}, "arm64_v8a": {}}, + "symbol_versions": {"x86_64": {}, "aarch64": {}}, "lib_whitelist": ["libandroid.so", "libc.so", "libcrypto_python.so", "libdl.so", "libEGL.so", "libGLESv1_CM.so", "libGLESv2.so", "libjnigraphics.so", "liblog.so", "libm.so", "libmediandk.so", "libOpenMAXAL.so", "libOpenSLES.so", "libsqlite3_python.so", "libssl_python.so", "libz.so"], "blacklist": {} }, @@ -19,7 +19,7 @@ "name": "android_24", "aliases": [], "priority": 76, - "symbol_versions": {"x86_64": {}, "arm64_v8a": {}}, + "symbol_versions": {"x86_64": {}, "aarch64": {}}, "lib_whitelist": ["libandroid.so", "libc.so", "libcamera2ndk.so", "libcrypto_python.so", "libdl.so", "libEGL.so", "libGLESv1_CM.so", "libGLESv2.so", "libjnigraphics.so", "liblog.so", "libm.so", "libmediandk.so", "libOpenMAXAL.so", "libOpenSLES.so", "libsqlite3_python.so", "libssl_python.so", "libvulkan.so", "libz.so"], "blacklist": {} }, @@ -27,7 +27,7 @@ "name": "android_26", "aliases": [], "priority": 74, - "symbol_versions": {"x86_64": {}, "arm64_v8a": {}}, + "symbol_versions": {"x86_64": {}, "aarch64": {}}, "lib_whitelist": ["libaaudio.so", "libandroid.so", "libc.so", "libcamera2ndk.so", "libcrypto_python.so", "libdl.so", "libEGL.so", "libGLESv1_CM.so", "libGLESv2.so", "libjnigraphics.so", "liblog.so", "libm.so", "libmediandk.so", "libOpenMAXAL.so", "libOpenSLES.so", "libsqlite3_python.so", "libssl_python.so", "libsync.so", "libvulkan.so", "libz.so"], "blacklist": {} }, @@ -35,7 +35,7 @@ "name": "android_27", "aliases": [], "priority": 73, - "symbol_versions": {"x86_64": {}, "arm64_v8a": {}}, + "symbol_versions": {"x86_64": {}, "aarch64": {}}, "lib_whitelist": ["libaaudio.so", "libandroid.so", "libc.so", "libcamera2ndk.so", "libcrypto_python.so", "libdl.so", "libEGL.so", "libGLESv1_CM.so", "libGLESv2.so", "libjnigraphics.so", "liblog.so", "libm.so", "libmediandk.so", "libneuralnetworks.so", "libOpenMAXAL.so", "libOpenSLES.so", "libsqlite3_python.so", "libssl_python.so", "libsync.so", "libvulkan.so", "libz.so"], "blacklist": {} }] diff --git a/src/auditwheel/repair.py b/src/auditwheel/repair.py index cc3a958c..3da5cc9a 100644 --- a/src/auditwheel/repair.py +++ b/src/auditwheel/repair.py @@ -11,7 +11,7 @@ from subprocess import check_call from typing import TYPE_CHECKING -from auditwheel.elfutils import elf_read_dt_needed, elf_read_rpaths +from auditwheel.elfutils import elf_read_dt_needed from auditwheel.hashfile import hashfile from auditwheel.lddtree import LIBPYTHON_RE from auditwheel.libc import Libc @@ -75,7 +75,7 @@ def repair_wheel( ext_libs = v[abis[0]].libs replacements: list[tuple[str, str]] = [] for soname, src_path in ext_libs.items(): - # libpython dependencies are forbidden on Linux, but required on Android. + # libpython dependencies are forbidden on Linux, but allowed on Android. if LIBPYTHON_RE.match(soname): if wheel_abi.policies.libc in [Libc.GLIBC, Libc.MUSL]: logger.warning( @@ -122,7 +122,6 @@ def repair_wheel( replacements.append((n, soname_map[n][0])) if replacements: patcher.replace_needed(path, *replacements) - patcher.set_rpath(path, "$ORIGIN") if update_tags: output_wheel = add_platforms(ctx, abis, get_replace_platforms(abis[0])) @@ -159,13 +158,8 @@ def copylib(src_path: Path, dest_dir: Path, patcher: ElfPatcher) -> tuple[str, P 1) Copy the file from src_path to dest_dir/ 2) Rename the shared object from soname to soname. - 3) If the library has a RUNPATH/RPATH, clear it and set RPATH to point to - its new location. + 3) Set its RPATH to point to its new location. """ - # Copy the a shared library from the system (src_path) into the wheel - # if the library has a RUNPATH/RPATH we clear it and set RPATH to point to - # its new location. - with src_path.open("rb") as f: shorthash = hashfile(f)[:8] @@ -178,7 +172,6 @@ def copylib(src_path: Path, dest_dir: Path, patcher: ElfPatcher) -> tuple[str, P return new_soname, dest_path logger.debug("Grafting: %s -> %s", src_path, dest_path) - rpaths = elf_read_rpaths(src_path) shutil.copy2(src_path, dest_path) statinfo = dest_path.stat() if not statinfo.st_mode & stat.S_IWRITE: @@ -186,8 +179,10 @@ def copylib(src_path: Path, dest_dir: Path, patcher: ElfPatcher) -> tuple[str, P patcher.set_soname(dest_path, new_soname) - if any(itertools.chain(rpaths["rpaths"], rpaths["runpaths"])): - patcher.set_rpath(dest_path, "$ORIGIN") + # Set the RPATH so the library can find any other libraries which we copy to the same location. + # This is particularly important on Android, which uses RUNPATH, which doesn't affect transitive + # dependencies (https://bugs.launchpad.net/ubuntu/+source/eglibc/+bug/1253638). + patcher.set_rpath(dest_path, "$ORIGIN") return new_soname, dest_path diff --git a/src/auditwheel/wheel_abi.py b/src/auditwheel/wheel_abi.py index e833c879..64b1a793 100644 --- a/src/auditwheel/wheel_abi.py +++ b/src/auditwheel/wheel_abi.py @@ -6,7 +6,7 @@ import os from collections import defaultdict from copy import deepcopy -from dataclasses import dataclass, replace +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, TypeVar @@ -22,7 +22,7 @@ from auditwheel.genericpkgctx import InGenericPkgCtx from auditwheel.lddtree import DynamicExecutable, ldd, parse_ld_paths from auditwheel.libc import Libc -from auditwheel.policy import ExternalReference, Policy, WheelPolicies, android_api_level +from auditwheel.policy import ExternalReference, Policy, WheelPolicies if TYPE_CHECKING: from collections.abc import Mapping @@ -482,19 +482,6 @@ def analyze_wheel_abi( if not allow_graft: overall_policy = min(overall_policy, ref_policy) - # https://android.googlesource.com/platform/bionic/+/refs/heads/main/android-changes-for-ndk-developers.md - if libc == Libc.ANDROID and external_libs and android_api_level(overall_policy.name) < 24: - log.warning( - "%s requires external libraries, which requires DT_RUNPATH; " - "increasing its API level to 24.", - wheel_fn, - ) - assert overall_policy is policies[1] # noqa: S101 - overall_policy = policies[1] = replace( - overall_policy, - name=f"android_24_{architecture}", - ) - return WheelAbIInfo( policies, external_refs_by_fn, diff --git a/src/auditwheel/wheeltools.py b/src/auditwheel/wheeltools.py index 3627266f..b1ca49ad 100644 --- a/src/auditwheel/wheeltools.py +++ b/src/auditwheel/wheeltools.py @@ -196,7 +196,7 @@ def iter_files(self) -> Generator[Path]: def add_platforms( wheel_ctx: InWheelCtx, platforms: list[str], - remove_platforms: Iterable[str | re.Pattern[str]] = (), + remove_platforms: Iterable[str] = (), ) -> Path: """Add platform tags `platforms` to a wheel @@ -218,6 +218,8 @@ def add_platforms( msg = "This function should be called from wheel_ctx context manager" raise ValueError(msg) + to_remove = list(remove_platforms) # we might want to modify this, make a copy + definitely_not_purelib = False info_fname = _dist_info_dir(wheel_ctx.path) / "WHEEL" @@ -230,17 +232,8 @@ def add_platforms( out_dir = Path.cwd() wheel_fname = wheel_ctx.in_wheel.name - _, _, _, in_tags = parse_wheel_filename(wheel_fname) - original_fname_tags = sorted({tag.platform for tag in in_tags}) + original_fname_tags = sorted(get_wheel_platforms(wheel_fname)) logger.info("Previous filename tags: %s", ", ".join(original_fname_tags)) - - to_remove: list[str] = [] - for rp in remove_platforms: - if isinstance(rp, re.Pattern): - to_remove += [tag for tag in original_fname_tags if rp.fullmatch(tag)] - else: - to_remove.append(rp) - fname_tags = [tag for tag in original_fname_tags if tag not in to_remove] fname_tags = unique_by_index(fname_tags + platforms) @@ -271,10 +264,10 @@ def add_platforms( pyc_apis = unique_by_index(pyc_apis) # Add new platform tags for each Python version, C-API combination wanted_tags = ["-".join(tup) for tup in product(pyc_apis, platforms)] + new_tags = [tag for tag in wanted_tags if tag not in in_info_tags] unwanted_tags = ["-".join(tup) for tup in product(pyc_apis, to_remove)] - updated_tags = unique_by_index( - [tag for tag in in_info_tags if tag not in unwanted_tags] + wanted_tags, - ) + updated_tags = [tag for tag in in_info_tags if tag not in unwanted_tags] + updated_tags += new_tags if updated_tags != in_info_tags: del info["Tag"] for tag in updated_tags: @@ -296,21 +289,20 @@ def get_wheel_architecture(filename: str) -> Architecture: result: set[Architecture] = set() missed = False pure = True - _, _, _, in_tags = parse_wheel_filename(filename) - for tag in in_tags: + for platform in get_wheel_platforms(filename): found = False - pure_ = tag.platform == "any" + pure_ = platform == "any" pure = pure and pure_ missed = missed or pure_ if not pure_: for arch in Architecture: - if tag.platform.endswith(f"_{arch.value}"): + if platform.endswith(f"_{arch.value}"): result.add(arch.baseline) found = True if not found: logger.warning( "couldn't guess architecture for platform tag '%s'", - tag.platform, + platform, ) missed = True if len(result) == 0: @@ -329,10 +321,9 @@ def get_wheel_architecture(filename: str) -> Architecture: def get_wheel_libc(filename: str) -> Libc: result: set[Libc] = set() - _, _, _, in_tags = parse_wheel_filename(filename) - for tag in in_tags: + for platform in get_wheel_platforms(filename): for libc in Libc: - if tag.platform.startswith(libc.tag_prefix): + if platform.startswith(libc.tag_prefix): result.add(libc) if len(result) == 0: msg = "unknown libc used" @@ -341,3 +332,16 @@ def get_wheel_libc(filename: str) -> Libc: msg = f"wheels with multiple libc are not supported, got {result}" raise WheelToolsError(msg) return result.pop() + + +def get_wheel_platforms(filename: str) -> set[str]: + _, _, _, in_tags = parse_wheel_filename(filename) + return {tag.platform for tag in in_tags} + + +def android_api_level(tag: str) -> int: + match = re.match(r"android_(\d+)", tag) + if match is None: + msg = f"invalid tag: {tag}" + raise ValueError(msg) + return int(match[1]) diff --git a/tests/unit/test_policy.py b/tests/unit/test_policy.py index 15e07678..470058e7 100644 --- a/tests/unit/test_policy.py +++ b/tests/unit/test_policy.py @@ -13,7 +13,6 @@ Policy, WheelPolicies, _validate_pep600_compliance, - android_api_level, get_replace_platforms, ) @@ -34,23 +33,6 @@ def raises(exception, match=None, escape=True): return pytest.raises(exception, match=match) -@pytest.mark.parametrize( - ("tag", "expected"), - [ - ("android_21_arm64_v8a", 21), - ("android_21_x86_64", 21), - ("android_9_x86_64", 9), - ("linux_aarch64", None), - ], -) -def test_android_api_level(tag, expected): - if expected is None: - with pytest.raises(AssertionError): - android_api_level(tag) - else: - assert android_api_level(tag) == expected - - @pytest.mark.parametrize( ("name", "expected"), [ @@ -58,7 +40,6 @@ def test_android_api_level(tag, expected): ("manylinux1_ppc64le", ["linux_ppc64le"]), ("manylinux2014_x86_64", ["linux_x86_64"]), ("manylinux_2_24_x86_64", ["linux_x86_64"]), - ("android_21_arm64_v8a", [re.compile(r"android_.+")]), ], ) def test_replacement_platform(name, expected): diff --git a/tests/unit/test_wheeltools.py b/tests/unit/test_wheeltools.py index e8535354..92889f96 100644 --- a/tests/unit/test_wheeltools.py +++ b/tests/unit/test_wheeltools.py @@ -14,8 +14,10 @@ InWheelCtx, WheelToolsError, add_platforms, + android_api_level, get_wheel_architecture, get_wheel_libc, + get_wheel_platforms, ) HERE = Path(__file__).parent.resolve() @@ -24,7 +26,11 @@ @pytest.mark.parametrize( ("filename", "expected"), [(f"foo-1.0-py3-none-linux_{arch}.whl", arch) for arch in Architecture] - + [("foo-1.0-py3-none-linux_x86_64.manylinux1_x86_64.whl", Architecture.x86_64)], + + [ + ("foo-1.0-py3-none-linux_x86_64.manylinux1_x86_64.whl", Architecture.x86_64), + ("foo-1.0-py3-none-android_21_x86_64.whl", Architecture.x86_64), + ("foo-1.0-py3-none-android_21_arm64_v8a.whl", Architecture.aarch64), + ], ) def test_get_wheel_architecture(filename: str, expected: Architecture) -> None: arch = get_wheel_architecture(filename) @@ -88,6 +94,36 @@ def test_get_wheel_libc_multiple(filename: str) -> None: get_wheel_libc(filename) +@pytest.mark.parametrize( + ("filename", "expected"), + [ + ("foo-1.0-py3-none-manylinux1_x86_64.whl", {"manylinux1_x86_64"}), + ("foo-1.0-py3-none-any.any.whl", {"any"}), + ("foo-1.0-py3-none-linux_x86_64.any.whl", {"linux_x86_64", "any"}), + ("foo-1.0-py2.py3-none-linux_x86_64.any.whl", {"linux_x86_64", "any"}), + ], +) +def test_get_wheel_platforms(filename: str, expected: set[str]) -> None: + assert get_wheel_platforms(filename) == expected + + +@pytest.mark.parametrize( + ("tag", "expected"), + [ + ("android_21_arm64_v8a", 21), + ("android_21_x86_64", 21), + ("android_9_whatever", 9), + ("linux_x86_64", None), + ], +) +def test_android_api_level(tag, expected): + if expected is None: + with pytest.raises(ValueError, match=f"invalid tag: {tag}"): + android_api_level(tag) + else: + assert android_api_level(tag) == expected + + def test_inwheel_tmpdir(tmp_path, monkeypatch): wheel_path = ( HERE / "../integration/arch-wheels/glibc/testsimple-0.0.1-cp313-cp313-linux_x86_64.whl" From c2a76d40693ce5e3045e1a7f701ced05db8ca9e3 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Fri, 13 Mar 2026 17:56:11 +0000 Subject: [PATCH 11/21] Add tests for policies --- src/auditwheel/policy/__init__.py | 23 ++++++------- src/auditwheel/wheeltools.py | 6 ++-- tests/unit/test_policy.py | 54 +++++++++++++++++++++++++++++++ tests/unit/test_wheeltools.py | 11 ++++--- 4 files changed, 73 insertions(+), 21 deletions(-) diff --git a/src/auditwheel/policy/__init__.py b/src/auditwheel/policy/__init__.py index 0d3e024a..2bfad861 100644 --- a/src/auditwheel/policy/__init__.py +++ b/src/auditwheel/policy/__init__.py @@ -122,32 +122,29 @@ def __init__( assert len(self._policies) == 2, self._policies # noqa: S101 elif self._libc_variant == Libc.ANDROID: - # Every Android API level has its own platform tag. One or two new levels are created - # every year, so the policy file doesn't list them all. Instead, it only lists the - # levels in which the set of available libraries changed - # (https://developer.android.com/ndk/guides/stable_apis). - # - # Determine the wheel's API level. + # Android wheels always have a platform tag with an API level (OS version). We won't + # change that tag, we'll only use it to determine which libraries need to be grafted. assert wheel_fn is not None # noqa: S101 platforms = get_wheel_platforms(wheel_fn.name) if len(platforms) != 1: msg = f"Android wheels must have exactly one platform tag, got {platforms}" raise ValueError(msg) - platform = platforms.pop() + platform = platforms[0] api_level = android_api_level(platform) - # Pick the policy with the highest API level that's less than or equal to the wheel's - # existing tag. + # One or two new API levels are created every year, so the policy file only lists the + # levels in which the available libraries changed + # (https://developer.android.com/ndk/guides/stable_apis). We should use the policy with + # the highest API level that's less than or equal to the wheel's tag. for p in self._policies: if p.name.startswith("android") and android_api_level(p.name) <= api_level: # Rename the policy to match the existing tag, and remove all other policies. self._policies = [self.linux, replace(p, name=platform)] break else: - msg = ( - f"no Android policies match the platform tag {platform!r}. The minimum " - f"supported API level is {android_api_level(self.highest.name)}." - ) + # The minimum API level is 21, which was the first version to be officially + # supported by Python (see PEP 738). + msg = f"no Android policies match the platform tag {platform!r}" raise ValueError(msg) def __iter__(self) -> Generator[Policy]: diff --git a/src/auditwheel/wheeltools.py b/src/auditwheel/wheeltools.py index b1ca49ad..51cb82db 100644 --- a/src/auditwheel/wheeltools.py +++ b/src/auditwheel/wheeltools.py @@ -232,7 +232,7 @@ def add_platforms( out_dir = Path.cwd() wheel_fname = wheel_ctx.in_wheel.name - original_fname_tags = sorted(get_wheel_platforms(wheel_fname)) + original_fname_tags = get_wheel_platforms(wheel_fname) logger.info("Previous filename tags: %s", ", ".join(original_fname_tags)) fname_tags = [tag for tag in original_fname_tags if tag not in to_remove] fname_tags = unique_by_index(fname_tags + platforms) @@ -334,9 +334,9 @@ def get_wheel_libc(filename: str) -> Libc: return result.pop() -def get_wheel_platforms(filename: str) -> set[str]: +def get_wheel_platforms(filename: str) -> list[str]: _, _, _, in_tags = parse_wheel_filename(filename) - return {tag.platform for tag in in_tags} + return sorted({tag.platform for tag in in_tags}) def android_api_level(tag: str) -> int: diff --git a/tests/unit/test_policy.py b/tests/unit/test_policy.py index 470058e7..99a22655 100644 --- a/tests/unit/test_policy.py +++ b/tests/unit/test_policy.py @@ -1,11 +1,13 @@ from __future__ import annotations +import json import re from contextlib import nullcontext as does_not_raise from pathlib import Path import pytest +import auditwheel.policy from auditwheel.architecture import Architecture from auditwheel.lddtree import DynamicExecutable, DynamicLibrary, Platform from auditwheel.libc import Libc @@ -266,3 +268,55 @@ def test_policy_checks_glibc(): policy = policies.versioned_symbols_policy({"some_library.so": {"IAMALIBRARY"}}) assert policy == policies.highest assert policies.linux < policies.lowest < policies.highest + + +@pytest.mark.parametrize( + ("wheel_level", "policy_level"), + [(21, 21), (22, 21), (23, 21), (24, 24), (25, 24), (26, 26), (27, 27), (28, 27)], +) +@pytest.mark.parametrize(("arch"), [Architecture.arm64_v8a, Architecture.x86_64]) +def test_android(wheel_level, policy_level, arch): + for policy_json in json.loads( + (Path(auditwheel.policy.__file__).parent / "android-policy.json").read_text(), + ): + if policy_json["name"] == f"android_{policy_level}": + whitelist = frozenset(policy_json["lib_whitelist"]) + break + else: + raise AssertionError(policy_level) + + assert "libc.so" in whitelist + assert ("libcamera2ndk.so" in whitelist) == (policy_level >= 24) + assert ("libaaudio.so" in whitelist) == (policy_level >= 26) + assert ("libneuralnetworks.so" in whitelist) == (policy_level >= 27) + + platform = f"android_{wheel_level}_{arch.value}" + policies = WheelPolicies( + libc=Libc.ANDROID, + arch=arch.baseline, + wheel_fn=Path(f"foo-1.0-py3-none-{platform}.whl"), + ) + assert len(list(policies)) == 2 + assert policies.linux.whitelist == frozenset() + + assert policies.lowest is policies.highest + assert policies.lowest.whitelist == whitelist + + +@pytest.mark.parametrize( + ("filename", "message"), + [ + ( + "foo-1.0-py3-none-android_21_arm64_v8a.android_22_arm64_v8a.whl", + "Android wheels must have exactly one platform tag, got " + "['android_21_arm64_v8a', 'android_22_arm64_v8a']", + ), + ( + "foo-1.0-py3-none-android_20_x86_64.whl", + "no Android policies match the platform tag 'android_20_x86_64'", + ), + ], +) +def test_android_error(filename, message): + with pytest.raises(ValueError, match=re.escape(message)): + WheelPolicies(libc=Libc.ANDROID, arch=Architecture.x86_64, wheel_fn=Path(filename)) diff --git a/tests/unit/test_wheeltools.py b/tests/unit/test_wheeltools.py index 92889f96..801907a1 100644 --- a/tests/unit/test_wheeltools.py +++ b/tests/unit/test_wheeltools.py @@ -97,13 +97,14 @@ def test_get_wheel_libc_multiple(filename: str) -> None: @pytest.mark.parametrize( ("filename", "expected"), [ - ("foo-1.0-py3-none-manylinux1_x86_64.whl", {"manylinux1_x86_64"}), - ("foo-1.0-py3-none-any.any.whl", {"any"}), - ("foo-1.0-py3-none-linux_x86_64.any.whl", {"linux_x86_64", "any"}), - ("foo-1.0-py2.py3-none-linux_x86_64.any.whl", {"linux_x86_64", "any"}), + ("foo-1.0-py3-none-manylinux1_x86_64.whl", ["manylinux1_x86_64"]), + ("foo-1.0-py3-none-any.any.whl", ["any"]), + ("foo-1.0-py3-none-linux_x86_64.any.whl", ["any", "linux_x86_64"]), + ("foo-1.0-py3-none-any.linux_x86_64.whl", ["any", "linux_x86_64"]), + ("foo-1.0-py2.py3-none-linux_x86_64.any.whl", ["any", "linux_x86_64"]), ], ) -def test_get_wheel_platforms(filename: str, expected: set[str]) -> None: +def test_get_wheel_platforms(filename: str, expected: list[str]) -> None: assert get_wheel_platforms(filename) == expected From e088b033c208b17eea30fbf007dc8392a0ae98e9 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Fri, 13 Mar 2026 21:55:48 +0000 Subject: [PATCH 12/21] Add --ldpaths to all subcommands, and make it required on Android --- src/auditwheel/lddtree.py | 44 +++++++++++++++++++++------------- src/auditwheel/main_lddtree.py | 7 ++++-- src/auditwheel/main_options.py | 37 ++++++++++++++++++++++++++++ src/auditwheel/main_repair.py | 26 ++++---------------- src/auditwheel/main_show.py | 20 +++++----------- src/auditwheel/wheel_abi.py | 21 ++++------------ tests/unit/test_lddtree.py | 31 +++++++++++++++++++++++- tests/unit/test_policy.py | 2 +- 8 files changed, 116 insertions(+), 72 deletions(-) create mode 100644 src/auditwheel/main_options.py diff --git a/src/auditwheel/lddtree.py b/src/auditwheel/lddtree.py index 36f9dcc0..6e43ec1a 100644 --- a/src/auditwheel/lddtree.py +++ b/src/auditwheel/lddtree.py @@ -363,15 +363,32 @@ def load_ld_paths( if ldpath_stripped == "": continue ldpaths["conf"].append(root + ldpath_stripped) - else: + elif libc == Libc.GLIBC: # Load up /etc/ld.so.conf. ldpaths["conf"] = parse_ld_so_conf(root + prefix + "/etc/ld.so.conf", root=root) # the trusted directories are not necessarily in ld.so.conf ldpaths["conf"].extend(["/lib", "/lib64/", "/usr/lib", "/usr/lib64"]) + else: + msg = f"can't load linker paths for libc {libc}: use the --ldpaths option" + raise ValueError(msg) + log.debug("linker ldpaths: %s", ldpaths) return ldpaths +def ld_paths_from_arg(args_ldpaths: str | None) -> dict[str, list[str]] | None: + """Load linker paths from the --ldpaths option and the LD_LIBRARY_PATH env var.""" + if args_ldpaths is None: + # The option was not provided, so fall back on load_ld_paths. + return None + + return { + "conf": parse_ld_paths(args_ldpaths), + "env": parse_ld_paths(os.environ.get("LD_LIBRARY_PATH", "")), + "interp": [], + } + + def find_lib( platform: Platform, lib: str, @@ -530,22 +547,19 @@ def ldd( if _first: # get the libc based on dependencies - def set_libc(new_libc: Libc) -> None: - nonlocal libc - if libc is None: - libc = new_libc - if libc != new_libc: - msg = f"found a dependency on {new_libc} but the libc is already set to {libc}" - raise InvalidLibcError(msg) - for soname in needed: if soname.startswith(("libc.musl-", "ld-musl-")): - set_libc(Libc.MUSL) + if libc is None: + libc = Libc.MUSL + if libc != Libc.MUSL: + msg = f"found a dependency on MUSL but the libc is already set to {libc}" + raise InvalidLibcError(msg) elif soname == "libc.so.6" or soname.startswith(("ld-linux-", "ld64.so.")): - set_libc(Libc.GLIBC) - elif soname == "libc.so": - set_libc(Libc.ANDROID) - + if libc is None: + libc = Libc.GLIBC + if libc != Libc.GLIBC: + msg = f"found a dependency on GLIBC but the libc is already set to {libc}" + raise InvalidLibcError(msg) if libc is None: # try the filename as a last resort if path.name.endswith(("-arm-linux-musleabihf.so", "-linux-musl.so")): @@ -556,8 +570,6 @@ def set_libc(new_libc: Libc) -> None: valid_python = tuple(f"3{minor}" for minor in range(11, 100)) if soabi[0] == "cpython" and soabi[1].startswith(valid_python): libc = Libc.GLIBC - elif path.name.endswith("-linux-android.so"): - libc = Libc.ANDROID if ldpaths is None: ldpaths = load_ld_paths(libc).copy() diff --git a/src/auditwheel/main_lddtree.py b/src/auditwheel/main_lddtree.py index 714d8b79..f6a3dda5 100644 --- a/src/auditwheel/main_lddtree.py +++ b/src/auditwheel/main_lddtree.py @@ -4,6 +4,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +from auditwheel import main_options + if TYPE_CHECKING: import argparse @@ -14,12 +16,13 @@ def configure_subparser(sub_parsers: Any) -> None: # noqa: ANN401 help_ = "Analyze a single ELF file (similar to ``ldd``)." p = sub_parsers.add_parser("lddtree", help=help_, description=help_) p.add_argument("file", type=Path, help="Path to .so file") + main_options.ldpaths(p) p.set_defaults(func=execute) def execute(args: argparse.Namespace, p: argparse.ArgumentParser) -> int: # noqa: ARG001 from auditwheel import json - from auditwheel.lddtree import ldd + from auditwheel.lddtree import ld_paths_from_arg, ldd - logger.info(json.dumps(ldd(args.file))) + logger.info(json.dumps(ldd(args.file, ldpaths=ld_paths_from_arg(args.LDPATHS)))) return 0 diff --git a/src/auditwheel/main_options.py b/src/auditwheel/main_options.py new file mode 100644 index 00000000..3991e470 --- /dev/null +++ b/src/auditwheel/main_options.py @@ -0,0 +1,37 @@ +"""Options shared between multiple subcommands.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from argparse import ArgumentParser + + +def disable_isa_check(parser: ArgumentParser) -> None: + parser.add_argument( + "--disable-isa-ext-check", + dest="DISABLE_ISA_EXT_CHECK", + action="store_true", + help="Do not check for extended ISA compatibility (e.g. x86_64_v2)", + default=False, + ) + + +def allow_pure_python_wheel(parser: ArgumentParser) -> None: + parser.add_argument( + "--allow-pure-python-wheel", + dest="ALLOW_PURE_PY_WHEEL", + action="store_true", + help="Allow processing of pure Python wheels (no platform-specific binaries) without error", + default=False, + ) + + +def ldpaths(parser: ArgumentParser) -> None: + parser.add_argument( + "--ldpaths", + dest="LDPATHS", + help="Colon-delimited list of directories to search for external libraries. " + "This replaces the default list; to add to the default, use LD_LIBRARY_PATH.", + ) diff --git a/src/auditwheel/main_repair.py b/src/auditwheel/main_repair.py index 7a1afb42..b80d263c 100644 --- a/src/auditwheel/main_repair.py +++ b/src/auditwheel/main_repair.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any +from auditwheel import main_options from auditwheel.architecture import Architecture from auditwheel.error import NonPlatformWheelError, WheelToolsError from auditwheel.lddtree import LIBPYTHON_RE @@ -112,13 +113,6 @@ def configure_parser(sub_parsers: Any) -> None: # noqa: ANN401 action="append", default=[], ) - parser.add_argument( - "--ldpaths", - dest="LDPATHS", - help="Colon-delimited list of directories to search for external libraries. " - "This replaces the default list; to add to the default, use LD_LIBRARY_PATH.", - default="", - ) parser.add_argument( "--only-plat", dest="ONLY_PLAT", @@ -126,20 +120,10 @@ def configure_parser(sub_parsers: Any) -> None: # noqa: ANN401 help="Do not check for higher policy compatibility", default=False, ) - parser.add_argument( - "--disable-isa-ext-check", - dest="DISABLE_ISA_EXT_CHECK", - action="store_true", - help="Do not check for extended ISA compatibility (e.g. x86_64_v2)", - default=False, - ) - parser.add_argument( - "--allow-pure-python-wheel", - dest="ALLOW_PURE_PY_WHEEL", - action="store_true", - help="Allow processing of pure Python wheels (no platform-specific binaries) without error", - default=False, - ) + main_options.disable_isa_check(parser) + main_options.allow_pure_python_wheel(parser) + main_options.ldpaths(parser) + parser.set_defaults(func=execute) diff --git a/src/auditwheel/main_show.py b/src/auditwheel/main_show.py index a184f044..35178d79 100644 --- a/src/auditwheel/main_show.py +++ b/src/auditwheel/main_show.py @@ -4,6 +4,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +from auditwheel import main_options + if TYPE_CHECKING: import argparse @@ -14,20 +16,9 @@ def configure_parser(sub_parsers: Any) -> None: # noqa: ANN401 help_ = "Audit a wheel for external shared library dependencies." p = sub_parsers.add_parser("show", help=help_, description=help_) p.add_argument("WHEEL_FILE", type=Path, help="Path to wheel file.") - p.add_argument( - "--disable-isa-ext-check", - dest="DISABLE_ISA_EXT_CHECK", - action="store_true", - help="Do not check for extended ISA compatibility (e.g. x86_64_v2)", - default=False, - ) - p.add_argument( - "--allow-pure-python-wheel", - dest="ALLOW_PURE_PY_WHEEL", - action="store_true", - help="Allow processing of pure Python wheels (no platform-specific binaries) without error", - default=False, - ) + main_options.disable_isa_check(p) + main_options.allow_pure_python_wheel(p) + main_options.ldpaths(p) p.set_defaults(func=execute) @@ -73,6 +64,7 @@ def execute(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: frozenset(), disable_isa_ext_check=args.DISABLE_ISA_EXT_CHECK, allow_graft=False, + args_ldpaths=args.LDPATHS, ) except NonPlatformWheelError as e: logger.info("%s", e.message) diff --git a/src/auditwheel/wheel_abi.py b/src/auditwheel/wheel_abi.py index 64b1a793..86d1fb73 100644 --- a/src/auditwheel/wheel_abi.py +++ b/src/auditwheel/wheel_abi.py @@ -3,7 +3,6 @@ import functools import itertools import logging -import os from collections import defaultdict from copy import deepcopy from dataclasses import dataclass @@ -20,7 +19,7 @@ ) from auditwheel.error import InvalidLibcError, NonPlatformWheelError from auditwheel.genericpkgctx import InGenericPkgCtx -from auditwheel.lddtree import DynamicExecutable, ldd, parse_ld_paths +from auditwheel.lddtree import DynamicExecutable, ld_paths_from_arg, ldd from auditwheel.libc import Libc from auditwheel.policy import ExternalReference, Policy, WheelPolicies @@ -64,7 +63,7 @@ def get_wheel_elfdata( architecture: Architecture | None, wheel_fn: Path, exclude: frozenset[str], - args_ldpaths: str, + args_ldpaths: str | None, ) -> WheelElfData: full_elftree: dict[Path, DynamicExecutable] = {} nonpy_elftree: dict[Path, DynamicExecutable] = {} @@ -93,19 +92,7 @@ def get_wheel_elfdata( # to fail and there's no need to do further checks if not shared_libraries_in_purelib: log.debug("processing: %s", fn) - elftree = ldd( - fn, - exclude=exclude, - ldpaths=( - None - if args_ldpaths == "" - else { - "conf": parse_ld_paths(args_ldpaths), - "env": parse_ld_paths(os.environ.get("LD_LIBRARY_PATH", "")), - "interp": [], - } - ), - ) + elftree = ldd(fn, exclude=exclude, ldpaths=ld_paths_from_arg(args_ldpaths)) try: elf_arch = elftree.platform.baseline_architecture @@ -389,7 +376,7 @@ def analyze_wheel_abi( disable_isa_ext_check: bool, allow_graft: bool, requested_policy_base_name: str | None = None, - args_ldpaths: str = "", + args_ldpaths: str | None = None, ) -> WheelAbIInfo: data = get_wheel_elfdata(libc, architecture, wheel_fn, exclude, args_ldpaths) policies = data.policies diff --git a/tests/unit/test_lddtree.py b/tests/unit/test_lddtree.py index 19942c3b..5ca22858 100644 --- a/tests/unit/test_lddtree.py +++ b/tests/unit/test_lddtree.py @@ -4,7 +4,7 @@ import pytest from auditwheel.architecture import Architecture -from auditwheel.lddtree import LIBPYTHON_RE, ldd, parse_ld_paths +from auditwheel.lddtree import LIBPYTHON_RE, ld_paths_from_arg, ldd, load_ld_paths, parse_ld_paths from auditwheel.libc import Libc from auditwheel.tools import zip2dir @@ -108,3 +108,32 @@ def test_parse_ld_paths_origin(origin): # Relative paths are made absolute. assert parse_ld_paths(origin, path=os.path.relpath(__file__)) == [here] + + +@pytest.mark.parametrize("libc", [Libc.ANDROID, None]) +def test_load_ld_paths_invalid(libc): + with pytest.raises( + ValueError, + match=f"can't load linker paths for libc {libc}: use the --ldpaths option", + ): + load_ld_paths(libc) + + +@pytest.mark.parametrize( + ("arg", "env", "expected"), + [ + (None, "", None), + (None, str(HERE.parent), None), + ("", "", {"conf": [], "env": [], "interp": []}), + (str(HERE), "", {"conf": [str(HERE)], "env": [], "interp": []}), + ("", str(HERE), {"conf": [], "env": [str(HERE)], "interp": []}), + ( + str(HERE), + str(HERE.parent), + {"conf": [str(HERE)], "env": [str(HERE.parent)], "interp": []}, + ), + ], +) +def test_ld_paths_from_arg(arg, env, expected, monkeypatch): + monkeypatch.setitem(os.environ, "LD_LIBRARY_PATH", env) + assert ld_paths_from_arg(arg) == expected diff --git a/tests/unit/test_policy.py b/tests/unit/test_policy.py index 99a22655..ca2dcd1d 100644 --- a/tests/unit/test_policy.py +++ b/tests/unit/test_policy.py @@ -290,7 +290,7 @@ def test_android(wheel_level, policy_level, arch): assert ("libaaudio.so" in whitelist) == (policy_level >= 26) assert ("libneuralnetworks.so" in whitelist) == (policy_level >= 27) - platform = f"android_{wheel_level}_{arch.value}" + platform = f"android_{wheel_level}_{arch}" policies = WheelPolicies( libc=Libc.ANDROID, arch=arch.baseline, From 79627e46f9254380c8040538b1a94c0703708f66 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Fri, 13 Mar 2026 23:57:16 +0000 Subject: [PATCH 13/21] Move API level 24 check to patcher --- src/auditwheel/main_repair.py | 16 ++----------- src/auditwheel/patcher.py | 23 +++++++++++------- tests/unit/test_elfpatcher.py | 44 ++++++++++++++++++++--------------- tests/unit/test_repair.py | 9 ++++--- 4 files changed, 46 insertions(+), 46 deletions(-) diff --git a/src/auditwheel/main_repair.py b/src/auditwheel/main_repair.py index b80d263c..08d8617a 100644 --- a/src/auditwheel/main_repair.py +++ b/src/auditwheel/main_repair.py @@ -10,12 +10,11 @@ from auditwheel import main_options from auditwheel.architecture import Architecture from auditwheel.error import NonPlatformWheelError, WheelToolsError -from auditwheel.lddtree import LIBPYTHON_RE from auditwheel.libc import Libc from auditwheel.patcher import Patchelf from auditwheel.policy import WheelPolicies from auditwheel.tools import EnvironmentDefault -from auditwheel.wheeltools import android_api_level, get_wheel_architecture, get_wheel_libc +from auditwheel.wheeltools import get_wheel_architecture, get_wheel_libc logger = logging.getLogger(__name__) @@ -220,17 +219,6 @@ def execute(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: plat = f"{plat_base}_{policies.architecture.value}" requested_policy = policies.get_policy_by_name(plat) - # On Android, grafted libraries are only supported on API level 24 or higher - # (https://android.googlesource.com/platform/bionic/+/refs/heads/main/android-changes-for-ndk-developers.md). - if libc == Libc.ANDROID and android_api_level(plat) < 24: - for soname in wheel_abi.external_refs[plat].libs: - if not LIBPYTHON_RE.match(soname): - msg = ( - "grafting external libraries requires RUNPATH, which requires " - "API level 24 or higher." - ) - parser.error(msg) - if requested_policy > wheel_abi.sym_policy: msg = ( f'cannot repair "{wheel_file}" to "{plat}" ABI because of the ' @@ -278,7 +266,7 @@ def execute(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: *abis, ] - patcher = Patchelf(wheel_abi.policies.libc) + patcher = Patchelf(requested_policy.name) out_wheel = repair_wheel( wheel_abi, wheel_file, diff --git a/src/auditwheel/patcher.py b/src/auditwheel/patcher.py index d4fd6166..602a173b 100644 --- a/src/auditwheel/patcher.py +++ b/src/auditwheel/patcher.py @@ -6,11 +6,11 @@ from subprocess import CalledProcessError, check_call, check_output from typing import TYPE_CHECKING +from auditwheel.wheeltools import android_api_level + if TYPE_CHECKING: from pathlib import Path -from .libc import Libc - class ElfPatcher: def replace_needed(self, file_name: Path, *old_new_pairs: tuple[str, str]) -> None: @@ -51,8 +51,8 @@ def _verify_patchelf() -> None: class Patchelf(ElfPatcher): - def __init__(self, libc: Libc) -> None: - self.libc = libc + def __init__(self, platform: str = "") -> None: + self.platform = platform _verify_patchelf() def replace_needed(self, file_name: Path, *old_new_pairs: tuple[str, str]) -> None: @@ -77,11 +77,18 @@ def set_soname(self, file_name: Path, new_so_name: str) -> None: check_call(["patchelf", "--set-soname", new_so_name, file_name]) def set_rpath(self, file_name: Path, rpath: str) -> None: - check_call(["patchelf", "--remove-rpath", file_name]) + set_args: list[str | Path] = ["patchelf", "--force-rpath", "--set-rpath", rpath, file_name] + if self.platform.startswith("android"): + # Android supports only RUNPATH, not RPATH. + set_args.remove("--force-rpath") - # Android supports only RUNPATH, not RPATH. - extra_args = [] if self.libc == Libc.ANDROID else ["--force-rpath"] - check_call(["patchelf", *extra_args, "--set-rpath", rpath, file_name]) + # https://android.googlesource.com/platform/bionic/+/refs/heads/main/android-changes-for-ndk-developers.md + if android_api_level(self.platform) < 24: + msg = "RUNPATH requires API level 24 or higher" + raise ValueError(msg) + + check_call(["patchelf", "--remove-rpath", file_name]) + check_call(set_args) def get_rpath(self, file_name: Path) -> str: return check_output(["patchelf", "--print-rpath", file_name]).decode("utf-8").strip() diff --git a/tests/unit/test_elfpatcher.py b/tests/unit/test_elfpatcher.py index 7d8f34f7..2e5718a2 100644 --- a/tests/unit/test_elfpatcher.py +++ b/tests/unit/test_elfpatcher.py @@ -6,7 +6,6 @@ import pytest -from auditwheel.libc import Libc from auditwheel.patcher import Patchelf @@ -14,7 +13,7 @@ def test_patchelf_unavailable(which): which.return_value = False with pytest.raises(ValueError, match="Cannot find required utility"): - Patchelf(Libc.GLIBC) + Patchelf() @patch("auditwheel.patcher.which") @@ -23,7 +22,7 @@ def test_patchelf_check_output_fail(check_output, which): which.return_value = True check_output.side_effect = CalledProcessError(1, "patchelf --version") with pytest.raises(ValueError, match="Could not call"): - Patchelf(Libc.GLIBC) + Patchelf() @patch("auditwheel.patcher.which") @@ -32,7 +31,7 @@ def test_patchelf_check_output_fail(check_output, which): def test_patchelf_version_check(check_output, which, version): which.return_value = True check_output.return_value.decode.return_value = f"patchelf {version}" - Patchelf(Libc.GLIBC) + Patchelf() @patch("auditwheel.patcher.which") @@ -42,18 +41,17 @@ def test_patchelf_version_check_fail(check_output, which, version): which.return_value = True check_output.return_value.decode.return_value = f"patchelf {version}" with pytest.raises(ValueError, match=f"patchelf {version} found"): - Patchelf(Libc.GLIBC) + Patchelf() -@pytest.mark.parametrize("libc", [Libc.GLIBC, Libc.MUSL, Libc.ANDROID]) @patch("auditwheel.patcher._verify_patchelf") @patch("auditwheel.patcher.check_output") @patch("auditwheel.patcher.check_call") class TestPatchElf: """ "Validate that patchelf is invoked with the correct arguments.""" - def test_replace_needed_one(self, check_call, _0, _1, libc): # noqa: PT019 - patcher = Patchelf(libc) + def test_replace_needed_one(self, check_call, _0, _1): # noqa: PT019 + patcher = Patchelf() filename = Path("test.so") soname_old = "TEST_OLD" soname_new = "TEST_NEW" @@ -62,8 +60,8 @@ def test_replace_needed_one(self, check_call, _0, _1, libc): # noqa: PT019 ["patchelf", "--replace-needed", soname_old, soname_new, filename], ) - def test_replace_needed_multple(self, check_call, _0, _1, libc): # noqa: PT019 - patcher = Patchelf(libc) + def test_replace_needed_multple(self, check_call, _0, _1): # noqa: PT019 + patcher = Patchelf() filename = Path("test.so") replacements = [ ("TEST_OLD1", "TEST_NEW1"), @@ -81,8 +79,8 @@ def test_replace_needed_multple(self, check_call, _0, _1, libc): # noqa: PT019 ], ) - def test_set_soname(self, check_call, _0, _1, libc): # noqa: PT019 - patcher = Patchelf(libc) + def test_set_soname(self, check_call, _0, _1): # noqa: PT019 + patcher = Patchelf() filename = Path("test.so") soname_new = "TEST_NEW" patcher.set_soname(filename, soname_new) @@ -90,23 +88,31 @@ def test_set_soname(self, check_call, _0, _1, libc): # noqa: PT019 ["patchelf", "--set-soname", soname_new, filename], ) - def test_set_rpath(self, check_call, _0, _1, libc): # noqa: PT019 - patcher = Patchelf(libc) + @pytest.mark.parametrize("platform", ["android_24_x86_64", "manylinux_2_26_x86_64"]) + def test_set_rpath(self, check_call, _0, _1, platform): # noqa: PT019 + patcher = Patchelf(platform) filename = Path("test.so") patcher.set_rpath(filename, "$ORIGIN/.lib") check_call_expected_args = [ call(["patchelf", "--remove-rpath", filename]), call( ["patchelf"] - + ([] if libc == Libc.ANDROID else ["--force-rpath"]) + + ([] if platform.startswith("android") else ["--force-rpath"]) + ["--set-rpath", "$ORIGIN/.lib", filename], ), ] assert check_call.call_args_list == check_call_expected_args - def test_get_rpath(self, _0, check_output, _1, libc): # noqa: PT019 - patcher = Patchelf(libc) + def test_set_rpath_android_old(self, check_call, _0, _1): # noqa: PT019 + patcher = Patchelf("android_23_x86_64") + filename = Path("test.so") + with pytest.raises(ValueError, match="RUNPATH requires API level 24 or higher"): + patcher.set_rpath(filename, "$ORIGIN/.lib") + check_call.assert_not_called() + + def test_get_rpath(self, _0, check_output, _1): # noqa: PT019 + patcher = Patchelf() filename = Path("test.so") check_output.return_value = b"existing_rpath" result = patcher.get_rpath(filename) @@ -115,8 +121,8 @@ def test_get_rpath(self, _0, check_output, _1, libc): # noqa: PT019 assert result == check_output.return_value.decode() assert check_output.call_args_list == check_output_expected_args - def test_remove_needed(self, check_call, _0, _1, libc): # noqa: PT019 - patcher = Patchelf(libc) + def test_remove_needed(self, check_call, _0, _1): # noqa: PT019 + patcher = Patchelf() filename = Path("test.so") soname_1 = "TEST_REM_1" soname_2 = "TEST_REM_2" diff --git a/tests/unit/test_repair.py b/tests/unit/test_repair.py index 4f365b7a..2a0df9d4 100644 --- a/tests/unit/test_repair.py +++ b/tests/unit/test_repair.py @@ -3,7 +3,6 @@ from pathlib import Path from unittest.mock import call, patch -from auditwheel.libc import Libc from auditwheel.patcher import Patchelf from auditwheel.repair import append_rpath_within_wheel @@ -13,7 +12,7 @@ @patch("auditwheel.patcher.check_call") class TestRepair: def test_append_rpath(self, check_call, check_output, _): # noqa: PT019 - patcher = Patchelf(Libc.GLIBC) + patcher = Patchelf() # When a library has an existing RPATH entry within wheel_dir existing_rpath = b"$ORIGIN/.existinglibdir" check_output.return_value = existing_rpath @@ -42,7 +41,7 @@ def test_append_rpath(self, check_call, check_output, _): # noqa: PT019 assert check_call.call_args_list == check_call_expected_args def test_append_rpath_reject_outside_wheel(self, check_call, check_output, _): # noqa: PT019 - patcher = Patchelf(Libc.GLIBC) + patcher = Patchelf() # When a library has an existing RPATH entry outside wheel_dir existing_rpath = b"/outside/wheel/dir" check_output.return_value = existing_rpath @@ -71,7 +70,7 @@ def test_append_rpath_reject_outside_wheel(self, check_call, check_output, _): assert check_call.call_args_list == check_call_expected_args def test_append_rpath_ignore_duplicates(self, check_call, check_output, _): # noqa: PT019 - patcher = Patchelf(Libc.GLIBC) + patcher = Patchelf() # When a library has an existing RPATH entry and we try and append it again existing_rpath = b"$ORIGIN" check_output.return_value = existing_rpath @@ -94,7 +93,7 @@ def test_append_rpath_ignore_duplicates(self, check_call, check_output, _): # n assert check_call.call_args_list == check_call_expected_args def test_append_rpath_ignore_relative(self, check_call, check_output, _): # noqa: PT019 - patcher = Patchelf(Libc.GLIBC) + patcher = Patchelf() # When a library has an existing RPATH entry but it cannot be resolved # to an absolute path, it is eliminated existing_rpath = b"not/absolute" From 5fb678f434f08249d8e9a13d1a8798bf4fb5afcd Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sun, 15 Mar 2026 13:21:39 +0000 Subject: [PATCH 14/21] Rename main_options.py to options.py --- src/auditwheel/main_lddtree.py | 4 ++-- src/auditwheel/main_repair.py | 8 ++++---- src/auditwheel/main_show.py | 8 ++++---- src/auditwheel/{main_options.py => options.py} | 0 4 files changed, 10 insertions(+), 10 deletions(-) rename src/auditwheel/{main_options.py => options.py} (100%) diff --git a/src/auditwheel/main_lddtree.py b/src/auditwheel/main_lddtree.py index f6a3dda5..00e28645 100644 --- a/src/auditwheel/main_lddtree.py +++ b/src/auditwheel/main_lddtree.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from auditwheel import main_options +from auditwheel import options if TYPE_CHECKING: import argparse @@ -16,7 +16,7 @@ def configure_subparser(sub_parsers: Any) -> None: # noqa: ANN401 help_ = "Analyze a single ELF file (similar to ``ldd``)." p = sub_parsers.add_parser("lddtree", help=help_, description=help_) p.add_argument("file", type=Path, help="Path to .so file") - main_options.ldpaths(p) + options.ldpaths(p) p.set_defaults(func=execute) diff --git a/src/auditwheel/main_repair.py b/src/auditwheel/main_repair.py index 08d8617a..447fb8aa 100644 --- a/src/auditwheel/main_repair.py +++ b/src/auditwheel/main_repair.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Any -from auditwheel import main_options +from auditwheel import options from auditwheel.architecture import Architecture from auditwheel.error import NonPlatformWheelError, WheelToolsError from auditwheel.libc import Libc @@ -119,9 +119,9 @@ def configure_parser(sub_parsers: Any) -> None: # noqa: ANN401 help="Do not check for higher policy compatibility", default=False, ) - main_options.disable_isa_check(parser) - main_options.allow_pure_python_wheel(parser) - main_options.ldpaths(parser) + options.disable_isa_check(parser) + options.allow_pure_python_wheel(parser) + options.ldpaths(parser) parser.set_defaults(func=execute) diff --git a/src/auditwheel/main_show.py b/src/auditwheel/main_show.py index 35178d79..838ec772 100644 --- a/src/auditwheel/main_show.py +++ b/src/auditwheel/main_show.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from auditwheel import main_options +from auditwheel import options if TYPE_CHECKING: import argparse @@ -16,9 +16,9 @@ def configure_parser(sub_parsers: Any) -> None: # noqa: ANN401 help_ = "Audit a wheel for external shared library dependencies." p = sub_parsers.add_parser("show", help=help_, description=help_) p.add_argument("WHEEL_FILE", type=Path, help="Path to wheel file.") - main_options.disable_isa_check(p) - main_options.allow_pure_python_wheel(p) - main_options.ldpaths(p) + options.disable_isa_check(p) + options.allow_pure_python_wheel(p) + options.ldpaths(p) p.set_defaults(func=execute) diff --git a/src/auditwheel/main_options.py b/src/auditwheel/options.py similarity index 100% rename from src/auditwheel/main_options.py rename to src/auditwheel/options.py From 364ab9142ed8a5f1b9a8bf25c066aa2d2902a83a Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sun, 15 Mar 2026 13:23:20 +0000 Subject: [PATCH 15/21] Improve error messages --- src/auditwheel/lddtree.py | 6 +----- src/auditwheel/patcher.py | 2 +- tests/unit/test_lddtree.py | 11 +---------- 3 files changed, 3 insertions(+), 16 deletions(-) diff --git a/src/auditwheel/lddtree.py b/src/auditwheel/lddtree.py index 6e43ec1a..6ef26c89 100644 --- a/src/auditwheel/lddtree.py +++ b/src/auditwheel/lddtree.py @@ -363,15 +363,11 @@ def load_ld_paths( if ldpath_stripped == "": continue ldpaths["conf"].append(root + ldpath_stripped) - elif libc == Libc.GLIBC: + else: # Load up /etc/ld.so.conf. ldpaths["conf"] = parse_ld_so_conf(root + prefix + "/etc/ld.so.conf", root=root) # the trusted directories are not necessarily in ld.so.conf ldpaths["conf"].extend(["/lib", "/lib64/", "/usr/lib", "/usr/lib64"]) - else: - msg = f"can't load linker paths for libc {libc}: use the --ldpaths option" - raise ValueError(msg) - log.debug("linker ldpaths: %s", ldpaths) return ldpaths diff --git a/src/auditwheel/patcher.py b/src/auditwheel/patcher.py index 602a173b..3f841cf7 100644 --- a/src/auditwheel/patcher.py +++ b/src/auditwheel/patcher.py @@ -84,7 +84,7 @@ def set_rpath(self, file_name: Path, rpath: str) -> None: # https://android.googlesource.com/platform/bionic/+/refs/heads/main/android-changes-for-ndk-developers.md if android_api_level(self.platform) < 24: - msg = "RUNPATH requires API level 24 or higher" + msg = "Grafting libraries with RUNPATH requires API level 24 or higher" raise ValueError(msg) check_call(["patchelf", "--remove-rpath", file_name]) diff --git a/tests/unit/test_lddtree.py b/tests/unit/test_lddtree.py index 5ca22858..df4e779e 100644 --- a/tests/unit/test_lddtree.py +++ b/tests/unit/test_lddtree.py @@ -4,7 +4,7 @@ import pytest from auditwheel.architecture import Architecture -from auditwheel.lddtree import LIBPYTHON_RE, ld_paths_from_arg, ldd, load_ld_paths, parse_ld_paths +from auditwheel.lddtree import LIBPYTHON_RE, ld_paths_from_arg, ldd, parse_ld_paths from auditwheel.libc import Libc from auditwheel.tools import zip2dir @@ -110,15 +110,6 @@ def test_parse_ld_paths_origin(origin): assert parse_ld_paths(origin, path=os.path.relpath(__file__)) == [here] -@pytest.mark.parametrize("libc", [Libc.ANDROID, None]) -def test_load_ld_paths_invalid(libc): - with pytest.raises( - ValueError, - match=f"can't load linker paths for libc {libc}: use the --ldpaths option", - ): - load_ld_paths(libc) - - @pytest.mark.parametrize( ("arg", "env", "expected"), [ From 4568843ba9a0b244e62b5dba98659da388d21a3c Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sun, 15 Mar 2026 16:05:40 +0000 Subject: [PATCH 16/21] Respond to Copilot and Codecov reports; update documentation --- README.rst | 17 +++++++---------- src/auditwheel/policy/__init__.py | 5 ++++- tests/unit/test_elfutils.py | 26 ++++++++++++++++++++++++++ tests/unit/test_policy.py | 5 +++++ 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/README.rst b/README.rst index e5c3f6bc..d56d609d 100644 --- a/README.rst +++ b/README.rst @@ -8,23 +8,20 @@ auditwheel .. image:: https://pepy.tech/badge/auditwheel/month :target: https://pepy.tech/project/auditwheel/month -Auditing and relabeling of `PEP 600 manylinux_x_y -`_, `PEP 513 manylinux1 -`_, `PEP 571 manylinux2010 -`_ and `PEP 599 manylinux2014 -`_ Linux wheels. +Auditing and relabeling of Linux and Android wheels. Overview -------- ``auditwheel`` is a command line tool to facilitate the creation of Python -`wheel packages `_ for Linux (containing pre-compiled -binary extensions) that are compatible with a wide variety of Linux distributions, +`wheel packages `_ (containing pre-compiled +binary extensions) that are compatible with a wide variety of distributions, consistent with the `PEP 600 manylinux_x_y `_, `PEP 513 manylinux1 `_, `PEP 571 manylinux2010 -`_ and `PEP 599 manylinux2014 -`_ platform tags. +`_, `PEP 599 manylinux2014 +`_ and `PEP 738 android +`_ platform tags. ``auditwheel show``: shows external shared libraries that the wheel depends on (beyond the libraries included in the ``manylinux`` policies), and @@ -39,7 +36,7 @@ advised that bundling, like static linking, may implicate copyright concerns. Requirements ------------ -- OS: Linux +- OS: Linux (for Android wheels, macOS may also be used) - Python: 3.10+ - `patchelf `_: 0.14+ diff --git a/src/auditwheel/policy/__init__.py b/src/auditwheel/policy/__init__.py index 2bfad861..8f6940cd 100644 --- a/src/auditwheel/policy/__init__.py +++ b/src/auditwheel/policy/__init__.py @@ -124,7 +124,10 @@ def __init__( elif self._libc_variant == Libc.ANDROID: # Android wheels always have a platform tag with an API level (OS version). We won't # change that tag, we'll only use it to determine which libraries need to be grafted. - assert wheel_fn is not None # noqa: S101 + if wheel_fn is None: + msg = "wheel_fn is required when selecting Android policies" + raise ValueError(msg) + platforms = get_wheel_platforms(wheel_fn.name) if len(platforms) != 1: msg = f"Android wheels must have exactly one platform tag, got {platforms}" diff --git a/tests/unit/test_elfutils.py b/tests/unit/test_elfutils.py index 2456956b..312f8dc7 100644 --- a/tests/unit/test_elfutils.py +++ b/tests/unit/test_elfutils.py @@ -233,6 +233,32 @@ def test_elf_references_pyfpe_jbuf_no_section(self): @patch("auditwheel.elfutils.ELFFile") class TestElfReadRpaths: + def test_read_rpaths(self, elffile_mock, tmp_path): + fake = tmp_path / "fake.so" + parent = tmp_path.parent + subdir = tmp_path / "subdir" + subdir.mkdir() + + # GIVEN + fake.touch() + section_mock = Mock() + tag1 = Mock(rpath=f"{parent}:$ORIGIN") + tag1.entry.d_tag = "DT_RPATH" + tag2 = Mock(runpath="$ORIGIN/subdir") + tag2.entry.d_tag = "DT_RUNPATH" + tag3 = Mock(needed="libfoo.so") + tag3.entry.d_tag = "DT_NEEDED" + + section_mock.iter_tags.return_value = [tag1, tag2, tag3] + elffile_mock.return_value.get_section_by_name.return_value = section_mock + + # THEN + result = elf_read_rpaths(fake) + assert result == { + "rpaths": [str(parent), str(tmp_path)], + "runpaths": [str(subdir)], + } + def test_missing_dynamic_section(self, elffile_mock, tmp_path): fake = tmp_path / "fake.so" diff --git a/tests/unit/test_policy.py b/tests/unit/test_policy.py index ca2dcd1d..8c455d18 100644 --- a/tests/unit/test_policy.py +++ b/tests/unit/test_policy.py @@ -303,6 +303,11 @@ def test_android(wheel_level, policy_level, arch): assert policies.lowest.whitelist == whitelist +def test_android_no_filename(): + with pytest.raises(ValueError, match="wheel_fn is required when selecting Android policies"): + WheelPolicies(libc=Libc.ANDROID, arch=Architecture.x86_64) + + @pytest.mark.parametrize( ("filename", "message"), [ From 5cf82d62eaeac5a9e33182c38b204e569de4b57d Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sun, 15 Mar 2026 21:35:29 +0000 Subject: [PATCH 17/21] Add integration test --- src/auditwheel/elfutils.py | 11 +++ .../android/ldpaths/arg/libc++_shared.so | Bin 0 -> 4976 bytes .../android/ldpaths/env/libc++_shared.so | Bin 0 -> 4976 bytes ...0.1.0-cp313-cp313-android_24_arm64_v8a.whl | Bin 0 -> 3285 bytes tests/integration/test_android.py | 69 ++++++++++++++++++ tests/unit/test_elfutils.py | 38 +++++++++- 6 files changed, 116 insertions(+), 2 deletions(-) create mode 100755 tests/integration/android/ldpaths/arg/libc++_shared.so create mode 100755 tests/integration/android/ldpaths/env/libc++_shared.so create mode 100644 tests/integration/android/spam-0.1.0-cp313-cp313-android_24_arm64_v8a.whl create mode 100644 tests/integration/test_android.py diff --git a/src/auditwheel/elfutils.py b/src/auditwheel/elfutils.py index f375a20c..928d338b 100644 --- a/src/auditwheel/elfutils.py +++ b/src/auditwheel/elfutils.py @@ -12,6 +12,17 @@ from pathlib import Path +def elf_read_soname(fn: Path) -> str | None: + with fn.open("rb") as f: + elf = ELFFile(f) + section = elf.get_section_by_name(".dynamic") + if section: + for t in section.iter_tags(): + if t.entry.d_tag == "DT_SONAME": + return str(t.soname) + return None + + def elf_read_dt_needed(fn: Path) -> list[str]: needed: list[str] = [] with fn.open("rb") as f: diff --git a/tests/integration/android/ldpaths/arg/libc++_shared.so b/tests/integration/android/ldpaths/arg/libc++_shared.so new file mode 100755 index 0000000000000000000000000000000000000000..e420e71214f829edb3ca9e07d9972af5e68dcfa2 GIT binary patch literal 4976 zcmbVQU2GIp6h6C)KTwdW2t<^w+GtgnZnrI6ASMM0LaYV}KA0HC*_~;3Wp-vWJB5N6 z5i#LGqb5F(Xv7CikZAP5L>@FYCMG^;bnCq3ag05zG5kJFfPRqZvGstVz zPhjCKRH|1Q3nXvBAO1vMQ94@=D5s zY$y*B@3-gY*p>IY-0`#<+O51vU7WNW(dj{|8$HZCouYbNo)Eq~C3(?#VMvE{iKJef>j&g`wiW6?d=zQRaEWJT?id`S4p8z9lRp_J!+-rOHDsx>Od>W*o4H02KjDhJ*|d+aNU)QSAMYUBu1q@iSS@h;+96L> zC-x?5F)NWbUTel)WIOR#80{k`8YjP2u2{{GMt5!9CW`BerUmhy(+NZG(Rc=aMBl5B z!ttF=-DNt=az8S6lrP$F4W-0%whhn0nPp~8Zu1DsYxtSwg?dbE*Aq2ZT+8wp%TAUO zcik-CWjV}pisf#W=UGa@8fOmeq0Hg6nKeth5=>(81vbJiaveX%xq)v~uwi} z{{8PGn2$3 z{AAyy{Hj5LJ=tkszF$m{eUx%R`G}!a2Bv~8GGY#V_?^=D!(L)soRw#l*rEMlJiUJC zgm$zV)YeZk63({uA-kdFq7#Zuppdw0I#JHdETvf?nU>Utiyd!Y92- zoOerwLccRGxWVlkaQa=ZTq+C>R9wdyEN&bdD~}b5?#6O)L-B4Jtp)qMXj6W4bVNt} z&oz*ET13Ckq@9*Ckfjd7xrKc&`!b7zcbod-{6jpNcwW}Z7vNt?8rQY<8~A;SqM0O%)afQiMd=wvo8Un>fQuw6eh<|mbXBFR_ z@_$kBms9v>jAyd5>w|Usj`3`^gC;dTf&Z!aamDdnF6WID|JN1A_j8BhXB5XizQO&> zx9@KNCpj?c$$WXTu~R0iS#8FVX&FhCC@yoZ6WGnrs&L{H2Da;0{IKa!_oUT# z31a)4A5ph!6{YHzYCx(%sWy--l&uS8n}uS$U~qW}(+c=)P0I^h3fz4*R#4jIiJI@) z!9>06MYLY#P>|mHq$=`kcI?^m;C6faLnF3raNBm9p9 zIG-SKPGNvnoGKunvD~%bFANMIF?rGT5@*d{P-3uJ| zFJxZFk^Df$PtSiNV}G{sasNZ+*)9eBWBvvuk{ysMA9myykjO9KFLP)w53nM00w4Jk zCFV@HY(9Pqz1-L-1)AwOpeVz1@IjZ-@AYMq#zGjdj7I*9+T7{lDT*(y`|1K|4> XVj371@VDHUkAHHh@pw2zNU8KMcv1d) literal 0 HcmV?d00001 diff --git a/tests/integration/android/ldpaths/env/libc++_shared.so b/tests/integration/android/ldpaths/env/libc++_shared.so new file mode 100755 index 0000000000000000000000000000000000000000..dc9aaf052d8be20270ae4e762c0766f4ebe2283e GIT binary patch literal 4976 zcmbVQQEU`d6urBP3KXO&0uiOF5~&K)?Y5;0#H2t$h}8hW4-=Ddc4yjMnVs3pPN5)1 zL`?Y5sEHpW8uddHBpUrNkq?cHiHRQ?`BC{oCD9na>W4;Rq~1I4p6xPimGGLG`|i2- zy!Yn4``)|TXLgM2%w{r#lB17lMk5+h!_-oE2v(v+{9ZxJ>3W72@H3?5wERmOgIuV7 z0t;`xQoYVtAgR;n!V?ql>aFUh@~lw_vYjVm&C5&VJy*)JI?f*#863PUt3oL$ucSQ4 zhVmfsefxfnUHQJN?N6JbJ<6NV$w|v$9Ui2*(O%~15Y^-Agz)7l$%oD%PZ^jX(Vu+h z34UbUTLU)=Yc8$gdQ=>ASzL4J?He2@3>N#Zxq*3zGR~Xlut`|Wh2Jp`-jm=R^rgld z=7T7k{7F9<{_A6@Axo8|60up@!fzDu6RybHv@V-Su%7T_2ibOI!m&qdf#cVXc&a+F zH&KgOiM+8|Gxj3eiATfe0C~Yd@@wUa)eLE5_qOe#xV~sw5brr1F!XMXXW+;5y9y~B z`)qQZX)w#}$k7!iNzP#2)oGj{G8+p_NZV({2WbT`x$F6 zww$FD@vHJ9*zE#v5!+FICKwYtiqv1_M|^dv6O+9*_ia}`_wBLgsk4jeqEF`R??nDH zJ-t%o6GR_P<>(A$yS{_%C)R~W)6<#;OBT@?T6F`UE*HK8Y5bB{rB{A5{vMY zb(iw11_jn+mx1|yF-6u<$|dC^hE^Jw3OdP%Iq>0kTH_CEiGFcbo>O9nwuk=o`k_6`w4Nv^P7|@9se%%zpVOx)u&VQqqP4O=XAfOB@X3$cW>Xu zO}zsHgX{D6H^##C`3>dJk6H0!QR=>6WvA)6`7p>w1I1#gchJ2zU-6w_EPv38nl;9I zOIE?^&v#ejxY69))8m*U-x>?UG2d&3<59)4Dq+1R4nx0Eb!tJ6?;oshXhh+YUM0@E zr9z?4=^xnW_Vzn{u2(J<2Kp1_~%NWQ~2jSepI+%5t!NI#z{c-*w9!)$iZRHE_FC~>5Tk8${0mX5C0^gQY zX5TaqCir5SY(Fd*8{tRp5!}nC5{yF2B?9BXN-Yzhn&34d)#wYMU6+fvs_T@5ONb!GNaqOSF6+f#u*6|H)XRdXB zi*e$0$NikB{CX#;%%1-<2|j!NKVUpVGdPy#(|P=N@i-~FjF1;5Zv6U&pV_?JS^Uez9roVsUMUG{)R7AGQXOasSF-?fAB zdfAI;gN&gdt@p~S$fMc0Z|lQ5>>ZB`+qS`N+iiw+mHYLP#kD1MUVS|FoA!9nO6U%F zhq)P(=qW9-4@FL+!5y~pKgkVMX~fmYb6jMCbLWQk@MDi`eR%hFgyl6$33ItaaSY&m zg2Xw64q9=lfNW#AYr)6)18L4B^P5Zh!nv_Z`I48*T?sz!Q%Ky?u&3pH%$K_tIPPD_ zy!Ipcf%Kmq|0c%%Y~$nphs?8G3fjl`4N4>{AXh%@$S)v~U%+3+&|V&5MaBd^@+Zh$ z>I^&j$NW92{B7!o{0Xw1FK2C0jAP{rUg^ literal 0 HcmV?d00001 diff --git a/tests/integration/android/spam-0.1.0-cp313-cp313-android_24_arm64_v8a.whl b/tests/integration/android/spam-0.1.0-cp313-cp313-android_24_arm64_v8a.whl new file mode 100644 index 0000000000000000000000000000000000000000..510b2f0b5ede8151d1af3905b3107b3ca6e434a6 GIT binary patch literal 3285 zcma);c{H2b8izvP>+Wy8Yp?HHdq4Z#-+KP|SwoKe!Vdrdjsd`E zgcD6%<+aN(03c8p0Fd4P8XV{rpy&}85#ob@E2yccDY&@>dH86mEBM3Ucfu9i;Lsoh z45}E6sCJ0)Inyl=yT!ZYuG=$SE!qvQvh=+vJpO%nX?X2urZn(|lM@6y>atezzRZ6TI8^EO> z#riF>XJZq+Sg?drj_P#A{*M-Zo%OBqs7}1C`>>L$yg9FZX1!=7$ z2##bGgsRrOFMJ~DT+VG6>}UzjKwMn8**TVGsv;)iAK-uVx4Rgg_tGfI$@d=>HY@lH>h^Q+;e8`PFtMqAi$a9=FtBu7eB-`puS4w6v6F`$9XrLswWRf>UX!=mm znrrH=GM}gYF=POIq`2}8Mr#kCx#M<0u zP!ncvCHAEdVSF&oN^5pYYc@@N77gpP(ws%F(=;9Ys&{$K&cv$F*-Ew-jK(Nwdh^Xd z%a+sjab+g0mh$)yY4cLHAce7`}?Gk>x6 z^d*@4!ndxK!m{fPr(9=!sNaMt9jy*?h(I1Jm79w{i&}HK zDmPNTM~N4ITcSs4;JoK~hQQoW>GnX0d%fr~s^?v+!3b3)vXb?#s0){J{sU0%mT|Nm zwl-p5w<`E8aJC!2RuoSra|k@SbTfa+{X|<* z+HbNn91Hz@d^^cJ+BxsGd(NmNU!|S(rRe-Z zh|kbm*lv;;dOT-f0Vg*#%5_hEO%s3MFE;DzbG9+&?$$&$HtG-Q{I%|y-`n#B;2ci1 zH^lk2S>Gzz^98cat!mLGQp$HjM}2R)>Iqnd6}FYjy~$h>$LJZz{<&PazP3aAvaspq z;9!oe`O5Dcz44iFLOh0D5R{@wn4(rSaLovWiZj}BX!1d;0K5ZiZxDVvW{*DL+1j13 z7$!Z4l&(riW~n5`#sCwyW$S02m1IA9W9tDVUxERHoD!YC~PJKK_4;f!0=IxxzN7 zRB>w^-0&xs1=mk?dUo4(cfbAqy)yJfHCNZHNze5=$v_E`=mbeFhgfV08SN7$KYD$dju z%<-s;g?>7funeBlz*JG>*~x@c2tETz@=Gi}eH+1RAVJ1UGYYZYWESk)CT%ui@_w}5 z47+^^ZAFTqLyrEW2ce6jh{Z4xb^3|3ky%uTdqH>D$cEV?Q;$?i95HvD z=0Kpog$*Iwk7EXsT>Lu|eRcCIHeTDPTTDv9C-QBA)xbDy6tKXzcRc&`scrdZAYO3r zk;dot9G6S}Ag6~}LWDR*djvygb&}VXp69Ry^o&AEZzxZ-uwU{L`O<^9B*Fn{U=dKu zEG&If10v#GlmsYW>Ij3kEOB=l>fxus7AhmpPw-amyz68MkL0S0gR?aPBuJ6K;1(Z0 z0n+7Qaf2!t!T#hB`F?_S`1Vsv`1 z(<<0r>G2@jRX!#YSfshJnPn`~l`N_--*=oi813JKhX|HX&CT($784w9XfZRw((!K(&E<0QLb?9-K+%#Rm?=!(1j6hzjZ%@7dj zeX^T`_Hi|UfIQ8Kt?0ViyUYg^J8x!gj0-dajo&&Sg{)O^emsHeNAMcq-3u6Ezms!D zoY$C{P^E4P#df!$7-LSQNBiPEuchu~Z#GMyz~lLZ_lnLqeeP;|z&bbYMp z+`{Ir5DJNLe>yGrRTW6p{skD<8p~iABss|+QSVR~id!J#{oI))t1OHa5S7Vd{ZmQ- zao)<8+fOIT`z7|Dl%k-lsG_K>2!#cQD8S%e2qjCfouP@Logu|i!Q8@J-h6};qHyU` zZ~seM&J`>ORKH3xzA}P@jS>VV3#bp?OE1}ju%~HxGAo6`8xVyzv^lSd6rxd z8GIGs$0wWorefHZ3FzNMhk!(ozf z;y)?(ysrDeCxX{FGck(Kq@}B0D{_L?QSlf(2xV&O@xLfnR#*Vj0%RgqsN*~ z^rh>XMwHMiVqEvaDkyXEb2@^_@J)#GsS)|2wVU?_!F@xz9pxSKP;o> KU%eAQ-u?}(wC_Ox literal 0 HcmV?d00001 diff --git a/tests/integration/test_android.py b/tests/integration/test_android.py new file mode 100644 index 00000000..8e671107 --- /dev/null +++ b/tests/integration/test_android.py @@ -0,0 +1,69 @@ +import os +import subprocess +from pathlib import Path +from zipfile import ZipFile + +import pytest + +from auditwheel.elfutils import elf_read_dt_needed, elf_read_rpaths, elf_read_soname + +HERE = Path(__file__).parent.resolve() + + +@pytest.mark.parametrize("ldpaths_methods", [["env"], ["arg"], ["env", "arg"]]) +def test_libcxx(ldpaths_methods, tmp_path): + # This wheel was generated from cibuildwheel's test_android.py::test_libcxx. It contains an + # external reference to libc++_shared.so. + android_dir = HERE / "android" + input_wheel = android_dir / "spam-0.1.0-cp313-cp313-android_24_arm64_v8a.whl" + + wheelhouse = tmp_path / "wheelhouse" + wheelhouse.mkdir() + + # The dummy libc++_shared.so files were generated using this command, where SUBDIR is either + # "env" or "arg": + # echo "void SUBDIR() {}" + # | aarch64-linux-android24-clang -x c - -shared -o SUBDIR/libc++_shared.so + ldpath_args: list[str | Path] = [] + if "arg" in ldpaths_methods: + libcxx_hash = "1be9716c" + ldpath_args = ["--ldpaths", android_dir / "ldpaths/arg"] + + env = os.environ.copy() + if "env" in ldpaths_methods: + # LD_LIBRARY_PATH takes priority over --ldpaths, so we overwrite the hash. + libcxx_hash = "18b6a03d" + env["LD_LIBRARY_PATH"] = str(android_dir / "ldpaths/env") + + subprocess.run( + ["auditwheel", "repair", "-w", wheelhouse, *ldpath_args, input_wheel], + env=env, + text=True, + check=True, + ) + output_wheels = list(wheelhouse.iterdir()) + assert len(output_wheels) == 1 + assert output_wheels[0].name == input_wheel.name + + output_dir = tmp_path / "output" + output_dir.mkdir() + with ZipFile(output_wheels[0]) as zf: + zf.extractall(output_dir) + + libs_dir = output_dir / "spam.libs" + libcxx_path = libs_dir / f"libc++_shared-{libcxx_hash}.so" + assert elf_read_soname(libcxx_path) == libcxx_path.name + assert elf_read_rpaths(libcxx_path) == {"rpaths": [], "runpaths": [str(libs_dir)]} + + spam_path = output_dir / "spam.cpython-313-aarch64-linux-android.so" + assert set(elf_read_dt_needed(spam_path)) == { + # Included in the policy + "libc.so", + "libdl.so", + "libm.so", + # libpython dependency is normal on Android, so it should be left alone + "libpython3.13.so", + # Grafted library + libcxx_path.name, + } + assert elf_read_rpaths(spam_path) == {"rpaths": [], "runpaths": [str(libs_dir)]} diff --git a/tests/unit/test_elfutils.py b/tests/unit/test_elfutils.py index 312f8dc7..baa2badf 100644 --- a/tests/unit/test_elfutils.py +++ b/tests/unit/test_elfutils.py @@ -12,6 +12,7 @@ elf_find_versioned_symbols, elf_read_dt_needed, elf_read_rpaths, + elf_read_soname, elf_references_pyfpe_jbuf, get_undefined_symbols, ) @@ -29,6 +30,38 @@ def name(self): return self._name +@patch("auditwheel.elfutils.ELFFile") +class TestElfReadSoname: + def test_missing_section(self, elffile_mock, tmp_path): + fake = tmp_path / "fake.so" + fake.touch() + + # GIVEN + elffile_mock.return_value.get_section_by_name.return_value = None + + # THEN + assert elf_read_soname(fake) is None + + def test_read_soname(self, elffile_mock, tmp_path): + fake = tmp_path / "fake.so" + fake.touch() + + # GIVEN + tag1 = Mock(needed="libz.so") # Non-SONAME tags should be ignored + tag1.entry.d_tag = "DT_NEEDED" + tag2 = Mock(soname="libfoo.so") + tag2.entry.d_tag = "DT_SONAME" + tag3 = Mock(soname="libbar.so") # Subsequent SONAME tags should be ignored + tag3.entry.d_tag = "DT_SONAME" + + section_mock = Mock() + section_mock.iter_tags.return_value = [tag1, tag2, tag3] + elffile_mock.return_value.get_section_by_name.return_value = section_mock + + # THEN + assert elf_read_soname(fake) == "libfoo.so" + + @patch("auditwheel.elfutils.ELFFile") class TestElfReadDt: def test_missing_section(self, elffile_mock, tmp_path): @@ -235,13 +268,13 @@ def test_elf_references_pyfpe_jbuf_no_section(self): class TestElfReadRpaths: def test_read_rpaths(self, elffile_mock, tmp_path): fake = tmp_path / "fake.so" + fake.touch() + parent = tmp_path.parent subdir = tmp_path / "subdir" subdir.mkdir() # GIVEN - fake.touch() - section_mock = Mock() tag1 = Mock(rpath=f"{parent}:$ORIGIN") tag1.entry.d_tag = "DT_RPATH" tag2 = Mock(runpath="$ORIGIN/subdir") @@ -249,6 +282,7 @@ def test_read_rpaths(self, elffile_mock, tmp_path): tag3 = Mock(needed="libfoo.so") tag3.entry.d_tag = "DT_NEEDED" + section_mock = Mock() section_mock.iter_tags.return_value = [tag1, tag2, tag3] elffile_mock.return_value.get_section_by_name.return_value = section_mock From 91ccc3a061edca55b3c30c539f0bc59eb90aacf8 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Mon, 16 Mar 2026 21:26:45 +0000 Subject: [PATCH 18/21] Always pass --ldpaths in integration test --- tests/integration/test_android.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/test_android.py b/tests/integration/test_android.py index 8e671107..a92e8dcf 100644 --- a/tests/integration/test_android.py +++ b/tests/integration/test_android.py @@ -24,10 +24,10 @@ def test_libcxx(ldpaths_methods, tmp_path): # "env" or "arg": # echo "void SUBDIR() {}" # | aarch64-linux-android24-clang -x c - -shared -o SUBDIR/libc++_shared.so - ldpath_args: list[str | Path] = [] + ldpaths = "" if "arg" in ldpaths_methods: libcxx_hash = "1be9716c" - ldpath_args = ["--ldpaths", android_dir / "ldpaths/arg"] + ldpaths = str(android_dir / "ldpaths/arg") env = os.environ.copy() if "env" in ldpaths_methods: @@ -36,7 +36,7 @@ def test_libcxx(ldpaths_methods, tmp_path): env["LD_LIBRARY_PATH"] = str(android_dir / "ldpaths/env") subprocess.run( - ["auditwheel", "repair", "-w", wheelhouse, *ldpath_args, input_wheel], + ["auditwheel", "repair", "-w", wheelhouse, "--ldpaths", ldpaths, input_wheel], env=env, text=True, check=True, From 725c7efd0ddda378db97bef6a03347d9ce0287ac Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Mon, 16 Mar 2026 22:26:33 +0000 Subject: [PATCH 19/21] Add coverage of elf_read_soname --- tests/unit/test_elfutils.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/unit/test_elfutils.py b/tests/unit/test_elfutils.py index baa2badf..cec8d0bd 100644 --- a/tests/unit/test_elfutils.py +++ b/tests/unit/test_elfutils.py @@ -42,6 +42,18 @@ def test_missing_section(self, elffile_mock, tmp_path): # THEN assert elf_read_soname(fake) is None + def test_missing_tag(self, elffile_mock, tmp_path): + fake = tmp_path / "fake.so" + fake.touch() + + # GIVEN + section_mock = Mock() + section_mock.iter_tags.return_value = [] + elffile_mock.return_value.get_section_by_name.return_value = section_mock + + # THEN + assert elf_read_soname(fake) is None + def test_read_soname(self, elffile_mock, tmp_path): fake = tmp_path / "fake.so" fake.touch() From 9841ab1695e4b00d8481d0e5a5e532343c0c8cbe Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sun, 22 Mar 2026 16:39:37 +0000 Subject: [PATCH 20/21] Correct handling of libpython --- src/auditwheel/policy/__init__.py | 4 +++ src/auditwheel/repair.py | 18 ++++++-------- tests/integration/test_android.py | 41 +++++++++++++++++++++++++------ tests/unit/test_policy.py | 15 ++++++++--- 4 files changed, 57 insertions(+), 21 deletions(-) diff --git a/src/auditwheel/policy/__init__.py b/src/auditwheel/policy/__init__.py index 8f6940cd..b55fc597 100644 --- a/src/auditwheel/policy/__init__.py +++ b/src/auditwheel/policy/__init__.py @@ -11,6 +11,7 @@ from auditwheel.architecture import Architecture from auditwheel.elfutils import filter_undefined_symbols from auditwheel.error import InvalidLibcError +from auditwheel.lddtree import LIBPYTHON_RE from auditwheel.libc import Libc from auditwheel.tools import is_subdir from auditwheel.wheeltools import android_api_level, get_wheel_platforms @@ -238,6 +239,9 @@ def filter_libs( # 'ld64.so.1' on ppc64le # 'ld-linux*' on other platforms continue + if self.libc == Libc.ANDROID and LIBPYTHON_RE.match(lib): + # libpython dependencies are normal on Android. + continue if lib in whitelist: # exclude any libs in the whitelist continue diff --git a/src/auditwheel/repair.py b/src/auditwheel/repair.py index 3da5cc9a..67cc3c98 100644 --- a/src/auditwheel/repair.py +++ b/src/auditwheel/repair.py @@ -14,7 +14,6 @@ from auditwheel.elfutils import elf_read_dt_needed from auditwheel.hashfile import hashfile from auditwheel.lddtree import LIBPYTHON_RE -from auditwheel.libc import Libc from auditwheel.policy import get_replace_platforms from auditwheel.sboms import create_sbom_for_wheel from auditwheel.tools import is_subdir, unique_by_index @@ -75,16 +74,15 @@ def repair_wheel( ext_libs = v[abis[0]].libs replacements: list[tuple[str, str]] = [] for soname, src_path in ext_libs.items(): - # libpython dependencies are forbidden on Linux, but allowed on Android. + # Handle libpython dependencies by removing them if LIBPYTHON_RE.match(soname): - if wheel_abi.policies.libc in [Libc.GLIBC, Libc.MUSL]: - logger.warning( - "Removing %s dependency from %s. " - "Linking with libpython is forbidden for manylinux/musllinux wheels.", - soname, - str(fn), - ) - patcher.remove_needed(fn, soname) + logger.warning( + "Removing %s dependency from %s. " + "Linking with libpython is forbidden for manylinux/musllinux wheels.", + soname, + str(fn), + ) + patcher.remove_needed(fn, soname) continue if src_path is None: diff --git a/tests/integration/test_android.py b/tests/integration/test_android.py index a92e8dcf..ef321c95 100644 --- a/tests/integration/test_android.py +++ b/tests/integration/test_android.py @@ -5,18 +5,23 @@ import pytest +from auditwheel.architecture import Architecture from auditwheel.elfutils import elf_read_dt_needed, elf_read_rpaths, elf_read_soname +from auditwheel.libc import Libc +from auditwheel.policy import ExternalReference +from auditwheel.wheel_abi import analyze_wheel_abi HERE = Path(__file__).parent.resolve() +android_dir = HERE / "android" + +# This wheel was generated from cibuildwheel's test_android.py::test_libcxx. It contains an +# external reference to libc++_shared.so. +libcxx_wheel = android_dir / "spam-0.1.0-cp313-cp313-android_24_arm64_v8a.whl" +libcxx_module = "spam.cpython-313-aarch64-linux-android.so" @pytest.mark.parametrize("ldpaths_methods", [["env"], ["arg"], ["env", "arg"]]) def test_libcxx(ldpaths_methods, tmp_path): - # This wheel was generated from cibuildwheel's test_android.py::test_libcxx. It contains an - # external reference to libc++_shared.so. - android_dir = HERE / "android" - input_wheel = android_dir / "spam-0.1.0-cp313-cp313-android_24_arm64_v8a.whl" - wheelhouse = tmp_path / "wheelhouse" wheelhouse.mkdir() @@ -36,14 +41,14 @@ def test_libcxx(ldpaths_methods, tmp_path): env["LD_LIBRARY_PATH"] = str(android_dir / "ldpaths/env") subprocess.run( - ["auditwheel", "repair", "-w", wheelhouse, "--ldpaths", ldpaths, input_wheel], + ["auditwheel", "repair", "-w", wheelhouse, "--ldpaths", ldpaths, libcxx_wheel], env=env, text=True, check=True, ) output_wheels = list(wheelhouse.iterdir()) assert len(output_wheels) == 1 - assert output_wheels[0].name == input_wheel.name + assert output_wheels[0].name == libcxx_wheel.name output_dir = tmp_path / "output" output_dir.mkdir() @@ -55,7 +60,7 @@ def test_libcxx(ldpaths_methods, tmp_path): assert elf_read_soname(libcxx_path) == libcxx_path.name assert elf_read_rpaths(libcxx_path) == {"rpaths": [], "runpaths": [str(libs_dir)]} - spam_path = output_dir / "spam.cpython-313-aarch64-linux-android.so" + spam_path = output_dir / libcxx_module assert set(elf_read_dt_needed(spam_path)) == { # Included in the policy "libc.so", @@ -67,3 +72,23 @@ def test_libcxx(ldpaths_methods, tmp_path): libcxx_path.name, } assert elf_read_rpaths(spam_path) == {"rpaths": [], "runpaths": [str(libs_dir)]} + + +def test_analyze_wheel_abi(): + winfo = analyze_wheel_abi( + Libc.ANDROID, + Architecture.aarch64, + libcxx_wheel, + exclude=frozenset(), + disable_isa_ext_check=False, + allow_graft=True, + ) + policy_name = "android_24_arm64_v8a" + assert winfo.overall_policy.name == policy_name + external_ref = ExternalReference( + libs={"libc++_shared.so": None}, + blacklist={}, + policy=winfo.overall_policy, + ) + assert winfo.external_refs[policy_name] == external_ref + assert winfo.full_external_refs[Path(libcxx_module)] == winfo.external_refs diff --git a/tests/unit/test_policy.py b/tests/unit/test_policy.py index 8c455d18..37a2c50b 100644 --- a/tests/unit/test_policy.py +++ b/tests/unit/test_policy.py @@ -175,7 +175,8 @@ def test_get_by_name_duplicate(self): class TestLddTreeExternalReferences: """Tests for lddtree_external_references.""" - def test_filter_libs(self): + @pytest.mark.parametrize("libc", [Libc.GLIBC, Libc.ANDROID]) + def test_filter_libs(self, libc): """Test the nested filter_libs function.""" filtered_libs = [ "ld-linux-x86_64.so.1", @@ -183,10 +184,12 @@ def test_filter_libs(self): "ld64.so.2", ] unfiltered_libs = ["libfoo.so.1.0", "libbar.so.999.999.999"] + (filtered_libs if libc == Libc.ANDROID else unfiltered_libs).append("libpython3.13.so") libs = filtered_libs + unfiltered_libs + lddtree = DynamicExecutable( interpreter=None, - libc=Libc.GLIBC, + libc=libc, path="/path/to/lib", realpath=Path("/path/to/lib"), platform=Platform( @@ -205,7 +208,13 @@ def test_filter_libs(self): rpath=(), runpath=(), ) - policies = WheelPolicies(libc=Libc.GLIBC, arch=Architecture.x86_64) + policies = WheelPolicies( + libc=libc, + arch=Architecture.x86_64, + wheel_fn=( + Path("spam-0.1-py3-none-android_24_x86_64.whl") if libc == Libc.ANDROID else None + ), + ) full_external_refs = policies.lddtree_external_references( lddtree, Path("/path/to/wheel"), From e0bf5bab52f3f8136b71f7781b4cdc45dea57b51 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sun, 22 Mar 2026 19:59:09 +0000 Subject: [PATCH 21/21] Make --ldpaths default to empty on Android --- src/auditwheel/wheel_abi.py | 4 ++++ tests/integration/test_android.py | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/auditwheel/wheel_abi.py b/src/auditwheel/wheel_abi.py index 86d1fb73..658cedef 100644 --- a/src/auditwheel/wheel_abi.py +++ b/src/auditwheel/wheel_abi.py @@ -73,6 +73,10 @@ def get_wheel_elfdata( uses_pyfpe_jbuf = False policies: WheelPolicies | None = None + # Android is cross-compiled, so ldpaths should never be loaded from the build machine. + if libc == Libc.ANDROID and args_ldpaths is None: + args_ldpaths = "" + with InGenericPkgCtx(wheel_fn) as ctx: shared_libraries_in_purelib = [] shared_libraries_with_invalid_machine = [] diff --git a/tests/integration/test_android.py b/tests/integration/test_android.py index ef321c95..3aae0222 100644 --- a/tests/integration/test_android.py +++ b/tests/integration/test_android.py @@ -29,19 +29,19 @@ def test_libcxx(ldpaths_methods, tmp_path): # "env" or "arg": # echo "void SUBDIR() {}" # | aarch64-linux-android24-clang -x c - -shared -o SUBDIR/libc++_shared.so - ldpaths = "" + ldpaths_args: list[str | Path] = [] if "arg" in ldpaths_methods: libcxx_hash = "1be9716c" - ldpaths = str(android_dir / "ldpaths/arg") + ldpaths_args = ["--ldpaths", android_dir / "ldpaths/arg"] env = os.environ.copy() if "env" in ldpaths_methods: - # LD_LIBRARY_PATH takes priority over --ldpaths, so we overwrite the hash. + # LD_LIBRARY_PATH is searched before --ldpaths, so we overwrite the hash. libcxx_hash = "18b6a03d" env["LD_LIBRARY_PATH"] = str(android_dir / "ldpaths/env") subprocess.run( - ["auditwheel", "repair", "-w", wheelhouse, "--ldpaths", ldpaths, libcxx_wheel], + ["auditwheel", "repair", "-w", wheelhouse, *ldpaths_args, libcxx_wheel], env=env, text=True, check=True,