diff --git a/Changelog.md b/Changelog.md index 5787d673..2e27f0dd 100644 --- a/Changelog.md +++ b/Changelog.md @@ -10,6 +10,19 @@ rules on making a good Changelog. ## [Unreleased] +### Added + +- Use `--require-target-macos-version` or the `MACOSX_DEPLOYMENT_TARGET` + environment variable to ensure that wheels are + compatible with the specified macOS version. #198 + +### Changed + +- Delocate now uses the binaries of the wheel file to determine a more accurate + platform tag, this will rename wheels accordingly. #198 +- `delocate-wheel` is now more strict with platform tags and will no longer allow + a wheel to be incompatible with its own tags. #198 + ## [0.10.7] - 2023-12-12 ### Changed diff --git a/delocate/cmd/delocate_wheel.py b/delocate/cmd/delocate_wheel.py index ebf29188..c0035813 100755 --- a/delocate/cmd/delocate_wheel.py +++ b/delocate/cmd/delocate_wheel.py @@ -12,6 +12,8 @@ from os.path import join as pjoin from typing import List, Optional, Text +from packaging.version import Version + from delocate import delocate_wheel from delocate.cmd.common import ( common_parser, @@ -61,6 +63,12 @@ " (from 'intel', 'i386', 'x86_64', 'i386,x86_64', 'universal2'," " 'x86_64,arm64')", ) +parser.add_argument( + "--require-target-macos-version", + type=Version, + help="Verify if platform tag in wheel name is proper", + default=None, +) def main() -> None: @@ -82,6 +90,15 @@ def main() -> None: else: require_archs = args.require_archs + require_target_macos_version = args.require_target_macos_version + if ( + require_target_macos_version is None + and "MACOSX_DEPLOYMENT_TARGET" in os.environ + ): + require_target_macos_version = Version( + os.environ["MACOSX_DEPLOYMENT_TARGET"] + ) + for wheel in wheels: if multi or args.verbose: print("Fixing: " + wheel) @@ -94,6 +111,7 @@ def main() -> None: out_wheel, lib_sdir=args.lib_sdir, require_archs=require_archs, + require_target_macos_version=require_target_macos_version, **delocate_values(args), ) if args.verbose and len(copied): diff --git a/delocate/delocating.py b/delocate/delocating.py index 38c2fdee..6138f7d5 100644 --- a/delocate/delocating.py +++ b/delocate/delocating.py @@ -1,11 +1,12 @@ """ Routines to copy / relink library dependencies in trees and wheels """ -from __future__ import division, print_function +from __future__ import annotations import functools import logging import os +import re import shutil import warnings from os.path import abspath, basename, dirname, exists, realpath, relpath @@ -17,6 +18,7 @@ Dict, FrozenSet, Iterable, + Iterator, List, Mapping, Optional, @@ -26,6 +28,15 @@ Union, ) +from macholib.mach_o import ( # type: ignore[import-untyped] + CPU_TYPE_NAMES, + LC_BUILD_VERSION, + LC_VERSION_MIN_MACOSX, +) +from macholib.MachO import MachO # type: ignore[import-untyped] +from packaging.utils import parse_wheel_filename +from packaging.version import Version + from .libsana import ( _allow_all, get_rp_stripper, @@ -35,6 +46,7 @@ ) from .tmpdirs import TemporaryDirectory from .tools import ( + _is_macho_file, _remove_absolute_rpaths, dir2zip, find_package_dirs, @@ -51,6 +63,8 @@ # Prefix for install_name_id of copied libraries DLC_PREFIX = "/DLC/" +_PLATFORM_REGEXP = re.compile(r"macosx_(\d+)_(\d+)_(\w+)") + class DelocationError(Exception): pass @@ -578,6 +592,281 @@ def _make_install_name_ids_unique( validate_signature(lib) +def _get_macos_min_version(dylib_path: Path) -> Iterator[tuple[str, Version]]: + """Get the minimum macOS version from a dylib file. + + Parameters + ---------- + dylib_path : Path + The path to the dylib file. + + Yields + ------ + str + The CPU type. + Version + The minimum macOS version. + """ + if not _is_macho_file(dylib_path): + return + for header in MachO(dylib_path).headers: + for cmd in header.commands: + if cmd[0].cmd == LC_BUILD_VERSION: + version = cmd[1].minos + elif cmd[0].cmd == LC_VERSION_MIN_MACOSX: + version = cmd[1].version + else: + continue + yield ( + CPU_TYPE_NAMES.get(header.header.cputype, "unknown"), + Version(f"{version >> 16 & 0xFF}.{version >> 8 & 0xFF}"), + ) + break + + +def _get_archs_and_version_from_wheel_name( + wheel_name: str, +) -> dict[str, Version]: + """ + Get the architecture and minimum macOS version from the wheel name. + + Parameters + ---------- + wheel_name : str + The name of the wheel. + + Returns + ------- + Dict[str, Version] + A dictionary containing the architecture and minimum macOS version + for each architecture in the wheel name. + """ + platform_tag_set = parse_wheel_filename(wheel_name)[-1] + platform_requirements = {} + for platform_tag in platform_tag_set: + match = _PLATFORM_REGEXP.match(platform_tag.platform) + if match is None: + raise ValueError(f"Invalid platform tag: {platform_tag.platform}") + major, minor, arch = match.groups() + platform_requirements[arch] = Version(f"{major}.{minor}") + return platform_requirements + + +def _get_problematic_libs( + required_version: Optional[Version], + version_lib_dict: Dict[Version, List[Path]], + arch: str, +) -> set[tuple[Path, Version]]: + """ + Filter libraries that require more modern macOS + version than the provided one. + + Parameters + ---------- + required_version : Version or None + The expected minimum macOS version. + If None, return an empty set. + version_lib_dict : Dict[Version, List[Path]] + A dictionary containing mapping from minimum macOS version to libraries + that require that version. + arch : str + The architecture of the libraries. For proper handle arm64 case + + Returns + ------- + set[tuple[Path, Version]] + A set of libraries that require a more modern macOS version than the + provided one. + """ + if required_version is None: + return set() + if arch == "arm64" and required_version < Version("11.0"): + # All arm64 libraries require macOS at least 11.0, + # So even if user provide lower deployment target, + # for example, by setting environment variable + # MACOSX_DEPLOYMENT_TARGET=10.15 + # the binaries still will be compatible with 11.0+ only. + # So there is no need to check for compatible with older macOS versions + required_version = Version("11.0") + bad_libraries: set[tuple[Path, Version]] = set() + for library_version, libraries in version_lib_dict.items(): + if library_version > required_version: + bad_libraries.update((path, library_version) for path in libraries) + return bad_libraries + + +def _calculate_minimum_wheel_name( + wheel_name: str, + wheel_dir: Path, + require_target_macos_version: Optional[Version], +) -> tuple[str, set[tuple[Path, Version]]]: + """ + Update wheel name platform tag, based on the architecture + of the libraries in the wheel and actual platform tag. + + Parameters + ---------- + wheel_name : str + The name of the wheel. + wheel_dir : Path + The directory of the unpacked wheel. + require_target_macos_version : Version or None + The target macOS version that the wheel should be compatible with. + + Returns + ------- + str + The updated wheel name. + set[tuple[Path, Version]] + A set of libraries that require a more modern macOS version than the + provided one. + """ + # get platform tag from wheel name using packaging + if wheel_name.endswith("any.whl"): + # universal wheel, no need to update the platform tag + return wheel_name, set() + arch_version = _get_archs_and_version_from_wheel_name(wheel_name) + # get the architecture and minimum macOS version from the libraries + # in the wheel + version_info_dict: Dict[str, Dict[Version, List[Path]]] = {} + + for lib in wheel_dir.glob("**/*"): + for arch, version in _get_macos_min_version(lib): + version_info_dict.setdefault(arch.lower(), {}).setdefault( + version, [] + ).append(lib) + version_dkt = { + arch: max(version) for arch, version in version_info_dict.items() + } + + problematic_libs: set[tuple[Path, Version]] = set() + + try: + for arch, version in list(arch_version.items()): + if arch == "universal2": + if version_dkt["arm64"] == Version("11.0"): + arch_version["universal2"] = max( + version, version_dkt["x86_64"] + ) + else: + arch_version["universal2"] = max( + version, version_dkt["arm64"], version_dkt["x86_64"] + ) + problematic_libs.update( + _get_problematic_libs( + require_target_macos_version, + version_info_dict["arm64"], + "arm64", + ) + ) + problematic_libs.update( + _get_problematic_libs( + require_target_macos_version, + version_info_dict["x86_64"], + "x86_64", + ) + ) + elif arch == "universal": + arch_version["universal"] = max( + version, version_dkt["i386"], version_dkt["x86_64"] + ) + problematic_libs.update( + _get_problematic_libs( + require_target_macos_version, + version_info_dict["i386"], + "i386", + ), + _get_problematic_libs( + require_target_macos_version, + version_info_dict["x86_64"], + "x86_64", + ), + ) + else: + arch_version[arch] = max(version, version_dkt[arch]) + problematic_libs.update( + _get_problematic_libs( + require_target_macos_version, + version_info_dict[arch], + arch, + ) + ) + except KeyError as e: + raise DelocationError( + f"Failed to find any binary with the required architecture: {e}" + ) from e + prefix = wheel_name.rsplit("-", 1)[0] + platform_tag = ".".join( + f"macosx_{version.major}_{version.minor}_{arch}" + for arch, version in arch_version.items() + ) + return f"{prefix}-{platform_tag}.whl", problematic_libs + + +def _check_and_update_wheel_name( + wheel_path: Path, + wheel_dir: Path, + require_target_macos_version: Optional[Version], +) -> Path: + """ + Based on curren wheel name and binary files in the wheel, + determine the minimum platform tag and update the wheel name if needed. + + Parameters + ---------- + wheel_path : Path + The path to the wheel. + wheel_dir : Path + The directory of the unpacked wheel. + require_target_macos_version : Version or None + The target macOS version that the wheel should be compatible with. + If provided and the wheel does not satisfy the target MacOS version, + raise an error. + """ + wheel_name = os.path.basename(wheel_path) + + new_name, problematic_files = _calculate_minimum_wheel_name( + wheel_name, Path(wheel_dir), require_target_macos_version + ) + if problematic_files: + problematic_files_str = "\n".join( + f"{lib_path} has a minimum target of {lib_macos_version}" + for lib_path, lib_macos_version in problematic_files + ) + raise DelocationError( + "Library dependencies do not satisfy target MacOS" + f" version {require_target_macos_version}:\n" + f"{problematic_files_str}" + ) + if new_name != wheel_name: + wheel_path = wheel_path.parent / new_name + return wheel_path + + +def _update_wheelfile(wheel_dir: Path, wheel_name: str) -> None: + """ + Update the WHEEL file in the wheel directory with the new platform tag. + + Parameters + ---------- + wheel_dir : Path + The directory of the unpacked wheel. + wheel_name : str + The name of the wheel. + Used for determining the new platform tag. + """ + platform_tag_set = parse_wheel_filename(wheel_name)[-1] + (file_path,) = wheel_dir.glob("*.dist-info/WHEEL") + with file_path.open(encoding="utf-8") as f: + lines = f.readlines() + with file_path.open("w", encoding="utf-8") as f: + for line in lines: + if line.startswith("Tag:"): + f.write(f"Tag: {'.'.join(str(x) for x in platform_tag_set)}\n") + else: + f.write(line) + + def delocate_wheel( in_wheel: str, out_wheel: Optional[str] = None, @@ -590,6 +879,7 @@ def delocate_wheel( executable_path: Optional[str] = None, ignore_missing: bool = False, sanitize_rpaths: bool = False, + require_target_macos_version: Optional[Version] = None, ) -> Dict[str, Dict[str, str]]: """Update wheel by copying required libraries to `lib_sdir` in wheel @@ -637,6 +927,8 @@ def delocate_wheel( Continue even if missing dependencies are detected. sanitize_rpaths : bool, default=False, keyword-only If True, absolute paths in rpaths of binaries are removed. + require_target_macos_version : None or Version, optional, keyword-only + If provided, the minimum macOS version that the wheel should support. Returns ------- @@ -662,6 +954,7 @@ def delocate_wheel( else: out_wheel = abspath(out_wheel) in_place = in_wheel == out_wheel + remove_old = in_place with TemporaryDirectory() as tmpdir: wheel_dir = realpath(pjoin(tmpdir, "wheel")) zip2dir(in_wheel, wheel_dir) @@ -704,8 +997,18 @@ def delocate_wheel( install_id_prefix=DLC_PREFIX + relpath(lib_sdir, wheel_dir), ) rewrite_record(wheel_dir) + out_wheel_ = Path(out_wheel) + out_wheel_fixed = _check_and_update_wheel_name( + out_wheel_, Path(wheel_dir), require_target_macos_version + ) + if out_wheel_fixed != out_wheel_: + out_wheel_ = out_wheel_fixed + in_place = False + _update_wheelfile(Path(wheel_dir), out_wheel_.name) if len(copied_libs) or not in_place: - dir2zip(wheel_dir, out_wheel) + if remove_old: + os.remove(in_wheel) + dir2zip(wheel_dir, out_wheel_) return stripped_lib_dict(copied_libs, wheel_dir + os.path.sep) diff --git a/delocate/tests/conftest.py b/delocate/tests/conftest.py index b57ce29b..1e408931 100644 --- a/delocate/tests/conftest.py +++ b/delocate/tests/conftest.py @@ -13,7 +13,9 @@ @pytest.fixture def plat_wheel(tmp_path: Path) -> Iterator[PlatWheel]: """Return a modified platform wheel for testing.""" - plat_wheel_tmp = str(tmp_path / "plat-wheel.whl") + plat_wheel_tmp = str( + tmp_path / "plat-1.0-cp311-cp311-macosx_10_9_x86_64.whl" + ) stray_lib: str = STRAY_LIB_DEP with InWheelCtx(PLAT_WHEEL, plat_wheel_tmp): diff --git a/delocate/tests/data/liba_12.dylib b/delocate/tests/data/liba_12.dylib new file mode 100755 index 00000000..d07bf701 Binary files /dev/null and b/delocate/tests/data/liba_12.dylib differ diff --git a/delocate/tests/data/libam1_12.dylib b/delocate/tests/data/libam1_12.dylib new file mode 100755 index 00000000..cf849eda Binary files /dev/null and b/delocate/tests/data/libam1_12.dylib differ diff --git a/delocate/tests/data/libc_12.dylib b/delocate/tests/data/libc_12.dylib new file mode 100755 index 00000000..6eedbd72 Binary files /dev/null and b/delocate/tests/data/libc_12.dylib differ diff --git a/delocate/tests/data/make_libs.sh b/delocate/tests/data/make_libs.sh index 22000230..be943a73 100755 --- a/delocate/tests/data/make_libs.sh +++ b/delocate/tests/data/make_libs.sh @@ -30,6 +30,7 @@ void c(); int main(int, char**) { c(); return 0; } EOF + CXX_64="$CXX -arch x86_64" CXX_M1="$CXX -arch arm64" @@ -45,6 +46,7 @@ fi $CXX_64 -o liba.dylib -dynamiclib a.cc $CXX_M1 -o libam1.dylib -dynamiclib a.cc +MACOSX_DEPLOYMENT_TARGET=12.0 $CXX_M1 -o libam1_12.dylib -dynamiclib a.cc $CXX_64 -o a.o -c a.cc ar rcs liba.a a.o $CXX_64 -o libb.dylib -dynamiclib b.cc -L. -la @@ -52,6 +54,8 @@ $CXX_64 -o libb.dylib -dynamiclib b.cc -L. -la $CXX_64 -o libc.dylib -dynamiclib c.cc -L. -la -lb $CXX_64 -o test-lib d.cc -L. -lc +MACOSX_DEPLOYMENT_TARGET=12.0 $CXX_64 -o libc_12.dylib -dynamiclib c.cc -L. -la -lb + # Make a dual-arch library lipo -create liba.dylib libam1.dylib -output liba_both.dylib diff --git a/delocate/tests/test_delocating.py b/delocate/tests/test_delocating.py index 8f46dc57..b5139527 100644 --- a/delocate/tests/test_delocating.py +++ b/delocate/tests/test_delocating.py @@ -12,9 +12,12 @@ from typing import Any, Callable, Dict, Iterable, List, Set, Text, Tuple import pytest +from packaging.utils import InvalidWheelFilename +from packaging.version import Version from ..delocating import ( DelocationError, + _get_archs_and_version_from_wheel_name, bads_report, check_archs, copy_recurse, @@ -705,3 +708,24 @@ def test_dyld_fallback_library_path_loses_to_basename() -> None: # tmpdir can end up in /var, and that can be symlinked to # /private/var, so we'll use realpath to resolve the two assert_equal(predicted_lib_location, os.path.realpath(libb)) + + +def test_get_archs_and_version_from_wheel_name() -> None: + # Test getting archs and version from wheel name + assert _get_archs_and_version_from_wheel_name( + "foo-1.0-py310-abi3-macosx_10_9_universal2.whl" + ) == { + "universal2": Version("10.9"), + } + assert _get_archs_and_version_from_wheel_name( + "foo-1.0-py310-abi3-macosx_12_0_arm64.whl" + ) == { + "arm64": Version("12.0"), + } + with pytest.raises(InvalidWheelFilename, match="Invalid wheel filename"): + _get_archs_and_version_from_wheel_name("foo.whl") + + with pytest.raises(ValueError, match="Invalid platform tag"): + _get_archs_and_version_from_wheel_name( + "foo-1.0-py310-abi3-manylinux1.whl" + ) diff --git a/delocate/tests/test_scripts.py b/delocate/tests/test_scripts.py index ab0fe025..e8650db9 100644 --- a/delocate/tests/test_scripts.py +++ b/delocate/tests/test_scripts.py @@ -253,16 +253,17 @@ def test_wheel(script_runner: ScriptRunner) -> None: ) _check_wheel(Path("fixed", basename(fixed_wheel)), ".dylibs") # More than one wheel - shutil.copy2(fixed_wheel, "wheel_copy.ext") + copy_name = "fakepkg1_copy-1.0-cp36-abi3-macosx_10_9_universal2.whl" + shutil.copy2(fixed_wheel, copy_name) result = script_runner.run( - ["delocate-wheel", "-w", "fixed2", fixed_wheel, "wheel_copy.ext"], + ["delocate-wheel", "-w", "fixed2", fixed_wheel, copy_name], check=True, ) assert _proc_lines(result.stdout) == [ - "Fixing: " + name for name in (fixed_wheel, "wheel_copy.ext") + "Fixing: " + name for name in (fixed_wheel, copy_name) ] _check_wheel(Path("fixed2", basename(fixed_wheel)), ".dylibs") - _check_wheel(Path("fixed2", "wheel_copy.ext"), ".dylibs") + _check_wheel(Path("fixed2", copy_name), ".dylibs") # Verbose - single wheel result = script_runner.run( @@ -283,12 +284,12 @@ def test_wheel(script_runner: ScriptRunner) -> None: "--wheel-dir", "fixed4", fixed_wheel, - "wheel_copy.ext", + copy_name, ], check=True, ) wheel_lines2 = [ - "Fixing: wheel_copy.ext", + f"Fixing: {copy_name}", "Copied to package .dylibs directory:", stray_lib, ] @@ -298,20 +299,24 @@ def test_wheel(script_runner: ScriptRunner) -> None: @pytest.mark.xfail( # type: ignore[misc] sys.platform != "darwin", reason="Needs macOS linkage." ) -def test_fix_wheel_dylibs(script_runner: ScriptRunner) -> None: +def test_fix_wheel_dylibs(script_runner: ScriptRunner, tmp_path: Path) -> None: # Check default and non-default search for dynamic libraries - with InTemporaryDirectory() as tmpdir: - # Default in-place fix - fixed_wheel, stray_lib = _fixed_wheel(tmpdir) - _rename_module(fixed_wheel, "module.other", "test.whl") - shutil.copyfile("test.whl", "test2.whl") - # Default is to look in all files and therefore fix - script_runner.run(["delocate-wheel", "test.whl"], check=True) - _check_wheel("test.whl", ".dylibs") - # Can turn this off to only look in dynamic lib exts - script_runner.run(["delocate-wheel", "test2.whl", "-d"], check=True) - with InWheel("test2.whl"): # No fix - assert not Path("fakepkg1", ".dylibs").exists() + fixed_wheel, stray_lib = _fixed_wheel(tmp_path) + test1_name = ( + tmp_path / "fakepkg1_test-1.0-cp36-abi3-macosx_10_9_universal2.whl" + ) + test2_name = ( + tmp_path / "fakepkg1_test2-1.0-cp36-abi3-macosx_10_9_universal2.whl" + ) + _rename_module(fixed_wheel, "module.other", test1_name) + shutil.copyfile(test1_name, test2_name) + # Default is to look in all files and therefore fix + script_runner.run(["delocate-wheel", test1_name], check=True) + _check_wheel(test1_name, ".dylibs") + # Can turn this off to only look in dynamic lib exts + script_runner.run(["delocate-wheel", test2_name, "-d"], check=True) + with InWheel(test2_name): # No fix + assert not Path("fakepkg1", ".dylibs").exists() @pytest.mark.xfail( # type: ignore[misc] @@ -577,23 +582,31 @@ def test_add_platforms(script_runner: ScriptRunner) -> None: @pytest.mark.xfail(sys.platform != "darwin", reason="Needs macOS linkage.") -def test_fix_wheel_with_excluded_dylibs(script_runner: ScriptRunner): - with InTemporaryDirectory() as tmpdir: - fixed_wheel, stray_lib = _fixed_wheel(tmpdir) - _rename_module(fixed_wheel, "module.other", "test.whl") - shutil.copyfile("test.whl", "test2.whl") - # We exclude the stray library so it shouldn't be present in the wheel - result = script_runner.run( - ["delocate-wheel", "-vv", "-e", "extfunc", "test.whl"], check=True - ) - assert "libextfunc.dylib excluded" in result.stderr - with InWheel("test.whl"): - assert not Path("plat_pkg/fakepkg1/.dylibs").exists() - # We exclude a library that does not exist so we should behave normally - script_runner.run( - ["delocate-wheel", "-e", "doesnotexist", "test2.whl"], check=True - ) - _check_wheel("test2.whl", ".dylibs") +def test_fix_wheel_with_excluded_dylibs( + script_runner: ScriptRunner, tmp_path: Path +) -> None: + fixed_wheel, stray_lib = _fixed_wheel(tmp_path) + test1_name = ( + tmp_path / "fakepkg1_test-1.0-cp36-abi3-macosx_10_9_universal2.whl" + ) + test2_name = ( + tmp_path / "fakepkg1_test2-1.0-cp36-abi3-macosx_10_9_universal2.whl" + ) + + _rename_module(fixed_wheel, "module.other", test1_name) + shutil.copyfile(test1_name, test2_name) + # We exclude the stray library so it shouldn't be present in the wheel + result = script_runner.run( + ["delocate-wheel", "-vv", "-e", "extfunc", test1_name], check=True + ) + assert "libextfunc.dylib excluded" in result.stderr + with InWheel(test1_name): + assert not Path("plat_pkg/fakepkg1/.dylibs").exists() + # We exclude a library that does not exist so we should behave normally + script_runner.run( + ["delocate-wheel", "-e", "doesnotexist", test2_name], check=True + ) + _check_wheel(test2_name, ".dylibs") @pytest.mark.xfail( # type: ignore[misc] @@ -605,8 +618,7 @@ def test_sanitize_command(tmp_path: Path, script_runner: ScriptRunner) -> None: assert "libs/" in set( get_rpaths(str(unpack_dir / "fakepkg/subpkg/module2.abi3.so")) ) - - rpath_wheel = tmp_path / "example.whl" + rpath_wheel = tmp_path / "example-1.0-cp37-abi3-macosx_10_9_x86_64.whl" shutil.copyfile(RPATH_WHEEL, rpath_wheel) libs_path = tmp_path / "libs" libs_path.mkdir() @@ -643,18 +655,180 @@ def test_glob( assert "*.whl" not in result.stdout assert not Path(tmp_path, "*.whl").exists() - # Delocate literal file "*.whl" instead of expanding glob - shutil.copyfile(plat_wheel.whl, tmp_path / "*.whl") - result = script_runner.run( - ["delocate-wheel", "*.whl", "-v"], check=True, cwd=tmp_path - ) - assert Path(plat_wheel.whl).name not in result.stdout - assert "*.whl" in result.stdout - Path(plat_wheel.whl).unlink() - Path(tmp_path, "*.whl").unlink() result = script_runner.run(["delocate-wheel", "*.whl"], cwd=tmp_path) assert result.returncode == 1 assert "FileNotFoundError:" in result.stderr script_runner.run(["delocate-path", "*/"], check=True, cwd=tmp_path) + + +@pytest.mark.xfail( # type: ignore[misc] + sys.platform != "darwin", reason="Needs macOS linkage." +) +def test_delocate_wheel_fix_name( + plat_wheel: PlatWheel, script_runner: ScriptRunner, tmp_path: Path +) -> None: + zip2dir(plat_wheel.whl, tmp_path / "plat") + shutil.copy( + DATA_PATH / "liba_12.dylib", tmp_path / "plat/fakepkg1/liba_12.dylib" + ) + dir2zip(tmp_path / "plat", plat_wheel.whl) + script_runner.run( + ["delocate-wheel", plat_wheel.whl], check=True, cwd=tmp_path + ) + assert (tmp_path / "plat-1.0-cp311-cp311-macosx_12_0_x86_64.whl").exists() + assert not Path(plat_wheel.whl).exists() + with InWheel( + tmp_path / "plat-1.0-cp311-cp311-macosx_12_0_x86_64.whl" + ) as wheel: + with open(pjoin(wheel, "fakepkg1-1.0.dist-info", "WHEEL")) as f: + assert "macosx_12_0_x86_64" in f.read() + + +@pytest.mark.xfail( # type: ignore[misc] + sys.platform != "darwin", reason="Needs macOS linkage." +) +def test_delocate_wheel_verify_name( + plat_wheel: PlatWheel, script_runner: ScriptRunner, tmp_path: Path +) -> None: + zip2dir(plat_wheel.whl, tmp_path / "plat") + whl_10_6 = tmp_path / "plat-1.0-cp311-cp311-macosx_10_6_x86_64.whl" + dir2zip(tmp_path / "plat", whl_10_6) + result = script_runner.run( + ["delocate-wheel", whl_10_6, "--require-target-macos-version", "10.6"], + check=False, + cwd=tmp_path, + print_result=False, + ) + assert result.returncode != 0 + assert "Library dependencies do not satisfy target MacOS" in result.stderr + assert "module2.abi3.so has a minimum target of 10.9" in result.stderr + + +@pytest.mark.xfail( # type: ignore[misc] + sys.platform != "darwin", reason="Needs macOS linkage." +) +def test_delocate_wheel_verify_name_universal2_ok( + plat_wheel: PlatWheel, script_runner: ScriptRunner, tmp_path: Path +) -> None: + zip2dir(plat_wheel.whl, tmp_path / "plat") + shutil.copy( + DATA_PATH / "libam1.dylib", tmp_path / "plat/fakepkg1/libam1.dylib" + ) + whl_10_9 = tmp_path / "plat-1.0-cp311-cp311-macosx_10_9_universal2.whl" + dir2zip(tmp_path / "plat", whl_10_9) + script_runner.run( + ["delocate-wheel", whl_10_9, "--require-target-macos-version", "10.9"], + check=True, + cwd=tmp_path, + ) + + +@pytest.mark.xfail( # type: ignore[misc] + sys.platform != "darwin", reason="Needs macOS linkage." +) +def test_delocate_wheel_verify_name_universal_ok( + plat_wheel: PlatWheel, script_runner: ScriptRunner, tmp_path: Path +) -> None: + zip2dir(plat_wheel.whl, tmp_path / "plat") + shutil.copy( + DATA_PATH / "np-1.6.0_intel_lib__compiled_base.so", + tmp_path / "plat/fakepkg1/np-1.6.0_intel_lib__compiled_base.so", + ) + whl_10_9 = tmp_path / "plat-1.0-cp311-cp311-macosx_10_9_universal.whl" + dir2zip(tmp_path / "plat", whl_10_9) + script_runner.run( + [ + "delocate-wheel", + whl_10_9, + "--require-target-macos-version", + "10.9", + "--ignore-missing-dependencies", + ], + check=True, + cwd=tmp_path, + ) + + +@pytest.mark.xfail( # type: ignore[misc] + sys.platform != "darwin", reason="Needs macOS linkage." +) +def test_delocate_wheel_missing_architecture( + plat_wheel: PlatWheel, + script_runner: ScriptRunner, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + shutil.copy( + plat_wheel.whl, + tmp_path / "plat2-1.0-cp311-cp311-macosx_10_9_universal.whl", + ) + result = script_runner.run( + [ + "delocate-wheel", + tmp_path / "plat2-1.0-cp311-cp311-macosx_10_9_universal.whl", + ], + check=False, + cwd=tmp_path, + ) + assert result.returncode != 0 + assert ( + "Failed to find any binary with the required architecture: 'i386'" + in result.stderr + ) + + +@pytest.mark.xfail( # type: ignore[misc] + sys.platform != "darwin", reason="Needs macOS linkage." +) +def test_delocate_wheel_verify_name_universal2_verify_crash( + plat_wheel: PlatWheel, + script_runner: ScriptRunner, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + zip2dir(plat_wheel.whl, tmp_path / "plat") + shutil.copy( + DATA_PATH / "libam1_12.dylib", + tmp_path / "plat" / "fakepkg1" / "libam1.dylib", + ) + whl_10_9 = tmp_path / "plat2-1.0-cp311-cp311-macosx_10_9_universal2.whl" + dir2zip(tmp_path / "plat", whl_10_9) + result = script_runner.run( + ["delocate-wheel", whl_10_9, "--require-target-macos-version", "10.9"], + check=False, + cwd=tmp_path, + ) + assert result.returncode != 0 + assert "Library dependencies do not satisfy target MacOS" in result.stderr + assert "libam1.dylib has a minimum target of 12.0" in result.stderr + + +@pytest.mark.xfail( # type: ignore[misc] + sys.platform != "darwin", reason="Needs macOS linkage." +) +def test_delocate_wheel_verify_name_universal2_verify_crash_env_var( + plat_wheel: PlatWheel, + script_runner: ScriptRunner, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + zip2dir(plat_wheel.whl, tmp_path / "plat") + shutil.copy( + DATA_PATH / "libam1_12.dylib", + tmp_path / "plat" / "fakepkg1" / "libam1.dylib", + ) + whl_10_9 = tmp_path / "plat2-1.0-cp311-cp311-macosx_10_9_universal2.whl" + dir2zip(tmp_path / "plat", whl_10_9) + + result = script_runner.run( + ["delocate-wheel", whl_10_9], + check=False, + cwd=tmp_path, + env={"MACOSX_DEPLOYMENT_TARGET": "10.9"}, + ) + assert result.returncode != 0 + assert "Library dependencies do not satisfy target MacOS" in result.stderr + assert "libam1.dylib has a minimum target of 12.0" in result.stderr + assert "module2.abi3.so has a minimum target of 11.0" not in result.stderr diff --git a/delocate/tests/test_wheelies.py b/delocate/tests/test_wheelies.py index aa0067ae..a62ca0e1 100644 --- a/delocate/tests/test_wheelies.py +++ b/delocate/tests/test_wheelies.py @@ -130,19 +130,23 @@ def test_fix_plat() -> None: assert os.listdir(dylibs) == ["libextfunc.dylib"] # New output name fixed_wheel, stray_lib = _fixed_wheel(tmpdir) - assert delocate_wheel(fixed_wheel, "fixed_wheel.ext") == { - _rp(stray_lib): {dep_mod: stray_lib} - } - zip2dir("fixed_wheel.ext", "plat_pkg1") + assert delocate_wheel( + fixed_wheel, "fixed_wheel-1.0-cp39-cp39-macosx_10_9_x86_64.whl" + ) == {_rp(stray_lib): {dep_mod: stray_lib}} + zip2dir("fixed_wheel-1.0-cp39-cp39-macosx_10_9_x86_64.whl", "plat_pkg1") assert exists(pjoin("plat_pkg1", "fakepkg1")) dylibs = pjoin("plat_pkg1", "fakepkg1", ".dylibs") assert exists(dylibs) assert os.listdir(dylibs) == ["libextfunc.dylib"] # Test another lib output directory assert delocate_wheel( - fixed_wheel, "fixed_wheel2.ext", "dylibs_dir" + fixed_wheel, + "fixed_wheel2-1.0-cp39-cp39-macosx_10_9_x86_64.whl", + "dylibs_dir", ) == {_rp(stray_lib): {dep_mod: stray_lib}} - zip2dir("fixed_wheel2.ext", "plat_pkg2") + zip2dir( + "fixed_wheel2-1.0-cp39-cp39-macosx_10_9_x86_64.whl", "plat_pkg2" + ) assert exists(pjoin("plat_pkg2", "fakepkg1")) dylibs = pjoin("plat_pkg2", "fakepkg1", "dylibs_dir") assert exists(dylibs) @@ -202,21 +206,35 @@ def test_fix_plat_dylibs(): # Check default and non-default searches for dylibs with InTemporaryDirectory() as tmpdir: fixed_wheel, stray_lib = _fixed_wheel(tmpdir) - _rename_module(fixed_wheel, "module.other", "test.whl") + _rename_module( + fixed_wheel, + "module.other", + "fixed_wheel-1.0-cp39-cp39-macosx_10_9_x86_64.whl", + ) # With dylibs-only - only analyze files with exts '.dylib', '.so' assert_equal( - delocate_wheel("test.whl", lib_filt_func="dylibs-only"), {} + delocate_wheel( + "fixed_wheel-1.0-cp39-cp39-macosx_10_9_x86_64.whl", + lib_filt_func="dylibs-only", + ), + {}, ) # With func that doesn't find the module def func(fn): return fn.endswith(".so") - assert_equal(delocate_wheel("test.whl", lib_filt_func=func), {}) + assert_equal( + delocate_wheel( + "fixed_wheel-1.0-cp39-cp39-macosx_10_9_x86_64.whl", + lib_filt_func=func, + ), + {}, + ) # Default - looks in every file dep_mod = pjoin("fakepkg1", "subpkg", "module.other") assert_equal( - delocate_wheel("test.whl"), + delocate_wheel("fixed_wheel-1.0-cp39-cp39-macosx_10_9_x86_64.whl"), {realpath(stray_lib): {dep_mod: stray_lib}}, ) @@ -269,6 +287,9 @@ def _fix_break_fix(arch_): _fixed_wheel(tmpdir) _thin_lib(stray_lib, arch_) _thin_mod(fixed_wheel, arch_) + new_name = fixed_wheel.replace("universal2", arch_) + shutil.move(fixed_wheel, new_name) + return new_name for arch in ("x86_64", "arm64"): # OK unless we check @@ -283,9 +304,9 @@ def _fix_break_fix(arch_): DelocationError, delocate_wheel, fixed_wheel, require_archs=() ) # We can fix again by thinning the module too - _fix_break_fix(arch) + fixed_wheel2 = _fix_break_fix(arch) assert_equal( - delocate_wheel(fixed_wheel, require_archs=()), + delocate_wheel(fixed_wheel2, require_archs=()), {realpath(stray_lib): {dep_mod: stray_lib}}, ) # But if we require the arch we don't have, it breaks @@ -294,11 +315,11 @@ def _fix_break_fix(arch_): ARCH_BOTH, ARCH_BOTH.difference([arch]), ): - _fix_break_fix(arch) + fixed_wheel3 = _fix_break_fix(arch) assert_raises( DelocationError, delocate_wheel, - fixed_wheel, + fixed_wheel3, require_archs=req_arch, ) # Can be verbose (we won't check output though) @@ -366,9 +387,14 @@ def test_fix_rpath(): }, } - assert delocate_wheel(RPATH_WHEEL, "tmp.whl") == stray_libs + assert ( + delocate_wheel( + RPATH_WHEEL, "out-1.0-cp39-cp39-macosx_10_9_x86_64.whl" + ) + == stray_libs + ) - with InWheel("tmp.whl"): + with InWheel("out-1.0-cp39-cp39-macosx_10_9_x86_64.whl"): check_call( [ "codesign", @@ -386,7 +412,9 @@ def ignore_libextfunc(path: str) -> bool: assert ( delocate_wheel( - RPATH_WHEEL, "tmp.whl", lib_filt_func=ignore_libextfunc + RPATH_WHEEL, + "tmp-1.0-cp39-cp39-macosx_10_9_x86_64.whl", + lib_filt_func=ignore_libextfunc, ) == {} ) @@ -402,7 +430,9 @@ def ignore_libextfunc2(path: str) -> bool: assert ( delocate_wheel( - RPATH_WHEEL, "tmp.whl", lib_filt_func=ignore_libextfunc2 + RPATH_WHEEL, + "tmp-1.0-cp39-cp39-macosx_10_9_x86_64.whl", + lib_filt_func=ignore_libextfunc2, ) == stray_libs_only_direct ) @@ -424,8 +454,13 @@ def test_fix_toplevel() -> None: realpath("libs/libextfunc2_rpath.dylib"): {dep_mod: dep_path}, } - assert delocate_wheel(TOPLEVEL_WHEEL, "out.whl") == stray_libs - with InWheel("out.whl") as wheel_path: + assert ( + delocate_wheel( + TOPLEVEL_WHEEL, "out-1.0-cp39-cp39-macosx_10_9_x86_64.whl" + ) + == stray_libs + ) + with InWheel("out-1.0-cp39-cp39-macosx_10_9_x86_64.whl") as wheel_path: assert "fakepkg_toplevel.dylibs" in os.listdir(wheel_path) @@ -443,7 +478,12 @@ def test_fix_namespace() -> None: realpath("libs/libextfunc2_rpath.dylib"): {dep_mod: dep_path}, } - assert delocate_wheel(NAMESPACE_WHEEL, "out.whl") == stray_libs + assert ( + delocate_wheel( + NAMESPACE_WHEEL, "out-1.0-cp39-cp39-macosx_10_9_x86_64.whl" + ) + == stray_libs + ) def test_source_date_epoch() -> None: diff --git a/delocate/tools.py b/delocate/tools.py index 4dd635e5..8e1fb0fc 100644 --- a/delocate/tools.py +++ b/delocate/tools.py @@ -168,7 +168,7 @@ def _run( ) -def _is_macho_file(filename: str) -> bool: +def _is_macho_file(filename: str | os.PathLike[str]) -> bool: """Return True if file at `filename` begins with Mach-O magic number.""" try: with open(filename, "rb") as f: @@ -178,6 +178,8 @@ def _is_macho_file(filename: str) -> bool: return False except FileNotFoundError: return False + except IsADirectoryError: + return False def unique_by_index(sequence): diff --git a/pyproject.toml b/pyproject.toml index 70831ba0..5815f141 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "machomachomangler; sys_platform == 'win32'", "packaging>=20.9", "typing_extensions", + "macholib" ] classifiers = [ "Development Status :: 5 - Production/Stable",