Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion src/auditwheel/repair.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def repair_wheel(
strip: bool,
zip_compression_level: int,
) -> Path | None:
external_refs_by_fn = wheel_abi.full_external_refs
external_refs_by_fn = wheel_abi.repair_external_refs
# Do not repair a pure wheel, i.e. has no external refs
if not external_refs_by_fn:
return None
Expand Down
53 changes: 42 additions & 11 deletions src/auditwheel/wheel_abi.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
class WheelAbIInfo:
policies: WheelPolicies
full_external_refs: dict[Path, dict[str, ExternalReference]]
repair_external_refs: dict[Path, dict[str, ExternalReference]]
overall_policy: Policy
external_refs: dict[str, ExternalReference]
ref_policy: Policy
Expand All @@ -52,11 +53,34 @@ class WheelElfData:
policies: WheelPolicies
full_elftree: dict[Path, DynamicExecutable]
full_external_refs: dict[Path, dict[str, ExternalReference]]
repair_external_refs: dict[Path, dict[str, ExternalReference]]
versioned_symbols: dict[str, set[str]]
uses_ucs2_symbols: bool
uses_pyfpe_jbuf: bool


def _filter_repair_refs(
external_refs: dict[str, ExternalReference],
wheel_sonames: frozenset[str],
) -> dict[str, ExternalReference]:
"""Return external refs with unresolved sonames already present in the
wheel removed.

``external_refs`` maps policy name to the libraries needed by one ELF.
``wheel_sonames`` is the set of shared-library sonames already present in
the wheel. The returned mapping keeps only refs that still need repair.
"""
filtered_refs = {}
for name, external_ref in external_refs.items():
libs = {
lib: path
for lib, path in external_ref.libs.items()
if path is not None or lib not in wheel_sonames
Comment on lines +64 to +78
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

wheel_sonames is built from fn.name (the filenames of ELF entries in the wheel), but _filter_repair_refs and its docstring describe this as a set of SONAMEs. To avoid confusion for future maintainers, consider renaming the parameter/variable (e.g., to reflect DT_NEEDED/filename matching) and updating the docstring accordingly.

Suggested change
wheel_sonames: frozenset[str],
) -> dict[str, ExternalReference]:
"""Return external refs with unresolved sonames already present in the
wheel removed.
``external_refs`` maps policy name to the libraries needed by one ELF.
``wheel_sonames`` is the set of shared-library sonames already present in
the wheel. The returned mapping keeps only refs that still need repair.
"""
filtered_refs = {}
for name, external_ref in external_refs.items():
libs = {
lib: path
for lib, path in external_ref.libs.items()
if path is not None or lib not in wheel_sonames
wheel_lib_names: frozenset[str],
) -> dict[str, ExternalReference]:
"""Return external refs with unresolved libraries already present in the
wheel removed.
``external_refs`` maps policy name to the libraries needed by one ELF.
``wheel_lib_names`` is the set of shared-library names/filenames already
present in the wheel (used for DT_NEEDED/filename-style matching). The
returned mapping keeps only refs that still need repair.
"""
filtered_refs = {}
for name, external_ref in external_refs.items():
libs = {
lib: path
for lib, path in external_ref.libs.items()
if path is not None or lib not in wheel_lib_names

Copilot uses AI. Check for mistakes.
}
filtered_refs[name] = ExternalReference(libs, external_ref.blacklist, external_ref.policy)
return filtered_refs


