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/architecture.py b/src/auditwheel/architecture.py index 06d9b2cd..ab1a292c 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" # Android armv7l = "armv7l" i686 = "i686" loongarch64 = "loongarch64" @@ -28,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/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/src/auditwheel/lddtree.py b/src/auditwheel/lddtree.py index 6d6acd30..8d7e7147 100644 --- a/src/auditwheel/lddtree.py +++ b/src/auditwheel/lddtree.py @@ -37,6 +37,9 @@ # 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\})") + @dataclass(frozen=True) class Platform: @@ -213,12 +216,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 @@ -235,13 +238,19 @@ 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 "$ORIGIN" in ldpath: - ldpath_ = ldpath.replace("$ORIGIN", os.path.dirname(os.path.abspath(path))) + 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 ldpaths.append(normpath(ldpath_)) @@ -363,6 +372,19 @@ def load_ld_paths( 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, diff --git a/src/auditwheel/libc.py b/src/auditwheel/libc.py index 2a6be1c8..7b8c2410 100644 --- a/src/auditwheel/libc.py +++ b/src/auditwheel/libc.py @@ -24,6 +24,7 @@ class Libc(Enum): GLIBC = "glibc" MUSL = "musl" + ANDROID = "android" def __str__(self) -> str: return self.value @@ -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 InvalidLibcError(msg) @staticmethod def detect() -> Libc: @@ -44,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.py b/src/auditwheel/main.py index 6e564278..68c5c212 100644 --- a/src/auditwheel/main.py +++ b/src/auditwheel/main.py @@ -12,10 +12,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"), diff --git a/src/auditwheel/main_lddtree.py b/src/auditwheel/main_lddtree.py index 714d8b79..00e28645 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 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") + 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_repair.py b/src/auditwheel/main_repair.py index c1a1fcc0..447fb8aa 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 options from auditwheel.architecture import Architecture from auditwheel.error import NonPlatformWheelError, WheelToolsError from auditwheel.libc import Libc @@ -118,20 +119,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, - ) + options.disable_isa_check(parser) + options.allow_pure_python_wheel(parser) + options.ldpaths(parser) + parser.set_defaults(func=execute) @@ -180,24 +171,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 to a wheel " + f"targeting {lc.name}" + ) + parser.error(msg) logger.info("Repairing %s", wheel_filename) @@ -213,6 +196,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, + args_ldpaths=args.LDPATHS, ) except NonPlatformWheelError as e: logger.info(e.message) @@ -282,7 +266,7 @@ def execute(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: *abis, ] - patcher = Patchelf() + patcher = Patchelf(requested_policy.name) out_wheel = repair_wheel( wheel_abi, wheel_file, diff --git a/src/auditwheel/main_show.py b/src/auditwheel/main_show.py index a184f044..838ec772 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 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, - ) + options.disable_isa_check(p) + options.allow_pure_python_wheel(p) + 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/options.py b/src/auditwheel/options.py new file mode 100644 index 00000000..3991e470 --- /dev/null +++ b/src/auditwheel/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/patcher.py b/src/auditwheel/patcher.py index 2f6bef35..3f841cf7 100644 --- a/src/auditwheel/patcher.py +++ b/src/auditwheel/patcher.py @@ -6,6 +6,8 @@ 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 @@ -49,7 +51,8 @@ def _verify_patchelf() -> None: class Patchelf(ElfPatcher): - def __init__(self) -> None: + 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: @@ -74,8 +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: + 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") + + # https://android.googlesource.com/platform/bionic/+/refs/heads/main/android-changes-for-ndk-developers.md + if android_api_level(self.platform) < 24: + msg = "Grafting libraries with RUNPATH requires API level 24 or higher" + raise ValueError(msg) + check_call(["patchelf", "--remove-rpath", file_name]) - check_call(["patchelf", "--force-rpath", "--set-rpath", 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/src/auditwheel/policy/__init__.py b/src/auditwheel/policy/__init__.py index 36f82b34..b55fc597 100644 --- a/src/auditwheel/policy/__init__.py +++ b/src/auditwheel/policy/__init__.py @@ -4,15 +4,17 @@ import logging import re from collections import defaultdict -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from typing import TYPE_CHECKING, Any 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 if TYPE_CHECKING: from collections.abc import Generator, Iterable @@ -27,6 +29,7 @@ _POLICY_JSON_MAP = { Libc.GLIBC: _HERE / "manylinux-policy.json", Libc.MUSL: _HERE / "musllinux-policy.json", + Libc.ANDROID: _HERE / "android-policy.json", } @@ -58,6 +61,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: @@ -118,6 +122,35 @@ def __init__( self._policies = [self._policies[0], self._policies[1]] assert len(self._policies) == 2, self._policies # noqa: S101 + 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. + 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}" + raise ValueError(msg) + platform = platforms[0] + api_level = android_api_level(platform) + + # 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: + # 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]: yield from self._policies @@ -206,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/policy/android-policy.json b/src/auditwheel/policy/android-policy.json new file mode 100644 index 00000000..7919f6dd --- /dev/null +++ b/src/auditwheel/policy/android-policy.json @@ -0,0 +1,41 @@ +[ + { + "name": "linux", + "aliases": [], + "priority": 0, + "symbol_versions": {}, + "lib_whitelist": [], + "blacklist": {} + }, + { + "name": "android_21", + "aliases": [], + "priority": 79, + "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": {} + }, + { + "name": "android_24", + "aliases": [], + "priority": 76, + "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": {} + }, + { + "name": "android_26", + "aliases": [], + "priority": 74, + "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": {} + }, + { + "name": "android_27", + "aliases": [], + "priority": 73, + "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 5f2fb60a..67cc3c98 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.policy import get_replace_platforms @@ -156,13 +156,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] @@ -175,7 +170,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: @@ -183,8 +177,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 8e78d472..658cedef 100644 --- a/src/auditwheel/wheel_abi.py +++ b/src/auditwheel/wheel_abi.py @@ -19,7 +19,7 @@ ) from auditwheel.error import InvalidLibcError, NonPlatformWheelError from auditwheel.genericpkgctx import InGenericPkgCtx -from auditwheel.lddtree import DynamicExecutable, ldd +from auditwheel.lddtree import DynamicExecutable, ld_paths_from_arg, ldd from auditwheel.libc import Libc from auditwheel.policy import ExternalReference, Policy, WheelPolicies @@ -63,6 +63,7 @@ def get_wheel_elfdata( architecture: Architecture | None, wheel_fn: Path, exclude: frozenset[str], + args_ldpaths: str | None, ) -> WheelElfData: full_elftree: dict[Path, DynamicExecutable] = {} nonpy_elftree: dict[Path, DynamicExecutable] = {} @@ -72,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 = [] @@ -91,7 +96,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) + elftree = ldd(fn, exclude=exclude, ldpaths=ld_paths_from_arg(args_ldpaths)) try: elf_arch = elftree.platform.baseline_architecture @@ -116,7 +121,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) + policies = WheelPolicies(libc=libc, arch=architecture, wheel_fn=wheel_fn) platform_wheel = True @@ -375,8 +380,9 @@ def analyze_wheel_abi( disable_isa_ext_check: bool, allow_graft: bool, requested_policy_base_name: str | None = None, + args_ldpaths: str | None = None, ) -> WheelAbIInfo: - data = get_wheel_elfdata(libc, architecture, wheel_fn, exclude) + 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/src/auditwheel/wheeltools.py b/src/auditwheel/wheeltools.py index 16f040b4..51cb82db 100644 --- a/src/auditwheel/wheeltools.py +++ b/src/auditwheel/wheeltools.py @@ -232,8 +232,7 @@ 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 = 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) @@ -290,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: @@ -323,12 +321,10 @@ 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: - if "musllinux_" in tag.platform: - result.add(Libc.MUSL) - if "manylinux" in tag.platform: - result.add(Libc.GLIBC) + for platform in get_wheel_platforms(filename): + for libc in Libc: + if platform.startswith(libc.tag_prefix): + result.add(libc) if len(result) == 0: msg = "unknown libc used" raise WheelToolsError(msg) @@ -336,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) -> list[str]: + _, _, _, in_tags = parse_wheel_filename(filename) + return sorted({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/integration/android/ldpaths/arg/libc++_shared.so b/tests/integration/android/ldpaths/arg/libc++_shared.so new file mode 100755 index 00000000..e420e712 Binary files /dev/null and b/tests/integration/android/ldpaths/arg/libc++_shared.so differ 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 00000000..dc9aaf05 Binary files /dev/null and b/tests/integration/android/ldpaths/env/libc++_shared.so differ 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 00000000..510b2f0b Binary files /dev/null and b/tests/integration/android/spam-0.1.0-cp313-cp313-android_24_arm64_v8a.whl differ diff --git a/tests/integration/test_android.py b/tests/integration/test_android.py new file mode 100644 index 00000000..3aae0222 --- /dev/null +++ b/tests/integration/test_android.py @@ -0,0 +1,94 @@ +import os +import subprocess +from pathlib import Path +from zipfile import ZipFile + +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): + 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 + ldpaths_args: list[str | Path] = [] + if "arg" in ldpaths_methods: + libcxx_hash = "1be9716c" + ldpaths_args = ["--ldpaths", android_dir / "ldpaths/arg"] + + env = os.environ.copy() + if "env" in ldpaths_methods: + # 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_args, libcxx_wheel], + env=env, + text=True, + check=True, + ) + output_wheels = list(wheelhouse.iterdir()) + assert len(output_wheels) == 1 + assert output_wheels[0].name == libcxx_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 / libcxx_module + 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)]} + + +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/integration/test_bundled_wheels.py b/tests/integration/test_bundled_wheels.py index db440ffa..e7bb52f7 100644 --- a/tests/integration/test_bundled_wheels.py +++ b/tests/integration/test_bundled_wheels.py @@ -176,6 +176,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", @@ -204,6 +205,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_elfpatcher.py b/tests/unit/test_elfpatcher.py index 556489a1..2e5718a2 100644 --- a/tests/unit/test_elfpatcher.py +++ b/tests/unit/test_elfpatcher.py @@ -88,19 +88,29 @@ 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() + @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", "--force-rpath", "--set-rpath", "$ORIGIN/.lib", filename], + ["patchelf"] + + ([] if platform.startswith("android") else ["--force-rpath"]) + + ["--set-rpath", "$ORIGIN/.lib", filename], ), ] assert check_call.call_args_list == check_call_expected_args + 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") diff --git a/tests/unit/test_elfutils.py b/tests/unit/test_elfutils.py index 2456956b..cec8d0bd 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,50 @@ 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_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() + + # 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): @@ -233,6 +278,33 @@ 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" + fake.touch() + + parent = tmp_path.parent + subdir = tmp_path / "subdir" + subdir.mkdir() + + # GIVEN + 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 = Mock() + 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_lddtree.py b/tests/unit/test_lddtree.py index 6e9f0fde..df4e779e 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, ld_paths_from_arg, ldd, parse_ld_paths from auditwheel.libc import Libc from auditwheel.tools import zip2dir @@ -63,3 +64,67 @@ 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] + + +@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_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_main.py b/tests/unit/test_main.py index e4f7e816..30ebcedd 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -10,24 +10,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"]) @@ -108,7 +91,6 @@ def test_repair_wheel_mismatch( assert message in captured.err -@on_supported_platform def test_main_module() -> None: version = metadata.version("auditwheel") result = subprocess.run( diff --git a/tests/unit/test_policy.py b/tests/unit/test_policy.py index 470058e7..37a2c50b 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 @@ -173,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", @@ -181,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( @@ -203,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"), @@ -266,3 +277,60 @@ 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}" + 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 + + +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"), + [ + ( + "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_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..801907a1 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) @@ -61,6 +67,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: @@ -87,6 +94,37 @@ 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", ["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: list[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"