Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f58cf99
ENH: drop empty strings when listing RPATH entries for ELF binaries
dnicolodi Jul 30, 2025
4c62699
TST: copy the shared-lib-in-package test package into -legacy version
dnicolodi Sep 18, 2026
2ec7957
TST: rework the sharedlib-in-package test package
dnicolodi Feb 15, 2025
f005ed6
TST: use linker flags instead of install_rpath to set the RPATH
dnicolodi Jul 30, 2025
59ff4c2
TST: use platform specific anchors in RPATH entries
dnicolodi Jul 30, 2025
1e1d1bc
TST: add another RPATH handling verification test
dnicolodi Jul 30, 2025
58aa44a
TST: test RPATH entries added via flags in $LDFLAGS environment variable
dnicolodi Jul 30, 2025
5098cd4
MAINT: refactor RPATH handling code and add tests
dnicolodi Aug 10, 2025
2e1893f
BUG: correctly read RPATH entries containing spaces on macOS
dnicolodi Sep 20, 2026
d48d0a0
MAINT: move checking shared libs on Windows
dnicolodi Aug 10, 2025
2539458
BUG: do not remove RPATH entries relative to $ORIGIN
dnicolodi Aug 10, 2025
edffe30
ENH: implement support for install_rpath
dnicolodi Oct 18, 2025
60a713b
ENH: add support for removing build time RPATH entries
dnicolodi Jun 27, 2026
9c5891a
ENH: on macOS translate $ORIGIN to @loader_path in install_rpath
dnicolodi Sep 18, 2026
07eca0d
TST: fix sharedlib-in-package-legacy
dnicolodi Sep 18, 2026
985d88d
DOC: mention that macOS uses @loader_path instead of $ORIGIN in RPATH
dnicolodi Sep 18, 2026
4c22ef1
TST: check installation RPATH precedence with competing shared libraries
rgommers Sep 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions docs/how-to-guides/shared-libraries.rst
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,9 @@ the Python package itself:
install_dir: py.get_install_dir() / 'mypkg/subdir',
)

Then an extension module in the same install directory can link against the
shared library in a portable manner by using ``install_rpath``:
On Linux, macOS, and in general on all systems except Windows, an extension
module can dynamically link against a shared library installed in the same
package by using ``install_rpath`` with a relative path:

.. code-block:: meson

Expand All @@ -86,12 +87,17 @@ shared library in a portable manner by using ``install_rpath``:
link_with: example_lib,
install: true,
subdir: 'mypkg/subdir',
install_rpath: '$ORIGIN'
install_rpath: host_machine.system() == 'darwin' ? '@loader_path' : '$ORIGIN'
)

The above method will work as advertised on macOS and Linux; ``meson-python`` does
nothing special for this case. Windows needs some special handling though, due to
the lack of RPATH support:
Note that Mach-O binaries on macOS use the ``@loader_path`` token to indicate
the location of the current executable or shared library while ELF binaries use
the ``$ORIGIN`` token. For backward compatibility, ``meson-python`` translates
the more common ``$ORIGIN`` token to the macOS-specific ``@loader_path`` token
when building on macOS. However, Meson itself does not, thus relying on this
translation is discouraged.

Windows needs some special handling though, due to the lack of RPATH support:

.. literalinclude:: ../../tests/packages/sharedlib-in-package/mypkg/__init__.py
:start-after: start-literalinclude
Expand Down
10 changes: 10 additions & 0 deletions docs/reference/meson-compatibility.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,20 @@ versions.
populate the package license and license files from the ones
declared via the ``project()`` call in ``meson.build``.

Meson 1.6.0 or later is also required to support the
``install_rpath`` argument to Meson functions that accept it, such
as ``library()`` and ``extension_module()``. On older Meson
versions, this argument has no effect.

.. option:: 1.9.0

Meson 1.9.0 or later is required to support building for iOS.

