Skip to content

Commit

Permalink
ENH: improve RPATH handling
Browse files Browse the repository at this point in the history
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 .<project-name>.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.
  • Loading branch information
dnicolodi committed Feb 15, 2025
1 parent 196bb57 commit 37c79fc
Show file tree
Hide file tree
Showing 3 changed files with 138 additions and 54 deletions.
4 changes: 4 additions & 0 deletions docs/reference/meson-compatibility.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 23 additions & 15 deletions mesonpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 ``.<project-name>.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())
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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

Expand Down
150 changes: 111 additions & 39 deletions mesonpy/_rpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
pass
def unique(values: List[T]) -> List[T]:
r = []
for value in values:
if value not in r:
r.append(value)
return r

elif sys.platform == 'darwin':

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


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
# ``.<package-name>.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
Expand All @@ -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)
Expand All @@ -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

0 comments on commit 37c79fc

Please sign in to comment.