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"