Meson 1.9.0 or later is also required to remove RPATH entries that
are added by Meson to allow executing parts of the project from
the build directory and that are normally removed during ``meson
install``. On older Meson versions, these entries are not removed.

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
60 changes: 40 additions & 20 deletions mesonpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,22 @@ def _compile_patterns(patterns: List[str]) -> Callable[[str], bool]:
return typing.cast('Callable[[str], bool]', func)


def _translate_rpath(path: str, dst: pathlib.Path) -> str:
root, sep, stem = path.partition('/')
if root == '$ORIGIN':
translated = f'@loader_path{sep}{stem}'
warnings.warn(
f'translated "install_rpath" argument for {str(dst)!r} '
f'from {path!r} to {translated!r}', stacklevel=2)
path = translated
return path


class _Entry(typing.NamedTuple):
dst: pathlib.Path
src: str
install_rpath: List[str] = []
build_rpath: List[str] = []


def _map_to_wheel(sources: Dict[str, Dict[str, Any]],
Expand Down Expand Up @@ -183,7 +196,17 @@ def _map_to_wheel(sources: Dict[str, Dict[str, Any]],
filedst = dst / relpath
wheel_files[path].append(_Entry(filedst, filesrc))
else:
wheel_files[path].append(_Entry(dst, src))
rpath = target.get('install_rpath')
install_rpath = rpath.split(':') if rpath else []
build_rpath = target.get('build_rpaths') or []

# Translate ``$ORIGIN`` to ``@loader_path`` in ``install_rpath``
# arguments on macOS. This is better done here to be able to
# emit a meaningful warning.
if sys.platform == 'darwin':
install_rpath = [_translate_rpath(path, dst) for path in install_rpath]

wheel_files[path].append(_Entry(dst, src, install_rpath, build_rpath))

return wheel_files

Expand Down Expand Up @@ -441,25 +464,16 @@ def _stable_abi(self) -> Optional[str]:
return 'abi3.abi3t' if abi3t else '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,
install_rpath: List[str], build_rpath: List[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, 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
# 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)
if self._has_internal_libs and _is_native(origin):
libspath = os.path.relpath(self._libs_dir, destination.parent)
mesonpy._rpath.fix_rpath(origin, install_rpath, build_rpath, libspath)
elif install_rpath or build_rpath:
mesonpy._rpath.fix_rpath(origin, install_rpath, build_rpath, None)

try:
wheel_file.write(origin, destination.as_posix())
Expand Down Expand Up @@ -488,6 +502,12 @@ 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 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.')

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 @@ -497,7 +517,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, install_rpath, build_rpath in entries:
counter.update(src)

if path == root:
Expand All @@ -508,7 +528,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, install_rpath, build_rpath)

return wheel_file

Expand Down
158 changes: 113 additions & 45 deletions mesonpy/_rpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,46 +5,117 @@
from __future__ import annotations

import os
import re
import subprocess
import sys
import typing


if typing.TYPE_CHECKING:
from typing import Iterable, List, Union
Path = Union[str, os.PathLike[str]]
Path = str | os.PathLike[str]
T = typing.TypeVar('T')


if sys.platform == 'win32' or sys.platform == 'cygwin':
def unique(values: list[T]) -> list[T]:
return list(dict.fromkeys(values))


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: list[str], build_rpath: list[str], libs_path: str | None) -> None:
old_rpath = cls.get_rpath(filepath)

# Prepend install_rpath entries.
new_rpath = install_rpath

def fix_rpath(filepath: Path, libs_relative_path: str) -> None:
# Merge with existing entries, excluding build_rpath entries. Meson
# adds a padding entry to RPATH composed of enough ``X`` characters
# to reserve enough space in the ELF header to hold the the final
# installation RPATH. Remove this entry and empty entries too.
new_rpath += [path for path in old_rpath if path.strip('X') and path not in build_rpath]

# When an executable, library, or Python extension module is
# dynamically linked to a library built as part of the project, Meson
# adds a build RPATH pointing to the build directory, in the form of a
# relative RPATH entry. We can use the presence of any RPATH entries
# relative to ``$ORIGIN`` as an indicator that the installed object
# depends on shared libraries internal to the project. In this case we
# need to add an RPATH entry pointing to the meson-python shared
# library install location. This heuristic is not perfect: RPATH
# entries relative to ``$ORIGIN`` can exist for other reasons.
# However, this only results in harmless additional RPATH entries.
if libs_path and any(path.startswith(cls.origin) for path in old_rpath):
new_rpath.append(os.path.join(cls.origin, libs_path))

new_rpath = unique(new_rpath)
if new_rpath != old_rpath:
cls.set_rpath(filepath, old_rpath, new_rpath)


class _Windows(RPATH):

@classmethod
def fix_rpath(cls, filepath: Path, install_rpath: list[str], build_rpath: list[str], libs_path: str | None) -> None:
pass

elif sys.platform == 'darwin':

def _get_rpath(filepath: Path) -> List[str]:
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
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
lines = iter(r.stdout.splitlines())
for line in lines:
if line.strip().startswith('cmd LC_RPATH'):
for line in lines:
if m := re.match(r'^\s*path (.+) \(offset \d+\)$', line):
rpath.append(m.group(1))
break
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)
@staticmethod
def set_rpath(filepath: Path, old: list[str], rpath: list[str]) -> None:
# ``install_name_tool`` allows to delete, add, or rewrite specific
# LC_RPATH entries, however, this operations cannot be combined in
# arbitrary order in a single call. The only robust way to get
# entries in a specific order with at max two tool invocations is to
# delete the entries that are out of place and re-add them in the
# right order.

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)
keep = 0
while keep < len(old) and keep < len(rpath) and old[keep] == rpath[keep]:
keep += 1

