diff --git a/relenv/relocate.py b/relenv/relocate.py index 762a05bd..2ceddea4 100755 --- a/relenv/relocate.py +++ b/relenv/relocate.py @@ -11,6 +11,7 @@ import pathlib import shutil as _shutil import subprocess as _subprocess +import sys as _sys from typing import Optional log = logging.getLogger(__name__) @@ -18,6 +19,7 @@ os = _os shutil = _shutil subprocess = _subprocess +sys = _sys __all__ = [ "is_macho", @@ -70,6 +72,83 @@ LC_LOAD_DYLIB = "LC_LOAD_DYLIB" LC_RPATH = "LC_RPATH" +# Cache for readelf binary path +_READELF_BINARY: Optional[str] = None + +# Cache for patchelf binary path +_PATCHELF_BINARY: Optional[str] = None + + +def _get_readelf_binary() -> str: + """ + Get the path to readelf binary, preferring toolchain version. + + Returns the cached value if already computed. On Linux, prefers the + toolchain's readelf over the system version. Falls back to "readelf" + from PATH if toolchain is unavailable. + + :return: Path to readelf binary + :rtype: str + """ + global _READELF_BINARY + if _READELF_BINARY is not None: + return _READELF_BINARY + + # Only Linux has the toolchain with readelf + if sys.platform == "linux": + try: + from relenv.common import get_toolchain, get_triplet + + toolchain = get_toolchain() + if toolchain: + triplet = get_triplet() + toolchain_readelf = toolchain / "bin" / f"{triplet}-readelf" + if toolchain_readelf.exists(): + _READELF_BINARY = str(toolchain_readelf) + return _READELF_BINARY + except Exception: + # Fall through to system readelf + pass + + # Fall back to system readelf + _READELF_BINARY = "readelf" + return _READELF_BINARY + + +def _get_patchelf_binary() -> str: + """ + Get the path to patchelf binary, preferring toolchain version. + + Returns the cached value if already computed. On Linux, prefers the + toolchain's patchelf over the system version. Falls back to "patchelf" + from PATH if toolchain is unavailable. + + :return: Path to patchelf binary + :rtype: str + """ + global _PATCHELF_BINARY + if _PATCHELF_BINARY is not None: + return _PATCHELF_BINARY + + # Only Linux has the toolchain with patchelf + if sys.platform == "linux": + try: + from relenv.common import get_toolchain + + toolchain = get_toolchain() + if toolchain: + toolchain_patchelf = toolchain / "bin" / "patchelf" + if toolchain_patchelf.exists(): + _PATCHELF_BINARY = str(toolchain_patchelf) + return _PATCHELF_BINARY + except Exception: + # Fall through to system patchelf + pass + + # Fall back to system patchelf + _PATCHELF_BINARY = "patchelf" + return _PATCHELF_BINARY + def is_macho(path: str | os.PathLike[str]) -> bool: """ @@ -192,8 +271,9 @@ def parse_rpath(path: str | os.PathLike[str]) -> list[str]: :return: The RPATH's found. :rtype: list """ + readelf = _get_readelf_binary() proc = subprocess.run( - ["readelf", "-d", path], stdout=subprocess.PIPE, stderr=subprocess.PIPE + [readelf, "-d", path], stdout=subprocess.PIPE, stderr=subprocess.PIPE ) return parse_readelf_d(proc.stdout.decode()) @@ -280,8 +360,9 @@ def remove_rpath(path: str | os.PathLike[str]) -> bool: return True log.info("Remove RPATH from %s (was: %s)", path, old_rpath) + patchelf = _get_patchelf_binary() proc = subprocess.run( - ["patchelf", "--remove-rpath", path], + [patchelf, "--remove-rpath", path], stderr=subprocess.PIPE, stdout=subprocess.PIPE, ) @@ -319,8 +400,9 @@ def patch_rpath( if new_rpath not in old_rpath: patched_rpath = ":".join([new_rpath] + old_rpath) log.info("Set RPATH=%s %s", patched_rpath, path) + patchelf = _get_patchelf_binary() proc = subprocess.run( - ["patchelf", "--force-rpath", "--set-rpath", patched_rpath, path], + [patchelf, "--force-rpath", "--set-rpath", patched_rpath, path], stderr=subprocess.PIPE, stdout=subprocess.PIPE, ) diff --git a/relenv/runtime.py b/relenv/runtime.py index 1f940044..1f80c51d 100644 --- a/relenv/runtime.py +++ b/relenv/runtime.py @@ -384,25 +384,30 @@ def wrapper( direct_url, requested, ) - plat = pathlib.Path(scheme.platlib) - rootdir = relenv_root() - with open(plat / info_dir / "RECORD") as fp: - for line in fp.readlines(): - file = plat / line.split(",", 1)[0] - if not file.exists(): - debug(f"Relenv - File not found {file}") - continue - if relocate().is_elf(file): - debug(f"Relenv - Found elf {file}") - relocate().handle_elf(plat / file, rootdir / "lib", True, rootdir) - elif relocate().is_macho(file): - otool_bin = shutil.which("otool") - if otool_bin: - relocate().handle_macho(str(plat / file), str(rootdir), True) - else: - debug( - "The otool command is not available, please run `xcode-select --install`" + if "RELENV_BUILDENV" in os.environ: + plat = pathlib.Path(scheme.platlib) + rootdir = relenv_root() + with open(plat / info_dir / "RECORD") as fp: + for line in fp.readlines(): + file = plat / line.split(",", 1)[0] + if not file.exists(): + debug(f"Relenv - File not found {file}") + continue + if relocate().is_elf(file): + debug(f"Relenv - Found elf {file}") + relocate().handle_elf( + plat / file, rootdir / "lib", True, rootdir ) + elif relocate().is_macho(file): + otool_bin = shutil.which("otool") + if otool_bin: + relocate().handle_macho( + str(plat / file), str(rootdir), True + ) + else: + debug( + "The otool command is not available, please run `xcode-select --install`" + ) return wrapper @@ -1022,7 +1027,11 @@ def install_cargo_config() -> None: cargo_home = dirs.data / "cargo" triplet = common().get_triplet() - toolchain = common().get_toolchain() + try: + toolchain = common().get_toolchain() + except PermissionError: + pass + if not toolchain: debug("Unable to set CARGO_HOME ppbt package not installed") return diff --git a/tests/test_relocate_tools.py b/tests/test_relocate_tools.py new file mode 100644 index 00000000..41c70ee1 --- /dev/null +++ b/tests/test_relocate_tools.py @@ -0,0 +1,152 @@ +# Copyright 2022-2026 Broadcom. +# SPDX-License-Identifier: Apache-2.0 + +import pathlib +from typing import Iterator +from unittest.mock import patch + +import pytest + +from relenv import relocate + + +@pytest.fixture(autouse=True) # type: ignore[misc] +def reset_globals() -> Iterator[None]: + """Reset global caches in relocate module before and after each test.""" + relocate._READELF_BINARY = None + relocate._PATCHELF_BINARY = None + yield + relocate._READELF_BINARY = None + relocate._PATCHELF_BINARY = None + + +def test_get_readelf_binary_toolchain_exists(tmp_path: pathlib.Path) -> None: + """Test that toolchain readelf is used when available.""" + toolchain_root = tmp_path / "toolchain" + toolchain_root.mkdir() + triplet = "x86_64-linux-gnu" + + # Create the fake toolchain binary + bin_dir = toolchain_root / "bin" + bin_dir.mkdir(parents=True) + toolchain_readelf = bin_dir / f"{triplet}-readelf" + toolchain_readelf.touch() + + with patch("relenv.relocate.sys.platform", "linux"): + # We need to mock relenv.common.get_toolchain and get_triplet + # Since they are imported inside the function, we can patch the module if it's already imported + # or use patch.dict(sys.modules) + + # Ensure relenv.common is imported so we can patch it + import relenv.common # noqa: F401 + + with patch("relenv.common.get_toolchain", return_value=toolchain_root): + with patch("relenv.common.get_triplet", return_value=triplet): + readelf = relocate._get_readelf_binary() + + assert readelf == str(toolchain_readelf) + assert relocate._READELF_BINARY == str(toolchain_readelf) + + +def test_get_readelf_binary_toolchain_missing(tmp_path: pathlib.Path) -> None: + """Test that system readelf is used when toolchain binary is missing.""" + toolchain_root = tmp_path / "toolchain" + toolchain_root.mkdir() + triplet = "x86_64-linux-gnu" + + # Do NOT create the binary + + with patch("relenv.relocate.sys.platform", "linux"): + # Ensure relenv.common is imported so we can patch it + import relenv.common # noqa: F401 + + with patch("relenv.common.get_toolchain", return_value=toolchain_root): + with patch("relenv.common.get_triplet", return_value=triplet): + readelf = relocate._get_readelf_binary() + + assert readelf == "readelf" + assert relocate._READELF_BINARY == "readelf" + + +def test_get_readelf_binary_no_toolchain() -> None: + """Test that system readelf is used when get_toolchain returns None.""" + with patch("relenv.relocate.sys.platform", "linux"): + # Ensure relenv.common is imported so we can patch it + import relenv.common # noqa: F401 + + with patch("relenv.common.get_toolchain", return_value=None): + readelf = relocate._get_readelf_binary() + + assert readelf == "readelf" + assert relocate._READELF_BINARY == "readelf" + + +def test_get_readelf_binary_not_linux() -> None: + """Test that system readelf is used on non-Linux platforms.""" + with patch("relenv.relocate.sys.platform", "darwin"): + readelf = relocate._get_readelf_binary() + + assert readelf == "readelf" + assert relocate._READELF_BINARY == "readelf" + + +def test_get_patchelf_binary_toolchain_exists(tmp_path: pathlib.Path) -> None: + """Test that toolchain patchelf is used when available.""" + toolchain_root = tmp_path / "toolchain" + toolchain_root.mkdir() + + # Create the fake toolchain binary + bin_dir = toolchain_root / "bin" + bin_dir.mkdir(parents=True) + toolchain_patchelf = bin_dir / "patchelf" + toolchain_patchelf.touch() + + with patch("relenv.relocate.sys.platform", "linux"): + # Ensure relenv.common is imported so we can patch it + import relenv.common # noqa: F401 + + with patch("relenv.common.get_toolchain", return_value=toolchain_root): + patchelf = relocate._get_patchelf_binary() + + assert patchelf == str(toolchain_patchelf) + assert relocate._PATCHELF_BINARY == str(toolchain_patchelf) + + +def test_get_patchelf_binary_toolchain_missing(tmp_path: pathlib.Path) -> None: + """Test that system patchelf is used when toolchain binary is missing.""" + toolchain_root = tmp_path / "toolchain" + toolchain_root.mkdir() + + # Do NOT create the binary + + with patch("relenv.relocate.sys.platform", "linux"): + # Ensure relenv.common is imported so we can patch it + import relenv.common # noqa: F401 + + with patch("relenv.common.get_toolchain", return_value=toolchain_root): + patchelf = relocate._get_patchelf_binary() + + assert patchelf == "patchelf" + assert relocate._PATCHELF_BINARY == "patchelf" + + +def test_get_patchelf_binary_no_toolchain() -> None: + """Test that system patchelf is used when get_toolchain returns None.""" + with patch("relenv.relocate.sys.platform", "linux"): + # Ensure relenv.common is imported so we can patch it + import relenv.common # noqa: F401 + + with patch("relenv.common.get_toolchain", return_value=None): + patchelf = relocate._get_patchelf_binary() + + assert patchelf == "patchelf" + assert relocate._PATCHELF_BINARY == "patchelf" + + +def test_get_patchelf_binary_not_linux() -> None: + """Test that system patchelf is used on non-Linux platforms.""" + with patch("relenv.relocate.sys.platform", "darwin"): + patchelf = relocate._get_patchelf_binary() + + assert patchelf == "patchelf" + assert relocate._PATCHELF_BINARY == "patchelf" diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 9187f1d9..71acdd44 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -374,6 +374,7 @@ def original(self: Dummy, *args: object, **kwargs: object) -> None: def test_install_wheel_wrapper_processes_record( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: + monkeypatch.setenv("RELENV_BUILDENV", "1") plat_dir = tmp_path / "plat" info_dir = plat_dir / "demo.dist-info" info_dir.mkdir(parents=True) @@ -440,6 +441,7 @@ def original_install(*_args: object, **_kwargs: object) -> str: def test_install_wheel_wrapper_missing_file( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: + monkeypatch.setenv("RELENV_BUILDENV", "1") plat_dir = tmp_path / "plat" info_dir = plat_dir / "demo.dist-info" info_dir.mkdir(parents=True) @@ -477,6 +479,7 @@ def test_install_wheel_wrapper_missing_file( def test_install_wheel_wrapper_macho_with_otool( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: + monkeypatch.setenv("RELENV_BUILDENV", "1") plat_dir = tmp_path / "plat" info_dir = plat_dir / "demo.dist-info" info_dir.mkdir(parents=True) @@ -520,6 +523,7 @@ def test_install_wheel_wrapper_macho_with_otool( def test_install_wheel_wrapper_macho_without_otool( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: + monkeypatch.setenv("RELENV_BUILDENV", "1") plat_dir = tmp_path / "plat" info_dir = plat_dir / "demo.dist-info" info_dir.mkdir(parents=True) @@ -565,6 +569,74 @@ def test_install_wheel_wrapper_macho_without_otool( assert any("otool command is not available" in msg for msg in messages) +def test_install_wheel_wrapper_skips_without_buildenv( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("RELENV_BUILDENV", raising=False) + plat_dir = tmp_path / "plat" + info_dir = plat_dir / "demo.dist-info" + info_dir.mkdir(parents=True) + record = info_dir / "RECORD" + record.write_text("libdemo.so,,\n") + binary = plat_dir / "libdemo.so" + binary.touch() + + # If relocation runs, this mock will raise an error or capture calls + handled: list[tuple[pathlib.Path, pathlib.Path]] = [] + monkeypatch.setattr( + relenv.runtime, + "relocate", + lambda: SimpleNamespace( + is_elf=lambda path: path.name.endswith(".so"), + is_macho=lambda path: False, + handle_elf=lambda file, lib_dir, fix, root: handled.append((file, lib_dir)), + handle_macho=lambda *args, **kwargs: None, + ), + ) + + wheel_utils = ModuleType("pip._internal.utils.wheel") + wheel_utils.parse_wheel = lambda _zf, _name: ("demo.dist-info", {}) + monkeypatch.setitem(sys.modules, wheel_utils.__name__, wheel_utils) + + class DummyZip: + def __init__(self, path: pathlib.Path) -> None: + self.path = path + + def __enter__(self) -> DummyZip: + return self + + def __exit__(self, *exc: object) -> bool: + return False + + monkeypatch.setattr("zipfile.ZipFile", DummyZip) + + install_module = ModuleType("pip._internal.operations.install.wheel") + + def original_install(*_args: object, **_kwargs: object) -> str: + return "original" + + install_module.install_wheel = original_install # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, install_module.__name__, install_module) + + wrapped_module = relenv.runtime.wrap_pip_install_wheel(install_module.__name__) + + scheme = SimpleNamespace( + platlib=str(plat_dir), + ) + wrapped_module.install_wheel( + "demo", + tmp_path / "wheel.whl", + scheme, + "desc", + None, + None, + None, + None, + ) + + assert not handled + + def test_install_legacy_wrapper_prefix( monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: diff --git a/tests/test_verify_build.py b/tests/test_verify_build.py index f16954a4..0f611e0e 100644 --- a/tests/test_verify_build.py +++ b/tests/test_verify_build.py @@ -519,6 +519,9 @@ def test_pip_install_pyzmq( env["RELENV_BUILDENV"] = "yes" env["USE_STATIC_REQUIREMENTS"] = "1" + if pyzmq_version == "26.2.0": + env["CMAKE_ARGS"] = "-DCMAKE_POLICY_VERSION_MINIMUM=3.5" + if sys.platform == "linux": fake_bsd_root = tmp_path / "fake_libbsd" (fake_bsd_root / "bsd").mkdir(parents=True, exist_ok=True)