Skip to content

Commit

Permalink
Fix linking against libraries from Meson project on macOS
Browse files Browse the repository at this point in the history
PR #260
  • Loading branch information
pastewka committed Feb 1, 2023
1 parent a2e3fa3 commit 4a3558c
Show file tree
Hide file tree
Showing 6 changed files with 104 additions and 27 deletions.
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 @@ -42,6 +42,7 @@
import tomllib

import mesonpy._compat
import mesonpy._dylib
import mesonpy._elf
import mesonpy._introspection
import mesonpy._tags
Expand Down Expand Up @@ -528,19 +529,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 @@ -577,7 +591,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 <lars.pastewka@imtek.uni-freiburg.de>

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

0 comments on commit 4a3558c

Please sign in to comment.