@functools.lru_cache
def get_wheel_elfdata(
libc: Libc | None,
Expand All @@ -67,6 +91,7 @@ def get_wheel_elfdata(
full_elftree: dict[Path, DynamicExecutable] = {}
nonpy_elftree: dict[Path, DynamicExecutable] = {}
full_external_refs: dict[Path, dict[str, ExternalReference]] = {}
repair_external_refs: dict[Path, dict[str, ExternalReference]] = {}
versioned_symbols: dict[str, set[str]] = defaultdict(set)
uses_ucs2_symbols = False
uses_pyfpe_jbuf = False
Expand Down Expand Up @@ -142,6 +167,7 @@ def get_wheel_elfdata(
elftree,
ctx.path,
)
repair_external_refs[fn] = full_external_refs[fn]
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

repair_external_refs for Python extensions is currently set to full_external_refs without applying _filter_repair_refs. That means unresolved DT_NEEDED entries that are actually internal (the library exists somewhere in the wheel, but lddtree couldn't resolve it so the path is None) will still be present for the extension and will still trigger ValueError: required library ... could not be located during repair_wheel. Consider building repair_external_refs for all ELFs (including Python extensions) by filtering against wheel_sonames after you’ve collected the wheel’s sonames, and only keeping refs that still have libs/blacklist after filtering.

Suggested change
repair_external_refs[fn] = full_external_refs[fn]
# Apply the same external-reference filtering used for other ELFs
# so that unresolved DT_NEEDED entries that are actually provided
# by libraries inside the wheel (wheel_sonames) do not cause
# spurious repair failures.
filtered_refs = _filter_repair_refs(
{fn: full_external_refs[fn]},
wheel_sonames,
)
if fn in filtered_refs:
repair_external_refs[fn] = filtered_refs[fn]

Copilot uses AI. Check for mistakes.
else:
# If the ELF is not a Python extension, it might be
# included in the wheel already because auditwheel repair
Expand All @@ -164,7 +190,6 @@ def get_wheel_elfdata(
arch = None if architecture is None else architecture.value
raise NonPlatformWheelError(arch, shared_libraries_with_invalid_machine)

# Get a list of all external libraries needed by ELFs in the wheel.
needed_libs = {
lib
for elf in itertools.chain(full_elftree.values(), nonpy_elftree.values())
Expand All @@ -181,31 +206,35 @@ def get_wheel_elfdata(
log.warning("couldn't detect wheel libc, defaulting to %s", str(libc))
policies = WheelPolicies(libc=libc, arch=architecture)

for fn, elf_tree in nonpy_elftree.items():
# If a non-pyextension ELF file is not needed by something else
# inside the wheel, then it was not checked by the logic above and
# we should walk its elftree.
if fn.name not in needed_libs:
full_elftree[fn] = elf_tree
wheel_sonames = frozenset(fn.name for fn in itertools.chain(full_elftree, nonpy_elftree))

for fn, external_refs in full_external_refs.items():
filtered_refs = _filter_repair_refs(external_refs, wheel_sonames)
if any(ref.libs or ref.blacklist for ref in filtered_refs.values()):
repair_external_refs[fn] = filtered_refs

# Even if a non-pyextension ELF file is not needed, we
# should include it as an external reference, because
# it might require additional external libraries.
for fn, elf_tree in nonpy_elftree.items():
full_external_refs[fn] = policies.lddtree_external_references(
elf_tree,
ctx.path,
)
if fn.name not in needed_libs:
full_elftree[fn] = elf_tree
filtered_refs = _filter_repair_refs(full_external_refs[fn], wheel_sonames)
if any(ref.libs or ref.blacklist for ref in filtered_refs.values()):
repair_external_refs[fn] = filtered_refs

log.debug("full_elftree:\n%s", json.dumps(full_elftree))
log.debug(
"full_external_refs (will be repaired):\n%s",
"full_external_refs:\n%s",
json.dumps(full_external_refs),
)

return WheelElfData(
policies,
full_elftree,
full_external_refs,
repair_external_refs,
versioned_symbols,
uses_ucs2_symbols,
uses_pyfpe_jbuf,
Expand Down Expand Up @@ -380,6 +409,7 @@ def analyze_wheel_abi(
policies = data.policies
elftree_by_fn = data.full_elftree
external_refs_by_fn = data.full_external_refs
repair_external_refs_by_fn = data.repair_external_refs
versioned_symbols = data.versioned_symbols

external_refs: dict[str, ExternalReference] = {
Expand Down Expand Up @@ -470,6 +500,7 @@ def analyze_wheel_abi(
return WheelAbIInfo(
policies,
external_refs_by_fn,
repair_external_refs_by_fn,
overall_policy,
external_refs,
ref_policy,
Expand Down
208 changes: 208 additions & 0 deletions tests/unit/test_wheel_abi.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from auditwheel import wheel_abi
from auditwheel.architecture import Architecture
from auditwheel.lddtree import DynamicExecutable, Platform
from auditwheel.libc import Libc
from auditwheel.policy import ExternalReference, WheelPolicies

Expand Down Expand Up @@ -66,6 +67,213 @@ def test_finds_shared_library_in_purelib(

assert exec_info.value.args == (message,)

def test_filters_internal_nonpy_refs_for_repair(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FakeCtx:
path = Path("/wheel")

def __enter__(self):
return self

def __exit__(self, *_args):
return None

def iter_files(self):
return [Path("pkg/ext.so"), Path("pkg/libinner.so")]

platform = Platform(
"",
64,
True,
"EM_X86_64",
Architecture.x86_64,
None,
None,
)
ext_tree = DynamicExecutable(
libc=Libc.GLIBC,
interpreter=None,
path="pkg/ext.so",
realpath=Path("/wheel/pkg/ext.so"),
platform=platform,
needed=("libinner.so",),
rpath=(),
runpath=(),
libraries={},
)
inner_tree = DynamicExecutable(
libc=Libc.GLIBC,
interpreter=None,
path="pkg/libinner.so",
realpath=Path("/wheel/pkg/libinner.so"),
platform=platform,
needed=(),
rpath=(),
runpath=(),
libraries={},
)

external_ref = {
"manylinux_2_17_x86_64": ExternalReference(
{
"libinner.so": None,
"libc.so.6": Path("/lib64/libc.so.6"),
},
{},
pretend.stub(priority=80),
),
}
fake_policies = pretend.stub(
lddtree_external_references=lambda _elftree, _wheel_path: external_ref,
)

monkeypatch.setattr(
wheel_abi,
"InGenericPkgCtx",
pretend.stub(__call__=lambda _wheel: FakeCtx()),
)
monkeypatch.setattr(
wheel_abi,
"elf_file_filter",
lambda _files: [
(Path("pkg/ext.so"), pretend.stub()),
(Path("pkg/libinner.so"), pretend.stub()),
],
)
monkeypatch.setattr(
wheel_abi,
"ldd",
lambda fn, **_kwargs: {
Path("pkg/ext.so"): ext_tree,
Path("pkg/libinner.so"): inner_tree,
}[fn],
)
monkeypatch.setattr(
wheel_abi,
"elf_find_versioned_symbols",
lambda _elf: [],
)
monkeypatch.setattr(
wheel_abi,
"elf_is_python_extension",
lambda fn, _elf: (fn == Path("pkg/ext.so"), 3),
)
monkeypatch.setattr(
wheel_abi,
"elf_references_pyfpe_jbuf",
lambda _elf: False,
)
monkeypatch.setattr(
wheel_abi,
"WheelPolicies",
lambda **_kwargs: fake_policies,
)

wheel_abi.get_wheel_elfdata.cache_clear()
result = wheel_abi.get_wheel_elfdata(
Libc.GLIBC,
Architecture.x86_64,
Path("/fakepath"),
frozenset(),
)

assert Path("pkg/ext.so") in result.full_external_refs
assert Path("pkg/libinner.so") in result.full_external_refs
assert Path("pkg/libinner.so") in result.repair_external_refs
assert result.repair_external_refs[Path("pkg/libinner.so")][
"manylinux_2_17_x86_64"
].libs == {
"libc.so.6": Path("/lib64/libc.so.6"),
}
# Ensure the Python extension itself does not list the internal
# non-Python ELF dependency as an external library to repair.
assert (
"libinner.so"
not in result.repair_external_refs[Path("pkg/ext.so")]["manylinux_2_17_x86_64"].libs
)
assert Path("pkg/libinner.so") not in result.full_elftree

def test_keeps_nonpy_roots_in_analysis(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FakeCtx:
path = Path("/wheel")

def __enter__(self):
return self

def __exit__(self, *_args):
return None

def iter_files(self):
return [Path("pkg/tool.so")]

platform = Platform(
"",
64,
True,
"EM_X86_64",
Architecture.x86_64,
None,
None,
)
tree = DynamicExecutable(
libc=Libc.GLIBC,
interpreter=None,
path="pkg/tool.so",
realpath=Path("/wheel/pkg/tool.so"),
platform=platform,
needed=(),
rpath=(),
runpath=(),
libraries={},
)

external_ref = {
"manylinux_2_17_x86_64": ExternalReference(
{},
{"libc.so.6": ["bad_symbol"]},
pretend.stub(priority=80),
),
}
fake_policies = pretend.stub(
lddtree_external_references=lambda _elftree, _wheel_path: external_ref,
)

monkeypatch.setattr(
wheel_abi,
"InGenericPkgCtx",
pretend.stub(__call__=lambda _wheel: FakeCtx()),
)
monkeypatch.setattr(
wheel_abi,
"elf_file_filter",
lambda _files: [(Path("pkg/tool.so"), pretend.stub())],
)
monkeypatch.setattr(wheel_abi, "ldd", lambda _fn, **_kwargs: tree)
monkeypatch.setattr(wheel_abi, "elf_find_versioned_symbols", lambda _elf: [])
monkeypatch.setattr(wheel_abi, "elf_is_python_extension", lambda *_args: (False, 3))
monkeypatch.setattr(wheel_abi, "elf_references_pyfpe_jbuf", lambda _elf: False)
monkeypatch.setattr(
wheel_abi,
"WheelPolicies",
lambda **_kwargs: fake_policies,
)

wheel_abi.get_wheel_elfdata.cache_clear()
result = wheel_abi.get_wheel_elfdata(
Libc.GLIBC,
Architecture.x86_64,
Path("/fakepath"),
frozenset(),
)

assert Path("pkg/tool.so") in result.full_elftree
assert Path("pkg/tool.so") in result.repair_external_refs


def test_get_symbol_policies() -> None:
policies = WheelPolicies(libc=Libc.GLIBC, arch=Architecture.x86_64)
Expand Down
Loading