diff --git a/meson.build b/meson.build index d6d29f353..d44a7f46b 100644 --- a/meson.build +++ b/meson.build @@ -9,9 +9,8 @@ py = import('python').find_installation() py.install_sources( 'mesonpy/__init__.py', 'mesonpy/_compat.py', - 'mesonpy/_dylib.py', 'mesonpy/_editable.py', - 'mesonpy/_elf.py', + 'mesonpy/_rpath.py', 'mesonpy/_tags.py', 'mesonpy/_util.py', 'mesonpy/_wheelfile.py', diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index 7973affe4..d6e8d335d 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -46,8 +46,7 @@ import pyproject_metadata import mesonpy._compat -import mesonpy._dylib -import mesonpy._elf +import mesonpy._rpath import mesonpy._tags import mesonpy._util import mesonpy._wheelfile @@ -285,8 +284,6 @@ def __init__( self._build_dir = build_dir self._sources = sources - self._libs_build_dir = self._build_dir / 'mesonpy-wheel-libs' - @cached_property def _wheel_files(self) -> DefaultDict[str, List[Tuple[pathlib.Path, str]]]: return _map_to_wheel(self._sources) @@ -438,49 +435,22 @@ def _is_native(self, file: Union[str, pathlib.Path]) -> bool: return True return False - def _install_path( - self, - wheel_file: mesonpy._wheelfile.WheelFile, - origin: Path, - destination: pathlib.Path, - ) -> None: - """"Install" file or directory into the wheel - and do the necessary processing before doing so. - - Some files might need to be fixed up to set the RPATH to the internal - library directory on Linux wheels for eg. - """ - location = destination.as_posix() + def _install_path(self, wheel_file: mesonpy._wheelfile.WheelFile, origin: Path, destination: pathlib.Path) -> None: + """Add a file to the wheel.""" 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] - elif platform.system() == '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] - else: - # Internal libraries are currently unsupported on this platform - raise NotImplementedError("Bundling libraries in wheel is not supported on platform '{}'" - .format(platform.system())) + if self._is_native(os.fspath(origin)): + # When an executable, libray, or Python extension module is + # dynamically linked to a library built as part of the project, + # Meson adds a library load path to it pointing to the build + # directory, in the form of a relative RPATH entry. meson-python + # relocates the shared libraries to the $project.mesonpy.libs + # folder. Rewrite the RPATH to point to that folder instead. + libspath = os.path.relpath(f'.{self._project.name}.mesonpy.libs', destination.parent) + mesonpy._rpath.fix_rpath(origin, libspath) try: - wheel_file.write(origin, location) + wheel_file.write(origin, destination.as_posix()) except FileNotFoundError: # work around for Meson bug, see https://github.com/mesonbuild/meson/pull/11655 if not os.fspath(origin).endswith('.pdb'): diff --git a/mesonpy/_dylib.py b/mesonpy/_dylib.py deleted file mode 100644 index 222d16ade..000000000 --- a/mesonpy/_dylib.py +++ /dev/null @@ -1,55 +0,0 @@ -# SPDX-License-Identifier: MIT -# SPDX-FileCopyrightText: 2023 Lars Pastewka - -from __future__ import annotations - -import os -import subprocess -import typing - - -if typing.TYPE_CHECKING: - 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/mesonpy/_elf.py b/mesonpy/_elf.py deleted file mode 100644 index b9d8512d7..000000000 --- a/mesonpy/_elf.py +++ /dev/null @@ -1,56 +0,0 @@ -# SPDX-FileCopyrightText: 2021 Filipe LaĆ­ns -# SPDX-FileCopyrightText: 2021 Quansight, LLC -# SPDX-FileCopyrightText: 2022 The meson-python developers -# -# SPDX-License-Identifier: MIT - -from __future__ import annotations - -import os -import subprocess -import typing - - -if typing.TYPE_CHECKING: # pragma: no cover - from typing import Optional - - from mesonpy._compat import Collection, Path - - -class ELF: - def __init__(self, path: Path) -> None: - self._path = os.fspath(path) - self._rpath: Optional[Collection[str]] = None - self._needed: Optional[Collection[str]] = None - - def _patchelf(self, *args: str) -> str: - return subprocess.check_output(['patchelf', *args, self._path], stderr=subprocess.STDOUT).decode() - - @property - def rpath(self) -> Collection[str]: - if self._rpath is None: - rpath = self._patchelf('--print-rpath').strip() - self._rpath = rpath.split(':') if rpath else [] - return frozenset(self._rpath) - - @rpath.setter - def rpath(self, value: Collection[str]) -> None: - self._patchelf('--set-rpath', ':'.join(value)) - self._rpath = value - - @property - def needed(self) -> Collection[str]: - if self._needed is None: - self._needed = frozenset(self._patchelf('--print-needed').splitlines()) - return self._needed - - @needed.setter - def needed(self, value: Collection[str]) -> None: - value = frozenset(value) - for entry in self.needed: - if entry not in value: - self._patchelf('--remove-needed', entry) - for entry in value: - if entry not in self.needed: - self._patchelf('--add-needed', entry) - self._needed = value diff --git a/mesonpy/_rpath.py b/mesonpy/_rpath.py new file mode 100644 index 000000000..317e776e4 --- /dev/null +++ b/mesonpy/_rpath.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: 2023 The meson-python developers +# +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import os +import subprocess +import sys +import typing + + +if typing.TYPE_CHECKING: + from typing import List + + from mesonpy._compat import Iterable, Path + + +if sys.platform == 'linux': + + def _get_rpath(filepath: Path) -> List[str]: + r = subprocess.run(['patchelf', '--print-rpath', os.fspath(filepath)], capture_output=True, text=True) + return r.stdout.strip().split(':') + + def _set_rpath(filepath: Path, rpath: Iterable[str]) -> None: + subprocess.run(['patchelf','--set-rpath', ':'.join(rpath), os.fspath(filepath)], check=True) + + def fix_rpath(filepath: Path, libs_relative_path: str) -> None: + rpath = _get_rpath(filepath) + if '$ORIGIN/' in rpath: + rpath = [('$ORIGIN/' + libs_relative_path if path == '$ORIGIN/' else path) for path in rpath] + _set_rpath(filepath, rpath) + + +elif sys.platform == 'darwin': + + def _get_rpath(filepath: Path) -> List[str]: + rpath = [] + r = subprocess.run(['otool', '-l', os.fspath(filepath)], capture_output=True, text=True) + rpath_tag = False + for line in [x.split() for x in r.stdout.split('\n')]: + if line == ['cmd', 'LC_RPATH']: + rpath_tag = True + elif len(line) >= 2 and line[0] == 'path' and rpath_tag: + rpath.append(line[1]) + rpath_tag = False + return rpath + + def _replace_rpath(filepath: Path, old: str, new: str) -> None: + subprocess.run(['install_name_tool', '-rpath', old, new, os.fspath(filepath)], check=True) + + def fix_rpath(filepath: Path, libs_relative_path: str) -> None: + rpath = _get_rpath(filepath) + if '@loader_path/' in rpath: + _replace_rpath(filepath, '@loader_path/', '@loader_path/' + libs_relative_path) + +else: + + def fix_rpath(filepath: Path, libs_relative_path: str) -> None: + raise NotImplementedError(f'Bundling libraries in wheel is not supported on {sys.platform}') diff --git a/tests/packages/link-against-local-lib/meson.build b/tests/packages/link-against-local-lib/meson.build index 1d15493b0..52d5fec32 100644 --- a/tests/packages/link-against-local-lib/meson.build +++ b/tests/packages/link-against-local-lib/meson.build @@ -16,5 +16,6 @@ py.extension_module( 'example', 'examplemod.c', link_with: example_lib, + link_args: ['-Wl,-rpath,custom-rpath'], install: true, ) diff --git a/tests/test_wheel.py b/tests/test_wheel.py index 39c60a9cc..9d13dbe12 100644 --- a/tests/test_wheel.py +++ b/tests/test_wheel.py @@ -3,7 +3,6 @@ # SPDX-License-Identifier: MIT import os -import platform import re import shutil import stat @@ -166,12 +165,13 @@ def test_rpath(wheel_link_against_local_lib, tmp_path): artifact = wheel.wheelfile.WheelFile(wheel_link_against_local_lib) artifact.extractall(tmp_path) - 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 + origin = {'linux': '$ORIGIN', 'darwin': '@loader_path'}[sys.platform] + expected = {f'{origin}/.link_against_local_lib.mesonpy.libs', 'custom-rpath',} + + rpath = set(mesonpy._rpath._get_rpath(tmp_path / f'example{EXT_SUFFIX}')) + # Verify that rpath is a superset of the expected one: linking to + # the Python runtime may require additional rpath entries. + assert rpath >= expected @pytest.mark.skipif(sys.platform not in {'linux', 'darwin'}, reason='Not supported on this platform') @@ -179,15 +179,11 @@ def test_uneeded_rpath(wheel_purelib_and_platlib, tmp_path): artifact = wheel.wheelfile.WheelFile(wheel_purelib_and_platlib) artifact.extractall(tmp_path) - 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 shared_lib.rpath: - assert 'mesonpy.libs' not in rpath + origin = {'linux': '$ORIGIN', 'darwin': '@loader_path'}[sys.platform] + + rpath = mesonpy._rpath._get_rpath(tmp_path / f'plat{EXT_SUFFIX}') + for path in rpath: + assert origin not in path @pytest.mark.skipif(sys.platform not in {'linux', 'darwin'}, reason='Not supported on this platform')