Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix support for install_rpath #724

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ jobs:
- os: ubuntu-latest
python: '3.12'
meson: '~=1.5.0'
- os: ubuntu-latest
python: '3.13'
meson: '~=1.6.0'
# Test with Meson master branch.
- os: ubuntu-latest
python: '3.12'
Expand Down
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
85 changes: 47 additions & 38 deletions mesonpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import argparse
import collections
import contextlib
import dataclasses
import difflib
import functools
import importlib.machinery
Expand Down Expand Up @@ -109,9 +110,15 @@
}


def _map_to_wheel(sources: Dict[str, Dict[str, Any]]) -> DefaultDict[str, List[Tuple[pathlib.Path, str]]]:
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]]:
"""Map files to the wheel, organized by wheel installation directory."""
wheel_files: DefaultDict[str, List[Tuple[pathlib.Path, str]]] = collections.defaultdict(list)
wheel_files: DefaultDict[str, List[Entry]] = collections.defaultdict(list)
packages: Dict[str, str] = {}

for key, group in sources.items():
Expand All @@ -129,7 +136,8 @@
other = packages.setdefault(package, path)
if other != path:
this = os.fspath(pathlib.Path(path, *destination.parts[1:]))
that = os.fspath(other / next(d for d, s in wheel_files[other] if d.parts[0] == destination.parts[1]))
module = next(entry.dst for entry in wheel_files[other] if entry.dst.parts[0] == destination.parts[1])
that = os.fspath(other / module)
raise BuildError(
f'The {package} package is split between {path} and {other}: '
f'{this!r} and {that!r}, a "pure: false" argument may be missing in meson.build. '
Expand All @@ -152,9 +160,9 @@
if relpath in exclude_files:
continue
filedst = dst / relpath
wheel_files[path].append((filedst, filesrc))
wheel_files[path].append(Entry(filedst, filesrc))
else:
wheel_files[path].append((dst, src))
wheel_files[path].append(Entry(dst, src, target.get('install_rpath')))

return wheel_files

Expand Down Expand Up @@ -301,20 +309,14 @@
return f.read(4) == b'\x7fELF' # ELF


@dataclasses.dataclass
class _WheelBuilder():
"""Helper class to build wheels from projects."""

def __init__(
self,
metadata: Metadata,
manifest: Dict[str, List[Tuple[pathlib.Path, str]]],
limited_api: bool,
allow_windows_shared_libs: bool,
) -> None:
self._metadata = metadata
self._manifest = manifest
self._limited_api = limited_api
self._allow_windows_shared_libs = allow_windows_shared_libs
_metadata: Metadata
_manifest: Dict[str, List[Entry]]
_limited_api: bool
_allow_windows_shared_libs: bool

@property
def _has_internal_libs(self) -> bool:
Expand All @@ -330,8 +332,8 @@
"""Whether the wheel is architecture independent"""
if self._manifest['platlib'] or self._manifest['mesonpy-libs']:
return False
for _, file in self._manifest['scripts']:
if _is_native(file):
for entry in self._manifest['scripts']:
if _is_native(entry.src):
return False
return True

Expand Down Expand Up @@ -408,36 +410,36 @@
# in {platlib} that look like extension modules, and raise
# an exception if any of them has a Python version
# specific extension filename suffix ABI tag.
for path, _ in self._manifest['platlib']:
match = _EXTENSION_SUFFIX_REGEX.match(path.name)
for entry in self._manifest['platlib']:
match = _EXTENSION_SUFFIX_REGEX.match(entry.dst.name)
if match:
abi = match.group('abi')
if abi is not None and abi != 'abi3':
raise BuildError(
f'The package declares compatibility with Python limited API but extension '
f'module {os.fspath(path)!r} is tagged for a specific Python version.')
f'module {os.fspath(entry.dst)!r} is tagged for a specific Python version.')
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 @@ -466,6 +468,13 @@
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(

Check warning on line 473 in mesonpy/__init__.py

View check run for this annotation

Codecov / codecov/patch

mesonpy/__init__.py#L473

Added line #L473 was not covered by tests
'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 @@ -475,7 +484,7 @@
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 @@ -486,7 +495,7 @@
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 All @@ -497,8 +506,8 @@
def _top_level_modules(self) -> Collection[str]:
modules = set()
for type_ in self._manifest:
for path, _ in self._manifest[type_]:
name, dot, ext = path.parts[0].partition('.')
for entry in self._manifest[type_]:
name, dot, ext = entry.dst.parts[0].partition('.')
if dot:
# module
suffix = dot + ext
Expand Down Expand Up @@ -854,7 +863,7 @@
return json.loads(info.read_text(encoding='utf-8'))

@property
def _manifest(self) -> DefaultDict[str, List[Tuple[pathlib.Path, str]]]:
def _manifest(self) -> DefaultDict[str, List[Entry]]:
"""The files to be added to the wheel, organized by wheel path."""

# Obtain the list of files Meson would install.
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

Check warning on line 14 in mesonpy/_rpath.py

View check run for this annotation

Codecov / codecov/patch

mesonpy/_rpath.py#L14

Added line #L14 was not covered by tests

from mesonpy._compat import Iterable, Path

T = TypeVar('T')

Check warning on line 18 in mesonpy/_rpath.py

View check run for this annotation

Codecov / codecov/patch

mesonpy/_rpath.py#L18

Added line #L18 was not covered by tests

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 []

Check warning on line 33 in mesonpy/_rpath.py

View check run for this annotation

Codecov / codecov/patch

mesonpy/_rpath.py#L33

Added line #L33 was not covered by tests

@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

Check warning on line 45 in mesonpy/_rpath.py

View check run for this annotation

Codecov / codecov/patch

mesonpy/_rpath.py#L45

Added line #L45 was not covered by tests

@staticmethod
def set_rpath(filepath: Path, old: List[str], rpath: List[str]) -> None:
raise NotImplementedError

Check warning on line 49 in mesonpy/_rpath.py

View check run for this annotation

Codecov / codecov/patch

mesonpy/_rpath.py#L49

Added line #L49 was not covered by tests

@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 @@
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 @@
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

Check warning on line 157 in mesonpy/_rpath.py

View check run for this annotation

Codecov / codecov/patch

mesonpy/_rpath.py#L157

Added line #L157 was not covered by tests
else:
_cls = _ELF

_get_rpath = _cls.get_rpath
fix_rpath = _cls.fix_rpath
Loading
Loading