From 7fd9c3cbec4324e03a272e04993c045d246d200c Mon Sep 17 00:00:00 2001 From: Lars Pastewka Date: Sat, 14 Jan 2023 20:26:14 +0100 Subject: [PATCH] Fix linking against libraries from Meson project on macOS --- meson.build | 1 + mesonpy/__init__.py | 39 ++++++++++++++++++++++------------- mesonpy/_dylib.py | 50 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_wheel.py | 25 +++++++++++++++-------- 4 files changed, 92 insertions(+), 23 deletions(-) create mode 100644 mesonpy/_dylib.py diff --git a/meson.build b/meson.build index 831c72107..2aacc0184 100644 --- a/meson.build +++ b/meson.build @@ -10,6 +10,7 @@ endif py.install_sources( 'mesonpy/__init__.py', 'mesonpy/_compat.py', + 'mesonpy/_dylib.py', 'mesonpy/_editable.py', 'mesonpy/_elf.py', 'mesonpy/_introspection.py', diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index fe669a02d..85c880853 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -44,6 +44,7 @@ import mesonpy._compat +import mesonpy._dylib import mesonpy._elf import mesonpy._introspection import mesonpy._tags @@ -509,19 +510,28 @@ def _install_path( arcname = os.path.join(destination, os.path.relpath(path, origin).replace(os.path.sep, '/')) wheel_file.write(path, arcname) else: - if self._has_internal_libs and platform.system() == 'Linux': - # add .mesonpy.libs to the RPATH of ELF files - if self._is_native(os.fspath(origin)): - # copy ELF to our working directory to avoid Meson having to regenerate the file - new_origin = self._libs_build_dir / pathlib.Path(origin).relative_to(self._build_dir) - os.makedirs(new_origin.parent, exist_ok=True) - shutil.copy2(origin, new_origin) - origin = new_origin - # add our in-wheel libs folder to the RPATH - elf = mesonpy._elf.ELF(origin) - libdir_path = f'$ORIGIN/{os.path.relpath(f".{self._project.name}.mesonpy.libs", destination.parent)}' - if libdir_path not in elf.rpath: - elf.rpath = [*elf.rpath, libdir_path] + if self._has_internal_libs: + if platform.system() == 'Linux' or platform.system() == 'Darwin': + # add .mesonpy.libs to the RPATH of ELF files + if self._is_native(os.fspath(origin)): + # copy ELF to our working directory to avoid Meson having to regenerate the file + new_origin = self._libs_build_dir / pathlib.Path(origin).relative_to(self._build_dir) + os.makedirs(new_origin.parent, exist_ok=True) + shutil.copy2(origin, new_origin) + origin = new_origin + # add our in-wheel libs folder to the RPATH + if platform.system() == 'Linux': + elf = mesonpy._elf.ELF(origin) + libdir_path = \ + f'$ORIGIN/{os.path.relpath(f".{self._project.name}.mesonpy.libs", destination.parent)}' + if libdir_path not in elf.rpath: + elf.rpath = [*elf.rpath, libdir_path] + else: # 'Darwin' + dylib = mesonpy._dylib.Dylib(origin) + libdir_path = \ + f'@loader_path/{os.path.relpath(f".{self._project.name}.mesonpy.libs", destination.parent)}' + if libdir_path not in dylib.rpath: + dylib.rpath = [*dylib.rpath, libdir_path] wheel_file.write(origin, location) @@ -558,7 +568,8 @@ def build(self, directory: Path) -> pathlib.Path: # install bundled libraries for destination, origin in self._wheel_files['mesonpy-libs']: - assert platform.system() == 'Linux', 'Bundling libraries in wheel is currently only supported in POSIX!' + assert platform.system() == 'Linux' or platform.system() == 'Darwin', \ + 'Bundling libraries in wheel is currently only supported in POSIX!' destination = pathlib.Path(f'.{self._project.name}.mesonpy.libs', destination) self._install_path(whl, counter, origin, destination) diff --git a/mesonpy/_dylib.py b/mesonpy/_dylib.py new file mode 100644 index 000000000..9c401d3f9 --- /dev/null +++ b/mesonpy/_dylib.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2023 Lars Pastewka + +import os +import subprocess + +from typing import Optional + +from mesonpy._compat import Collection, Path + + +# This class is modeled after the ELF class in _elf.py +class Dylib: + def __init__(self, path: Path) -> None: + self._path = os.fspath(path) + self._rpath: Optional[Collection[str]] = None + self._needed: Optional[Collection[str]] = None + + def _otool(self, *args: str) -> str: + return subprocess.check_output(['otool', *args, self._path], stderr=subprocess.STDOUT).decode() + + def _install_name_tool(self, *args: str) -> str: + return subprocess.check_output(['install_name_tool', *args, self._path], stderr=subprocess.STDOUT).decode() + + @property + def rpath(self) -> Collection[str]: + if self._rpath is None: + self._rpath = [] + # Run otool -l to get the load commands + otool_output = self._otool('-l').strip() + # Manually parse the output for LC_RPATH + rpath_tag = False + for line in [x.split() for x in otool_output.split('\n')]: + if line == ['cmd', 'LC_RPATH']: + rpath_tag = True + elif len(line) >= 2 and line[0] == 'path' and rpath_tag: + self._rpath += [line[1]] + rpath_tag = False + return frozenset(self._rpath) + + @rpath.setter + def rpath(self, value: Collection[str]) -> None: + # We clear all LC_RPATH load commands + if self._rpath: + for rpath in self._rpath: + self._install_name_tool('-delete_rpath', rpath) + # We then rewrite the new load commands + for rpath in value: + self._install_name_tool('-add_rpath', rpath) + self._rpath = value diff --git a/tests/test_wheel.py b/tests/test_wheel.py index b3d77c65f..2554eae45 100644 --- a/tests/test_wheel.py +++ b/tests/test_wheel.py @@ -144,7 +144,7 @@ def test_configure_data(wheel_configure_data): } -@pytest.mark.skipif(platform.system() != 'Linux', reason='Unsupported on this platform for now') +@pytest.mark.skipif(platform.system() not in ['Linux', 'Darwin'], reason='Unsupported on this platform for now') def test_local_lib(venv, wheel_link_against_local_lib): venv.pip('install', wheel_link_against_local_lib) output = venv.python('-c', 'import example; print(example.example_sum(1, 2))') @@ -184,25 +184,32 @@ def test_detect_wheel_tag_script(wheel_executable): assert name.group('plat') == PLATFORM -@pytest.mark.skipif(platform.system() != 'Linux', reason='Unsupported on this platform for now') +@pytest.mark.skipif(platform.system() not in ['Linux', 'Darwin'], reason='Unsupported on this platform for now') def test_rpath(wheel_link_against_local_lib, tmp_path): artifact = wheel.wheelfile.WheelFile(wheel_link_against_local_lib) artifact.extractall(tmp_path) - elf = mesonpy._elf.ELF(tmp_path / f'example{EXT_SUFFIX}') - assert '$ORIGIN/.link_against_local_lib.mesonpy.libs' in elf.rpath + if platform.system() == 'Linux': + elf = mesonpy._elf.ELF(tmp_path / f'example{EXT_SUFFIX}') + assert '$ORIGIN/.link_against_local_lib.mesonpy.libs' in elf.rpath + else: # 'Darwin' + dylib = mesonpy._dylib.Dylib(tmp_path / f'example{EXT_SUFFIX}') + assert '@loader_path/.link_against_local_lib.mesonpy.libs' in dylib.rpath -@pytest.mark.skipif(platform.system() != 'Linux', reason='Unsupported on this platform for now') +@pytest.mark.skipif(platform.system() not in ['Linux', 'Darwin'], reason='Unsupported on this platform for now') def test_uneeded_rpath(wheel_purelib_and_platlib, tmp_path): artifact = wheel.wheelfile.WheelFile(wheel_purelib_and_platlib) artifact.extractall(tmp_path) - elf = mesonpy._elf.ELF(tmp_path / f'plat{EXT_SUFFIX}') - if elf.rpath: - # elf.rpath is a frozenset, so iterate over it. An rpath may be + if platform.system() == 'Linux': + shared_lib = mesonpy._elf.ELF(tmp_path / f'plat{EXT_SUFFIX}') + else: # 'Darwin' + shared_lib = mesonpy._dylib.Dylib(tmp_path / f'plat{EXT_SUFFIX}') + if shared_lib.rpath: + # shared_lib.rpath is a frozenset, so iterate over it. An rpath may be # present, e.g. when conda is used (rpath will be /lib/) - for rpath in elf.rpath: + for rpath in shared_lib.rpath: assert 'mesonpy.libs' not in rpath