Skip to content

Commit d75f4cd

Browse files
pastewkaFFY00
authored andcommitted
ENH: fix linking against libraries from Meson project on macOS
PR #260 Signed-off-by: Filipe Laíns <[email protected]>
1 parent a2e3fa3 commit d75f4cd

File tree

6 files changed

+104
-27
lines changed

6 files changed

+104
-27
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ If you have a general question feel free to [start a discussion][new discussion]
2323
on Github. If you want to report a bug, request a feature, or propose an improvement, feel
2424
free to open an issue on our [bugtracker][bugtracker].
2525

26+
2627
## Contributing
2728

2829
If you are interested in contributing, please check out

docs/reference/limitations.rst

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,13 @@ Platform-specific limitations
5252
=============================
5353

5454

55-
Executables with internal dependencies :bdg-warning:`Windows` :bdg-warning:`macOS`
56-
----------------------------------------------------------------------------------
55+
Executables with internal dependencies :bdg-warning:`Windows`
56+
-------------------------------------------------------------
5757

5858

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

6363
The executable will be included in the *wheel*, but it
6464
will not be able to find the project libraries it links against.

meson.build

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ endif
1414
py.install_sources(
1515
'mesonpy/__init__.py',
1616
'mesonpy/_compat.py',
17+
'mesonpy/_dylib.py',
1718
'mesonpy/_editable.py',
1819
'mesonpy/_elf.py',
1920
'mesonpy/_introspection.py',

mesonpy/__init__.py

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
import tomllib
4343

4444
import mesonpy._compat
45+
import mesonpy._dylib
4546
import mesonpy._elf
4647
import mesonpy._introspection
4748
import mesonpy._tags
@@ -528,19 +529,32 @@ def _install_path(
528529
arcname = os.path.join(destination, os.path.relpath(path, origin).replace(os.path.sep, '/'))
529530
wheel_file.write(path, arcname)
530531
else:
531-
if self._has_internal_libs and platform.system() == 'Linux':
532-
# add .mesonpy.libs to the RPATH of ELF files
533-
if self._is_native(os.fspath(origin)):
534-
# copy ELF to our working directory to avoid Meson having to regenerate the file
535-
new_origin = self._libs_build_dir / pathlib.Path(origin).relative_to(self._build_dir)
536-
os.makedirs(new_origin.parent, exist_ok=True)
537-
shutil.copy2(origin, new_origin)
538-
origin = new_origin
539-
# add our in-wheel libs folder to the RPATH
540-
elf = mesonpy._elf.ELF(origin)
541-
libdir_path = f'$ORIGIN/{os.path.relpath(f".{self._project.name}.mesonpy.libs", destination.parent)}'
542-
if libdir_path not in elf.rpath:
543-
elf.rpath = [*elf.rpath, libdir_path]
532+
if self._has_internal_libs:
533+
if platform.system() == 'Linux' or platform.system() == 'Darwin':
534+
# add .mesonpy.libs to the RPATH of ELF files
535+
if self._is_native(os.fspath(origin)):
536+
# copy ELF to our working directory to avoid Meson having to regenerate the file
537+
new_origin = self._libs_build_dir / pathlib.Path(origin).relative_to(self._build_dir)
538+
os.makedirs(new_origin.parent, exist_ok=True)
539+
shutil.copy2(origin, new_origin)
540+
origin = new_origin
541+
# add our in-wheel libs folder to the RPATH
542+
if platform.system() == 'Linux':
543+
elf = mesonpy._elf.ELF(origin)
544+
libdir_path = \
545+
f'$ORIGIN/{os.path.relpath(f".{self._project.name}.mesonpy.libs", destination.parent)}'
546+
if libdir_path not in elf.rpath:
547+
elf.rpath = [*elf.rpath, libdir_path]
548+
elif platform.system() == 'Darwin':
549+
dylib = mesonpy._dylib.Dylib(origin)
550+
libdir_path = \
551+
f'@loader_path/{os.path.relpath(f".{self._project.name}.mesonpy.libs", destination.parent)}'
552+
if libdir_path not in dylib.rpath:
553+
dylib.rpath = [*dylib.rpath, libdir_path]
554+
else:
555+
# Internal libraries are currently unsupported on this platform
556+
raise NotImplementedError("Bundling libraries in wheel is not supported on platform '{}'"
557+
.format(platform.system()))
544558

545559
wheel_file.write(origin, location)
546560

@@ -577,7 +591,6 @@ def build(self, directory: Path) -> pathlib.Path:
577591

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

mesonpy/_dylib.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# SPDX-License-Identifier: MIT
2+
# SPDX-FileCopyrightText: 2023 Lars Pastewka <[email protected]>
3+
4+
from __future__ import annotations
5+
6+
import os
7+
import subprocess
8+
import typing
9+
10+
11+
if typing.TYPE_CHECKING:
12+
from typing import Optional
13+
14+
from mesonpy._compat import Collection, Path
15+
16+
17+
# This class is modeled after the ELF class in _elf.py
18+
class Dylib:
19+
def __init__(self, path: Path) -> None:
20+
self._path = os.fspath(path)
21+
self._rpath: Optional[Collection[str]] = None
22+
self._needed: Optional[Collection[str]] = None
23+
24+
def _otool(self, *args: str) -> str:
25+
return subprocess.check_output(['otool', *args, self._path], stderr=subprocess.STDOUT).decode()
26+
27+
def _install_name_tool(self, *args: str) -> str:
28+
return subprocess.check_output(['install_name_tool', *args, self._path], stderr=subprocess.STDOUT).decode()
29+
30+
@property
31+
def rpath(self) -> Collection[str]:
32+
if self._rpath is None:
33+
self._rpath = []
34+
# Run otool -l to get the load commands
35+
otool_output = self._otool('-l').strip()
36+
# Manually parse the output for LC_RPATH
37+
rpath_tag = False
38+
for line in [x.split() for x in otool_output.split('\n')]:
39+
if line == ['cmd', 'LC_RPATH']:
40+
rpath_tag = True
41+
elif len(line) >= 2 and line[0] == 'path' and rpath_tag:
42+
self._rpath += [line[1]]
43+
rpath_tag = False
44+
return frozenset(self._rpath)
45+
46+
@rpath.setter
47+
def rpath(self, value: Collection[str]) -> None:
48+
# We clear all LC_RPATH load commands
49+
if self._rpath:
50+
for rpath in self._rpath:
51+
self._install_name_tool('-delete_rpath', rpath)
52+
# We then rewrite the new load commands
53+
for rpath in value:
54+
self._install_name_tool('-add_rpath', rpath)
55+
self._rpath = value

tests/test_wheel.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ def test_configure_data(wheel_configure_data):
147147
}
148148

149149

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

189189

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

195-
elf = mesonpy._elf.ELF(tmp_path / f'example{EXT_SUFFIX}')
196-
assert '$ORIGIN/.link_against_local_lib.mesonpy.libs' in elf.rpath
195+
if platform.system() == 'Linux':
196+
elf = mesonpy._elf.ELF(tmp_path / f'example{EXT_SUFFIX}')
197+
assert '$ORIGIN/.link_against_local_lib.mesonpy.libs' in elf.rpath
198+
else: # 'Darwin'
199+
dylib = mesonpy._dylib.Dylib(tmp_path / f'example{EXT_SUFFIX}')
200+
assert '@loader_path/.link_against_local_lib.mesonpy.libs' in dylib.rpath
197201

198202

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

204-
elf = mesonpy._elf.ELF(tmp_path / f'plat{EXT_SUFFIX}')
205-
if elf.rpath:
206-
# elf.rpath is a frozenset, so iterate over it. An rpath may be
208+
if platform.system() == 'Linux':
209+
shared_lib = mesonpy._elf.ELF(tmp_path / f'plat{EXT_SUFFIX}')
210+
else: # 'Darwin'
211+
shared_lib = mesonpy._dylib.Dylib(tmp_path / f'plat{EXT_SUFFIX}')
212+
if shared_lib.rpath:
213+
# shared_lib.rpath is a frozenset, so iterate over it. An rpath may be
207214
# present, e.g. when conda is used (rpath will be <conda-prefix>/lib/)
208-
for rpath in elf.rpath:
215+
for rpath in shared_lib.rpath:
209216
assert 'mesonpy.libs' not in rpath
210217

211218

0 commit comments

Comments
 (0)