Skip to content

Fix linking against libraries from Meson project on macOS #260

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

Merged
merged 4 commits into from
Feb 1, 2023
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ If you have a general question feel free to [start a discussion][new discussion]
on Github. If you want to report a bug, request a feature, or propose an improvement, feel
free to open an issue on our [bugtracker][bugtracker].


## Contributing

If you are interested in contributing, please check out
Expand Down
8 changes: 4 additions & 4 deletions docs/reference/limitations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,13 @@ Platform-specific limitations
=============================


Executables with internal dependencies :bdg-warning:`Windows` :bdg-warning:`macOS`
----------------------------------------------------------------------------------
Executables with internal dependencies :bdg-warning:`Windows`
-------------------------------------------------------------


If you have an executable that links against a shared library provided by your
project, on Windows and macOS ``meson-python`` will not be able to correctly
bundle it into the *wheel*.
project, on Windows ``meson-python`` will not be able to correctly bundle it
into the *wheel*.

The executable will be included in the *wheel*, but it
will not be able to find the project libraries it links against.
Expand Down
1 change: 1 addition & 0 deletions meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ endif
py.install_sources(
'mesonpy/__init__.py',
'mesonpy/_compat.py',
'mesonpy/_dylib.py',
'mesonpy/_editable.py',
'mesonpy/_elf.py',
'mesonpy/_introspection.py',
Expand Down
41 changes: 27 additions & 14 deletions mesonpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import tomllib

import mesonpy._compat
import mesonpy._dylib
import mesonpy._elf
import mesonpy._introspection
import mesonpy._tags
Expand Down Expand Up @@ -527,19 +528,32 @@ def _install_path(
arcname = os.path.join(destination, os.path.relpath(path, origin).replace(os.path.sep, '/'))
wheel_file.write(path, arcname)
else:
if self._has_internal_libs and platform.system() == 'Linux':
# 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
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]
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()))

wheel_file.write(origin, location)

Expand Down Expand Up @@ -576,7 +590,6 @@ def build(self, directory: Path) -> pathlib.Path:

# install bundled libraries
for destination, origin in self._wheel_files['mesonpy-libs']:
assert platform.system() == 'Linux', 'Bundling libraries in wheel is currently only supported in POSIX!'
destination = pathlib.Path(f'.{self._project.name}.mesonpy.libs', destination)
self._install_path(whl, counter, origin, destination)

Expand Down
55 changes: 55 additions & 0 deletions mesonpy/_dylib.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2023 Lars Pastewka <[email protected]>

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
25 changes: 16 additions & 9 deletions tests/test_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ def test_configure_data(wheel_configure_data):
}


@pytest.mark.skipif(platform.system() != 'Linux', reason='Unsupported on this platform for now')
@pytest.mark.skipif(platform.system() not in ['Linux', 'Darwin'], reason='Unsupported on this platform for now')
def test_local_lib(venv, wheel_link_against_local_lib):
venv.pip('install', wheel_link_against_local_lib)
output = venv.python('-c', 'import example; print(example.example_sum(1, 2))')
Expand Down Expand Up @@ -187,25 +187,32 @@ def test_detect_wheel_tag_script(wheel_executable):
assert name.group('plat') == PLATFORM


@pytest.mark.skipif(platform.system() != 'Linux', reason='Unsupported on this platform for now')
@pytest.mark.skipif(platform.system() not in ['Linux', 'Darwin'], reason='Unsupported on this platform for now')
def test_rpath(wheel_link_against_local_lib, tmp_path):
artifact = wheel.wheelfile.WheelFile(wheel_link_against_local_lib)
artifact.extractall(tmp_path)

elf = mesonpy._elf.ELF(tmp_path / f'example{EXT_SUFFIX}')
assert '$ORIGIN/.link_against_local_lib.mesonpy.libs' in elf.rpath
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


@pytest.mark.skipif(platform.system() != 'Linux', reason='Unsupported on this platform for now')
@pytest.mark.skipif(platform.system() not in ['Linux', 'Darwin'], reason='Unsupported on this platform for now')
def test_uneeded_rpath(wheel_purelib_and_platlib, tmp_path):
artifact = wheel.wheelfile.WheelFile(wheel_purelib_and_platlib)
artifact.extractall(tmp_path)

elf = mesonpy._elf.ELF(tmp_path / f'plat{EXT_SUFFIX}')
if elf.rpath:
# elf.rpath is a frozenset, so iterate over it. An rpath may be
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 <conda-prefix>/lib/)
for rpath in elf.rpath:
for rpath in shared_lib.rpath:
assert 'mesonpy.libs' not in rpath


Expand Down