From d356a4216695728eb8da6d719f4c84451f5476c4 Mon Sep 17 00:00:00 2001 From: Daniele Nicolodi Date: Thu, 13 Feb 2025 18:15:28 +0100 Subject: [PATCH] ENH: improve RPATH handling Always strip RPATH pointing to the build directory automatically added by meson at build time to all artifacts linking to a shared library built as part of the project. Before this was done only when the project contained a shared library relocated to ..mesonpy.libs. Add the RPATH entry specified in the meson.build definition via the install_rpath argument to all artifacts. This automatically remaps the $ORIGIN anchor to @loader_path on macOS. This requires Meson 1.6.0 or later. Deduplicate RPATH entries. Fixes #711. --- docs/reference/meson-compatibility.rst | 4 + mesonpy/__init__.py | 38 ++++--- mesonpy/_rpath.py | 150 ++++++++++++++++++------- 3 files changed, 138 insertions(+), 54 deletions(-) diff --git a/docs/reference/meson-compatibility.rst b/docs/reference/meson-compatibility.rst index 6b339d60..dfa96432 100644 --- a/docs/reference/meson-compatibility.rst +++ b/docs/reference/meson-compatibility.rst @@ -53,6 +53,10 @@ versions. declared via the ``project()`` call in ``meson.build``. This also requires ``pyproject-metadata`` version 0.9.0 or later. + Meson 1.6.0 or later is also required for support for the + ``install_rpath`` argument to Meson functions declaring build rules + for object files. + Build front-ends by default build packages in an isolated Python environment where build dependencies are installed. Most often, unless a package or its build dependencies declare explicitly a version diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index c56e5592..c01ab1cd 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -113,6 +113,7 @@ class InvalidLicenseExpression(Exception): # type: ignore[no-redef] class Entry(typing.NamedTuple): dst: pathlib.Path src: str + rpath: Optional[str] = None def _map_to_wheel(sources: Dict[str, Dict[str, Any]]) -> DefaultDict[str, List[Entry]]: @@ -161,7 +162,7 @@ def _map_to_wheel(sources: Dict[str, Dict[str, Any]]) -> DefaultDict[str, List[E filedst = dst / relpath wheel_files[path].append(Entry(filedst, filesrc)) else: - wheel_files[path].append(Entry(dst, src)) + wheel_files[path].append(Entry(dst, src, target.get('install_rpath'))) return wheel_files @@ -420,25 +421,25 @@ def _stable_abi(self) -> Optional[str]: return 'abi3' return None - def _install_path(self, wheel_file: mesonpy._wheelfile.WheelFile, origin: Path, destination: pathlib.Path) -> None: + def _install_path(self, wheel_file: mesonpy._wheelfile.WheelFile, + origin: Path, destination: pathlib.Path, rpath: Optional[str]) -> None: """Add a file to the wheel.""" - if self._has_internal_libs: - if _is_native(origin): - if sys.platform == 'win32' and not self._allow_windows_shared_libs: - raise NotImplementedError( - 'Loading shared libraries bundled in the Python wheel on Windows requires ' - 'setting the DLL load path or preloading. See the documentation for ' - 'the "tool.meson-python.allow-windows-internal-shared-libs" option.') - - # When an executable, libray, or Python extension module is + if _is_native(origin): + libspath = None + if self._has_internal_libs: + # When an executable, library, 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 + # relocates the shared libraries to the ``..mesonpy.libs`` # folder. Rewrite the RPATH to point to that folder instead. libspath = os.path.relpath(self._libs_dir, destination.parent) - mesonpy._rpath.fix_rpath(origin, libspath) + + # Adjust RPATH: remove build RPATH added by meson, add an RPATH + # entries as per above, and add any ``install_rpath`` specified in + # meson.build + mesonpy._rpath.fix_rpath(origin, rpath, libspath) try: wheel_file.write(origin, destination.as_posix()) @@ -467,6 +468,13 @@ def _wheel_write_metadata(self, whl: mesonpy._wheelfile.WheelFile) -> None: whl.write(f, f'{self._distinfo_dir}/licenses/{pathlib.Path(f).as_posix()}') def build(self, directory: Path) -> pathlib.Path: + + if sys.platform == 'win32' and self._has_internal_libs and not self._allow_windows_shared_libs: + raise ConfigError( + 'Loading shared libraries bundled in the Python wheel on Windows requires ' + 'setting the DLL load path or preloading. See the documentation for ' + 'the "tool.meson-python.allow-windows-internal-shared-libs" option.') + wheel_file = pathlib.Path(directory, f'{self.name}.whl') with mesonpy._wheelfile.WheelFile(wheel_file, 'w') as whl: self._wheel_write_metadata(whl) @@ -476,7 +484,7 @@ def build(self, directory: Path) -> pathlib.Path: root = 'purelib' if self._pure else 'platlib' for path, entries in self._manifest.items(): - for dst, src in entries: + for dst, src, rpath in entries: counter.update(src) if path == root: @@ -487,7 +495,7 @@ def build(self, directory: Path) -> pathlib.Path: else: dst = pathlib.Path(self._data_dir, path, dst) - self._install_path(whl, src, dst) + self._install_path(whl, src, dst, rpath) return wheel_file diff --git a/mesonpy/_rpath.py b/mesonpy/_rpath.py index a7cbbb92..69103e64 100644 --- a/mesonpy/_rpath.py +++ b/mesonpy/_rpath.py @@ -11,19 +11,81 @@ if typing.TYPE_CHECKING: - from typing import List + from typing import List, Optional, TypeVar from mesonpy._compat import Iterable, Path + T = TypeVar('T') -if sys.platform == 'win32' or sys.platform == 'cygwin': - def fix_rpath(filepath: Path, libs_relative_path: str) -> None: +def unique(values: List[T]) -> List[T]: + r = [] + for value in values: + if value not in r: + r.append(value) + return r + + +class _Windows: + + @staticmethod + def get_rpath(filepath: Path) -> List[str]: + return [] + + @classmethod + def fix_rpath(cls, filepath: Path, install_rpath: Optional[str], libs_rpath: Optional[str]) -> None: pass -elif sys.platform == 'darwin': - def _get_rpath(filepath: Path) -> List[str]: +class RPATH: + origin = '$ORIGIN' + + @staticmethod + def get_rpath(filepath: Path) -> List[str]: + raise NotImplementedError + + @staticmethod + def set_rpath(filepath: Path, old: List[str], rpath: List[str]) -> None: + raise NotImplementedError + + @classmethod + def fix_rpath(cls, filepath: Path, install_rpath: Optional[str], libs_rpath: Optional[str]) -> None: + old_rpath = cls.get_rpath(filepath) + new_rpath = [] + if libs_rpath is not None: + if libs_rpath == '.': + libs_rpath = '' + for path in old_rpath: + if path.split('/', 1)[0] == cls.origin: + # Any RPATH entry relative to ``$ORIGIN`` is interpreted as + # pointing to a location in the build directory added by + # Meson. These need to be removed. Their presence indicates + # that the executable, shared library, or Python module + # depends on libraries build as part of the package. These + # entries are thus replaced with entries pointing to the + # ``..mesonpy.libs`` folder where meson-python + # relocates shared libraries distributed with the package. + # The package may however explicitly install these in a + # different location, thus this is not a perfect heuristic + # and may add not required RPATH entries. These are however + # harmless. + path = f'{cls.origin}/{libs_rpath}' + # Any other RPATH entry is preserved. + new_rpath.append(path) + if install_rpath: + # Add the RPATH entry spcified with the ``install_rpath`` argument. + new_rpath.append(install_rpath) + # Make the RPATH entries unique. + new_rpath = unique(new_rpath) + if new_rpath != old_rpath: + cls.set_rpath(filepath, old_rpath, new_rpath) + + +class _MacOS(RPATH): + origin = '@loader_path' + + @staticmethod + def get_rpath(filepath: Path) -> List[str]: rpath = [] r = subprocess.run(['otool', '-l', os.fspath(filepath)], capture_output=True, text=True) rpath_tag = False @@ -35,17 +97,31 @@ def _get_rpath(filepath: Path) -> List[str]: 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: - for path in _get_rpath(filepath): - if path.startswith('@loader_path/'): - _replace_rpath(filepath, path, '@loader_path/' + libs_relative_path) - -elif sys.platform == 'sunos5': - - def _get_rpath(filepath: Path) -> List[str]: + @staticmethod + def set_rpath(filepath: Path, old: List[str], rpath: List[str]) -> None: + args: List[str] = [] + for path in rpath: + if path not in old: + args += ['-add_rpath', path] + for path in old: + if path not in rpath: + args += ['-delete_rpath', path] + subprocess.run(['install_name_tool', *args, os.fspath(filepath)], check=True) + + @classmethod + def fix_rpath(cls, filepath: Path, install_rpath: Optional[str], libs_rpath: Optional[str]) -> None: + if install_rpath is not None: + root, sep, stem = install_rpath.partition('/') + if root == '$ORIGIN': + install_rpath = f'{cls.origin}{sep}{stem}' + # warnings.warn('...') + super().fix_rpath(filepath, install_rpath, libs_rpath) + + +class _SunOS(RPATH): + + @staticmethod + def get_rpath(filepath: Path) -> List[str]: rpath = [] r = subprocess.run(['/usr/bin/elfedit', '-r', '-e', 'dyn:rpath', os.fspath(filepath)], capture_output=True, check=True, text=True) @@ -56,35 +132,31 @@ def _get_rpath(filepath: Path) -> List[str]: rpath.append(path) return rpath - def _set_rpath(filepath: Path, rpath: Iterable[str]) -> None: + @staticmethod + def set_rpath(filepath: Path, old: Iterable[str], rpath: Iterable[str]) -> None: subprocess.run(['/usr/bin/elfedit', '-e', 'dyn:rpath ' + ':'.join(rpath), os.fspath(filepath)], check=True) - def fix_rpath(filepath: Path, libs_relative_path: str) -> None: - old_rpath = _get_rpath(filepath) - new_rpath = [] - for path in old_rpath: - if path.startswith('$ORIGIN/'): - path = '$ORIGIN/' + libs_relative_path - new_rpath.append(path) - if new_rpath != old_rpath: - _set_rpath(filepath, new_rpath) -else: - # Assume that any other platform uses ELF binaries. +class _ELF(RPATH): - def _get_rpath(filepath: Path) -> List[str]: + @staticmethod + 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: + @staticmethod + def set_rpath(filepath: Path, old: Iterable[str], 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: - old_rpath = _get_rpath(filepath) - new_rpath = [] - for path in old_rpath: - if path.startswith('$ORIGIN/'): - path = '$ORIGIN/' + libs_relative_path - new_rpath.append(path) - if new_rpath != old_rpath: - _set_rpath(filepath, new_rpath) + +if sys.platform == 'win32' or sys.platform == 'cygwin': + _cls = _Windows +elif sys.platform == 'darwin': + _cls = _MacOS +elif sys.platform == 'sunos5': + _cls = _SunOS +else: + _cls = _ELF + +_get_rpath = _cls.get_rpath +fix_rpath = _cls.fix_rpath