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
35 changes: 33 additions & 2 deletions src/auditwheel/main_repair.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import argparse
import logging
import shutil
import warnings
import zlib
from pathlib import Path
from typing import Any
Expand All @@ -12,6 +13,7 @@
from auditwheel.libc import Libc
from auditwheel.patcher import Patchelf
from auditwheel.policy import WheelPolicies
from auditwheel.repair import StripLevel
from auditwheel.tools import EnvironmentDefault
from auditwheel.wheeltools import get_wheel_architecture, get_wheel_libc

Expand Down Expand Up @@ -97,9 +99,24 @@ def configure_parser(sub_parsers: Any) -> None: # noqa: ANN401
"--strip",
dest="STRIP",
action="store_true",
help="Strip symbols in the resulting wheel",
help=(
"(DEPRECATED) Strip all symbols in the resulting wheel. Use --strip-level=all instead."
),
default=False,
)
parser.add_argument(
"--strip-level",
dest="STRIP_LEVEL",
choices=[level.value for level in StripLevel],
help=(
"Strip level for symbol processing. "
"Options: none (default, no stripping), "
"debug (remove debug symbols only), "
"unneeded (remove unneeded symbols), "
"all (remove all symbols)."
),
default="none",
)
parser.add_argument(
"--exclude",
dest="EXCLUDE",
Expand Down Expand Up @@ -143,6 +160,20 @@ def execute(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
wheel_dir: Path = args.WHEEL_DIR.absolute()
wheel_files: list[Path] = args.WHEEL_FILE

# Validate and resolve strip arguments once, before processing any wheel.
if args.STRIP and args.STRIP_LEVEL != "none":
parser.error("Cannot specify both --strip and --strip-level")

if args.STRIP:
warnings.warn(
"The --strip option is deprecated. Use --strip-level=all instead.",
DeprecationWarning,
stacklevel=2,
)
strip_level = StripLevel.ALL
else:
strip_level = StripLevel(args.STRIP_LEVEL)

requested_architecture: Architecture | None = None

plat_base: str = args.PLAT
Expand Down Expand Up @@ -291,7 +322,7 @@ def execute(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
out_dir=wheel_dir,
update_tags=args.UPDATE_TAGS,
patcher=patcher,
strip=args.STRIP,
strip_level=strip_level,
zip_compression_level=args.ZIP_COMPRESSION_LEVEL,
)

Expand Down
72 changes: 63 additions & 9 deletions src/auditwheel/repair.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import platform
import shutil
import stat
import zlib
from enum import Enum
from pathlib import Path
from subprocess import check_call
from typing import TYPE_CHECKING
Expand All @@ -28,6 +30,15 @@
logger = logging.getLogger(__name__)


class StripLevel(Enum):
"""Strip levels for symbol processing."""

NONE = "none"
DEBUG = "debug"
UNNEEDED = "unneeded"
ALL = "all"


def repair_wheel(
wheel_abi: WheelAbIInfo,
wheel_path: Path,
Expand All @@ -37,9 +48,20 @@ def repair_wheel(
*,
update_tags: bool,
patcher: ElfPatcher,
strip: bool,
zip_compression_level: int,
strip: bool | None = None,
strip_level: StripLevel | None = None,
zip_compression_level: int = zlib.Z_DEFAULT_COMPRESSION,
) -> Path | None:
# Validate and normalize strip arguments before doing any work.
if strip is not None and strip_level is not None:
msg = "Cannot specify both 'strip' and 'strip_level' parameters"
raise ValueError(msg)

if strip is True:
strip_level = StripLevel.ALL
elif strip_level is None:
strip_level = StripLevel.NONE

external_refs_by_fn = wheel_abi.full_external_refs
# Do not repair a pure wheel, i.e. has no external refs
if not external_refs_by_fn:
Expand Down Expand Up @@ -124,10 +146,11 @@ def repair_wheel(
if update_tags:
output_wheel = add_platforms(ctx, abis, get_replace_platforms(abis[0]))

if strip:
libs_to_strip = [path for (_, path) in soname_map.values()]
extensions = external_refs_by_fn.keys()
strip_symbols(itertools.chain(libs_to_strip, extensions))
if strip_level != StripLevel.NONE:
libs_to_process = [path for (_, path) in soname_map.values()]
extensions = list(external_refs_by_fn.keys())
all_libraries = list(itertools.chain(libs_to_process, extensions))
process_symbols(all_libraries, strip_level)

# If we grafted packages with identities we add an SBOM to the wheel.
# We recalculate the checksum at this point because there can be
Expand All @@ -144,10 +167,41 @@ def repair_wheel(
return output_wheel


def process_symbols(
libraries: Iterable[Path],
strip_level: StripLevel,
) -> None:
"""Process symbols in libraries according to the given strip level."""
libraries_list = list(libraries)

if not libraries_list or strip_level == StripLevel.NONE:
return

strip_args = _get_strip_args(strip_level)
for lib in libraries_list:
logger.info("Stripping symbols from %s (level: %s)", lib, strip_level.value)
check_call(["strip", *strip_args, str(lib)])


def _get_strip_args(strip_level: StripLevel) -> list[str]:
"""Get strip command arguments for the given strip level.

``StripLevel.NONE`` must not be passed here; callers are responsible for
skipping symbol processing when the level is NONE.
"""
if strip_level == StripLevel.DEBUG:
return ["-g"]
if strip_level == StripLevel.UNNEEDED:
return ["--strip-unneeded"]
if strip_level == StripLevel.ALL:
return ["-s"]
msg = f"_get_strip_args called with unsupported strip level: {strip_level!r}"
raise ValueError(msg)


def strip_symbols(libraries: Iterable[Path]) -> None:
for lib in libraries:
logger.info("Stripping symbols from %s", lib)
check_call(["strip", "-s", lib])
"""Legacy function for backward compatibility. Use process_symbols instead."""
process_symbols(libraries, StripLevel.ALL)


def copylib(src_path: Path, dest_dir: Path, patcher: ElfPatcher) -> tuple[str, Path]:
Expand Down
160 changes: 160 additions & 0 deletions tests/unit/test_main_repair_strip_levels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
from __future__ import annotations

import argparse
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from auditwheel.main_repair import configure_parser, execute
from auditwheel.repair import StripLevel


class _ComparablePolicy:
def __init__(self, name: str, aliases: list[str] | None = None) -> None:
self.name = name
self.aliases = aliases or []

def __gt__(self, _other: object) -> bool:
return False

def __lt__(self, _other: object) -> bool:
return False


class TestStripLevelArgument:
def test_default_values(self):
parser = argparse.ArgumentParser()
configure_parser(parser.add_subparsers())
args = parser.parse_args(["repair", "test.whl"])
assert args.STRIP_LEVEL == "none"
assert args.STRIP is False

def test_strip_level_choices(self):
parser = argparse.ArgumentParser()
configure_parser(parser.add_subparsers())
for level in StripLevel:
args = parser.parse_args(["repair", f"--strip-level={level.value}", "test.whl"])
assert level.value == args.STRIP_LEVEL

def test_strip_level_invalid_choice(self):
parser = argparse.ArgumentParser()
configure_parser(parser.add_subparsers())
with pytest.raises(SystemExit):
parser.parse_args(["repair", "--strip-level=invalid", "test.whl"])

def test_deprecated_strip_still_accepted(self):
parser = argparse.ArgumentParser()
configure_parser(parser.add_subparsers())
args = parser.parse_args(["repair", "--strip", "test.whl"])
assert args.STRIP is True


class TestStripLevelExecute:
@pytest.fixture(autouse=True)
def _patch_patchelf(self):
with patch("auditwheel.main_repair.Patchelf"):
yield

def _make_wheel_abi_mock(self):
mock_wheel_abi = MagicMock()
mock_wheel_abi.full_external_refs = {}
mock_wheel_abi.policies = MagicMock()
mock_policy = _ComparablePolicy("manylinux_2_17_x86_64")

mock_wheel_abi.policies.lowest = mock_policy
mock_wheel_abi.policies.linux = _ComparablePolicy("linux_x86_64")
mock_wheel_abi.policies.get_policy_by_name = MagicMock(return_value=mock_policy)
mock_wheel_abi.overall_policy = mock_policy
mock_wheel_abi.sym_policy = mock_policy
mock_wheel_abi.ucs_policy = mock_policy
mock_wheel_abi.blacklist_policy = mock_policy
mock_wheel_abi.machine_policy = mock_policy
return mock_wheel_abi

def _make_args(self, **kwargs):
args = MagicMock()
args.WHEEL_FILE = [Path("test.whl")]
args.WHEEL_DIR = Path("wheelhouse")
args.LIB_SDIR = ".libs"
args.PLAT = "auto"
args.UPDATE_TAGS = True
args.ONLY_PLAT = False
args.EXCLUDE = []
args.DISABLE_ISA_EXT_CHECK = False
args.ZIP_COMPRESSION_LEVEL = 6
args.STRIP = False
args.STRIP_LEVEL = "none"
args.ALLOW_PURE_PY_WHEEL = False
for k, v in kwargs.items():
setattr(args, k, v)
return args

# repair_wheel and analyze_wheel_abi are lazily imported inside execute(),
# so they must be patched at their source modules, not at auditwheel.main_repair.
@patch("auditwheel.repair.repair_wheel")
@patch("auditwheel.wheel_abi.analyze_wheel_abi")
def test_strip_level_debug_passed_to_repair_wheel(
self,
mock_analyze,
mock_repair,
):
mock_analyze.return_value = self._make_wheel_abi_mock()
mock_repair.return_value = Path("output.whl")
args = self._make_args(STRIP_LEVEL="debug")

with (
patch("auditwheel.main_repair.Path.mkdir"),
patch("auditwheel.main_repair.Path.exists", return_value=True),
patch("auditwheel.main_repair.Path.is_file", return_value=True),
patch("auditwheel.main_repair.get_wheel_architecture"),
patch("auditwheel.main_repair.get_wheel_libc"),
):
result = execute(args, MagicMock())

assert result == 0
call_kwargs = mock_repair.call_args[1]
assert call_kwargs["strip_level"] == StripLevel.DEBUG
assert "strip" not in call_kwargs # deprecated param not forwarded

@patch("auditwheel.repair.repair_wheel")
@patch("auditwheel.wheel_abi.analyze_wheel_abi")
def test_deprecated_strip_resolves_to_strip_level_all(
self,
mock_analyze,
mock_repair,
):
"""--strip is deprecated; it resolves to strip_level=ALL before the loop."""
mock_analyze.return_value = self._make_wheel_abi_mock()
mock_repair.return_value = Path("output.whl")
args = self._make_args(STRIP=True)

with (
patch("auditwheel.main_repair.Path.mkdir"),
patch("auditwheel.main_repair.Path.exists", return_value=True),
patch("auditwheel.main_repair.Path.is_file", return_value=True),
patch("auditwheel.main_repair.get_wheel_architecture"),
patch("auditwheel.main_repair.get_wheel_libc"),
patch("auditwheel.main_repair.warnings.warn") as mock_warn,
):
result = execute(args, MagicMock())

assert result == 0
mock_warn.assert_called_once_with(
"The --strip option is deprecated. Use --strip-level=all instead.",
DeprecationWarning,
stacklevel=2,
)
call_kwargs = mock_repair.call_args[1]
assert call_kwargs["strip_level"] == StripLevel.ALL
assert "strip" not in call_kwargs

def test_conflicting_strip_and_strip_level_errors(self):
args = self._make_args(STRIP=True, STRIP_LEVEL="debug")
parser = MagicMock()
# parser.error must halt execution (as argparse does) so subsequent code
# doesn't run after the conflict is detected.
parser.error.side_effect = SystemExit(2)
with pytest.raises(SystemExit):
execute(args, parser)
parser.error.assert_called_once_with("Cannot specify both --strip and --strip-level")
Loading