Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a95e8d0
Begin Android support
mhsmith Dec 13, 2025
a35924f
Improve API level auto-detection
mhsmith Dec 14, 2025
d2a0db9
Fix some tests
mhsmith Dec 15, 2025
237874e
Working with manual repair of numpy-1.26.2-0-cp313-cp313-android_24_a…
mhsmith Dec 15, 2025
588758c
Use LD_LIBRARY_PATHS variable even when --ldpaths is passed
mhsmith Dec 16, 2025
38a59c5
Use RUNPATH rather than RPATH
mhsmith Dec 16, 2025
e675469
Merge remote-tracking branch 'origin/main' into android
mhsmith Feb 4, 2026
2033e47
Rename and test android_api_level
mhsmith Mar 4, 2026
3a5e1b2
Revert unnecessary reformatting
mhsmith Mar 4, 2026
d4092fc
Add tests
mhsmith Mar 4, 2026
5e54a3e
Remove automatic increase to API level 24
mhsmith Mar 9, 2026
c2a76d4
Add tests for policies
mhsmith Mar 13, 2026
e088b03
Add --ldpaths to all subcommands, and make it required on Android
mhsmith Mar 13, 2026
79627e4
Move API level 24 check to patcher
mhsmith Mar 13, 2026
5fb678f
Rename main_options.py to options.py
mhsmith Mar 15, 2026
364ab91
Improve error messages
mhsmith Mar 15, 2026
4568843
Respond to Copilot and Codecov reports; update documentation
mhsmith Mar 15, 2026
c3911bc
Merge remote-tracking branch 'origin/main' into android
mhsmith Mar 15, 2026
5cf82d6
Add integration test
mhsmith Mar 15, 2026
bd58c59
Merge branch 'main' into android
mayeut Mar 16, 2026
91ccc3a
Always pass --ldpaths in integration test
mhsmith Mar 16, 2026
725c7ef
Add coverage of elf_read_soname
mhsmith Mar 16, 2026
9841ab1
Correct handling of libpython
mhsmith Mar 22, 2026
e0bf5ba
Make --ldpaths default to empty on Android
mhsmith Mar 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
<https://www.python.org/dev/peps/pep-0600/>`_, `PEP 513 manylinux1
<https://www.python.org/dev/peps/pep-0513/>`_, `PEP 571 manylinux2010
<https://www.python.org/dev/peps/pep-0571/>`_ and `PEP 599 manylinux2014
<https://www.python.org/dev/peps/pep-0599/>`_ 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 <http://pythonwheels.com/>`_ for Linux (containing pre-compiled
binary extensions) that are compatible with a wide variety of Linux distributions,
`wheel packages <http://pythonwheels.com/>`_ (containing pre-compiled
binary extensions) that are compatible with a wide variety of distributions,
consistent with the `PEP 600 manylinux_x_y
<https://www.python.org/dev/peps/pep-0600/>`_, `PEP 513 manylinux1
<https://www.python.org/dev/peps/pep-0513/>`_, `PEP 571 manylinux2010
<https://www.python.org/dev/peps/pep-0571/>`_ and `PEP 599 manylinux2014
<https://www.python.org/dev/peps/pep-0599/>`_ platform tags.
<https://www.python.org/dev/peps/pep-0571/>`_, `PEP 599 manylinux2014
<https://www.python.org/dev/peps/pep-0599/>`_ and `PEP 738 android
<https://www.python.org/dev/peps/pep-0738/>`_ platform tags.

``auditwheel show``: shows external shared libraries that the wheel depends on
(beyond the libraries included in the ``manylinux`` policies), and
Expand All @@ -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 <https://github.com/NixOS/patchelf>`_: 0.14+

Expand Down
3 changes: 3 additions & 0 deletions src/auditwheel/architecture.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class Architecture(Enum):
value: str

aarch64 = "aarch64"
arm64_v8a = "arm64_v8a" # Android
armv7l = "armv7l"
i686 = "i686"
loongarch64 = "loongarch64"
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/auditwheel/elfutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
30 changes: 26 additions & 4 deletions src/auditwheel/lddtree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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)
Comment on lines +250 to +253
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure whether including $ORIGIN in LD_LIBRARY_PATH is valid at all, but the TODO in load_ld_paths makes it clear that it isn't currently supported. So if the user attempts to do this, I think it's better to throw an exception, rather than silently skipping the path entry.

The current code is even worse, because it transforms $ORIGIN into the parent of the working directory, which is what dirname(abspath("")) returns.

else:
ldpath_ = root + ldpath
ldpaths.append(normpath(ldpath_))
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 13 additions & 1 deletion src/auditwheel/libc.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,18 @@ class Libc(Enum):

GLIBC = "glibc"
MUSL = "musl"
ANDROID = "android"

def __str__(self) -> str:
return self.value

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:
Expand All @@ -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:
Expand Down
4 changes: 0 additions & 4 deletions src/auditwheel/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
7 changes: 5 additions & 2 deletions src/auditwheel/main_lddtree.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any

from auditwheel import options

if TYPE_CHECKING:
import argparse

Expand All @@ -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
50 changes: 17 additions & 33 deletions src/auditwheel/main_repair.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 6 additions & 14 deletions src/auditwheel/main_show.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any

from auditwheel import options

if TYPE_CHECKING:
import argparse

Expand All @@ -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)


Expand Down Expand Up @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions src/auditwheel/options.py
Original file line number Diff line number Diff line change
@@ -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.",
)
Loading
Loading