elif sys.platform == 'sunos5':
delete = old[keep:]
add = rpath[keep:]

if delete:
args = [a for p in delete for a in ('-delete_rpath', p)]
subprocess.run(['install_name_tool', *args, os.fspath(filepath)], check=True)

def _get_rpath(filepath: Path) -> List[str]:
if add:
args = [a for p in add for a in ('-add_rpath', p)]
subprocess.run(['install_name_tool', *args, os.fspath(filepath)], check=True)


class _SunOS5(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 @@ -55,35 +126,32 @@ 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: list[str], rpath: list[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(':')
return [x for x in r.stdout.strip().split(':') if x]

def _set_rpath(filepath: Path, rpath: Iterable[str]) -> None:
@staticmethod
def set_rpath(filepath: Path, old: list[str], rpath: list[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 = _SunOS5
else:
_cls = _ELF

get_rpath = _cls.get_rpath
set_rpath = _cls.set_rpath
fix_rpath = _cls.fix_rpath
9 changes: 5 additions & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,11 +218,12 @@ def __init__(self, source_dir, build_dir, meson_args=None, editable_verbose=None
# toolchains.
'cmake-subproject',

# The ``link-against-local-lib`` package uses linker arguments
# to add RPATH entries. This functionality is deprecated in
# Meson but it is used in the wild thus we should make sure it
# keeps working.
# These packages use linker arguments to add RPATH entries.
# This functionality is deprecated in Meson but it is used
# in the wild thus we should make sure it keeps working.
'link-against-local-lib',
'sharedlib-in-package',
'same-name-sharedlibs',

}:
if meson_args is None:
Expand Down
9 changes: 9 additions & 0 deletions tests/packages/same-name-sharedlibs/meson.build
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# SPDX-FileCopyrightText: 2026 The meson-python developers
#
# SPDX-License-Identifier: MIT

project('same-name-sharedlibs', 'c', version: '1.0.0')

py = import('python').find_installation(pure: false)

subdir('same_name')
7 changes: 7 additions & 0 deletions tests/packages/same-name-sharedlibs/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# SPDX-FileCopyrightText: 2026 The meson-python developers
#
# SPDX-License-Identifier: MIT

[build-system]
build-backend = 'mesonpy'
requires = ['meson-python']
3 changes: 3 additions & 0 deletions tests/packages/same-name-sharedlibs/same_name/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2026 The meson-python developers
#
# SPDX-License-Identifier: MIT
Loading
Loading