diff --git a/src/auditwheel/main_repair.py b/src/auditwheel/main_repair.py index c1a1fcc0..c2ca5d95 100644 --- a/src/auditwheel/main_repair.py +++ b/src/auditwheel/main_repair.py @@ -3,6 +3,7 @@ import argparse import logging import shutil +import warnings import zlib from pathlib import Path from typing import Any @@ -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 @@ -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", @@ -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 @@ -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, ) diff --git a/src/auditwheel/repair.py b/src/auditwheel/repair.py index 5f2fb60a..62181fff 100644 --- a/src/auditwheel/repair.py +++ b/src/auditwheel/repair.py @@ -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 @@ -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, @@ -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: @@ -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 @@ -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]: diff --git a/tests/unit/test_main_repair_strip_levels.py b/tests/unit/test_main_repair_strip_levels.py new file mode 100644 index 00000000..e7ee5593 --- /dev/null +++ b/tests/unit/test_main_repair_strip_levels.py @@ -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") diff --git a/tests/unit/test_repair_strip_levels.py b/tests/unit/test_repair_strip_levels.py new file mode 100644 index 00000000..a66bf0ee --- /dev/null +++ b/tests/unit/test_repair_strip_levels.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from auditwheel.repair import StripLevel, repair_wheel + + +def _make_wheel_abi(libs=None): + mock = MagicMock() + mock.full_external_refs = { + Path("ext.so"): { + "manylinux_2_17_x86_64": MagicMock(libs=libs or {}), + }, + } + return mock + + +def _make_ctx_mock(): + """Return a mock InWheelCtx context with the minimum setup repair_wheel needs.""" + mock_ctx = MagicMock() + mock_ctx.out_wheel = Path("output.whl") + mock_ctx.name = Path("wheel_name") + # repair_wheel asserts exactly one dist-info dir exists + mock_ctx.path.glob.return_value = [MagicMock()] + return mock_ctx + + +@pytest.fixture +def mock_ctx_cls(): + with patch("auditwheel.repair.InWheelCtx") as mock: + yield mock + + +@pytest.fixture +def mock_process(): + with patch("auditwheel.repair.process_symbols") as mock: + yield mock + + +class TestRepairWheelStripLevels: + """Tests for repair_wheel strip level behaviour.""" + + @pytest.fixture(autouse=True) + def _mock_sbom(self): + with patch("auditwheel.repair.create_sbom_for_wheel", return_value=None): + yield + + def test_strip_level_none_does_not_call_process_symbols( + self, + mock_ctx_cls, + mock_process, + tmp_path, + ): + mock_ctx_cls.return_value.__enter__.return_value = _make_ctx_mock() + repair_wheel( + wheel_abi=_make_wheel_abi(), + wheel_path=Path("pkg-1.0-py3-none-linux_x86_64.whl"), + abis=["manylinux_2_17_x86_64"], + lib_sdir=".libs", + out_dir=tmp_path, + update_tags=False, + patcher=MagicMock(), + strip_level=StripLevel.NONE, + ) + mock_process.assert_not_called() + + def test_strip_level_debug_calls_process_symbols( + self, + mock_ctx_cls, + mock_process, + tmp_path, + ): + mock_ctx_cls.return_value.__enter__.return_value = _make_ctx_mock() + repair_wheel( + wheel_abi=_make_wheel_abi(), + wheel_path=Path("pkg-1.0-py3-none-linux_x86_64.whl"), + abis=["manylinux_2_17_x86_64"], + lib_sdir=".libs", + out_dir=tmp_path, + update_tags=False, + patcher=MagicMock(), + strip_level=StripLevel.DEBUG, + ) + mock_process.assert_called_once() + libs, level = mock_process.call_args[0] + assert level == StripLevel.DEBUG + assert Path("ext.so") in list(libs) + + def test_strip_true_maps_to_strip_level_all( + self, + mock_ctx_cls, + mock_process, + tmp_path, + ): + """Backward compatibility: strip=True behaves like strip_level=ALL.""" + mock_ctx_cls.return_value.__enter__.return_value = _make_ctx_mock() + repair_wheel( + wheel_abi=_make_wheel_abi(), + wheel_path=Path("pkg-1.0-py3-none-linux_x86_64.whl"), + abis=["manylinux_2_17_x86_64"], + lib_sdir=".libs", + out_dir=tmp_path, + update_tags=False, + patcher=MagicMock(), + strip=True, + ) + mock_process.assert_called_once() + _, level = mock_process.call_args[0] + assert level == StripLevel.ALL + + def test_pure_wheel_returns_none(self, mock_process, tmp_path): + mock_abi = MagicMock() + mock_abi.full_external_refs = {} + result = repair_wheel( + wheel_abi=mock_abi, + wheel_path=Path("pkg-1.0-py3-none-linux_x86_64.whl"), + abis=["manylinux_2_17_x86_64"], + lib_sdir=".libs", + out_dir=tmp_path, + update_tags=False, + patcher=MagicMock(), + strip_level=StripLevel.DEBUG, + ) + assert result is None + mock_process.assert_not_called() + + +class TestRepairWheelConflict: + def test_conflicting_strip_and_strip_level_raises(self, tmp_path): + """Conflict guard fires before the pure-wheel early return.""" + mock_abi = MagicMock() + mock_abi.full_external_refs = {} # pure wheel — would normally return None + with pytest.raises(ValueError, match="Cannot specify both"): + repair_wheel( + wheel_abi=mock_abi, + wheel_path=Path("pkg-1.0-py3-none-linux_x86_64.whl"), + abis=["manylinux_2_17_x86_64"], + lib_sdir=".libs", + out_dir=tmp_path, + update_tags=False, + patcher=MagicMock(), + strip=True, + strip_level=StripLevel.DEBUG, + ) diff --git a/tests/unit/test_strip_levels.py b/tests/unit/test_strip_levels.py new file mode 100644 index 00000000..10e9db14 --- /dev/null +++ b/tests/unit/test_strip_levels.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import call, patch + +import pytest + +from auditwheel.repair import ( + StripLevel, + _get_strip_args, + process_symbols, + strip_symbols, +) + + +class TestStripLevel: + def test_strip_level_values(self): + assert StripLevel.NONE.value == "none" + assert StripLevel.DEBUG.value == "debug" + assert StripLevel.UNNEEDED.value == "unneeded" + assert StripLevel.ALL.value == "all" + + def test_strip_level_from_string(self): + assert StripLevel("none") == StripLevel.NONE + assert StripLevel("debug") == StripLevel.DEBUG + assert StripLevel("unneeded") == StripLevel.UNNEEDED + assert StripLevel("all") == StripLevel.ALL + + def test_strip_level_invalid_value(self): + with pytest.raises(ValueError, match="'invalid' is not a valid StripLevel"): + StripLevel("invalid") + + +class TestGetStripArgs: + def test_none_raises(self): + """NONE is a caller error; process_symbols guards against it.""" + with pytest.raises(ValueError, match="unsupported strip level"): + _get_strip_args(StripLevel.NONE) + + def test_debug(self): + assert _get_strip_args(StripLevel.DEBUG) == ["-g"] + + def test_unneeded(self): + assert _get_strip_args(StripLevel.UNNEEDED) == ["--strip-unneeded"] + + def test_all(self): + assert _get_strip_args(StripLevel.ALL) == ["-s"] + + +@patch("auditwheel.repair.check_call") +class TestProcessSymbols: + def test_none_level_does_not_strip(self, mock_check_call): + process_symbols([Path("lib1.so"), Path("lib2.so")], StripLevel.NONE) + mock_check_call.assert_not_called() + + def test_empty_libraries_does_not_strip(self, mock_check_call): + process_symbols([], StripLevel.ALL) + mock_check_call.assert_not_called() + + def test_debug_level(self, mock_check_call): + process_symbols([Path("lib1.so"), Path("lib2.so")], StripLevel.DEBUG) + mock_check_call.assert_has_calls( + [ + call(["strip", "-g", "lib1.so"]), + call(["strip", "-g", "lib2.so"]), + ], + ) + + def test_unneeded_level(self, mock_check_call): + process_symbols([Path("lib1.so")], StripLevel.UNNEEDED) + mock_check_call.assert_called_once_with(["strip", "--strip-unneeded", "lib1.so"]) + + def test_all_level(self, mock_check_call): + process_symbols([Path("lib1.so")], StripLevel.ALL) + mock_check_call.assert_called_once_with(["strip", "-s", "lib1.so"]) + + +@patch("auditwheel.repair.check_call") +class TestStripSymbolsBackwardCompatibility: + def test_strip_symbols_uses_all_level(self, mock_check_call): + strip_symbols([Path("lib1.so"), Path("lib2.so")]) + mock_check_call.assert_has_calls( + [ + call(["strip", "-s", "lib1.so"]), + call(["strip", "-s", "lib2.so"]), + ], + )