diff --git a/docs/how-to-guides/shared-libraries.rst b/docs/how-to-guides/shared-libraries.rst index 16ab3ac69..d17572dab 100644 --- a/docs/how-to-guides/shared-libraries.rst +++ b/docs/how-to-guides/shared-libraries.rst @@ -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 @@ -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 diff --git a/docs/reference/meson-compatibility.rst b/docs/reference/meson-compatibility.rst index 73258e73a..a342f4ec3 100644 --- a/docs/reference/meson-compatibility.rst +++ b/docs/reference/meson-compatibility.rst @@ -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 diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index 51ba001ba..a397db8a1 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -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]], @@ -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 @@ -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()) @@ -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) @@ -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: @@ -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 diff --git a/mesonpy/_rpath.py b/mesonpy/_rpath.py index 9953d7b5b..662b99b8a 100644 --- a/mesonpy/_rpath.py +++ b/mesonpy/_rpath.py @@ -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) @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index be9b52b6a..1b5939de6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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: diff --git a/tests/packages/same-name-sharedlibs/meson.build b/tests/packages/same-name-sharedlibs/meson.build new file mode 100644 index 000000000..690cc5594 --- /dev/null +++ b/tests/packages/same-name-sharedlibs/meson.build @@ -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') diff --git a/tests/packages/same-name-sharedlibs/pyproject.toml b/tests/packages/same-name-sharedlibs/pyproject.toml new file mode 100644 index 000000000..435419311 --- /dev/null +++ b/tests/packages/same-name-sharedlibs/pyproject.toml @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT + +[build-system] +build-backend = 'mesonpy' +requires = ['meson-python'] diff --git a/tests/packages/same-name-sharedlibs/same_name/__init__.py b/tests/packages/same-name-sharedlibs/same_name/__init__.py new file mode 100644 index 000000000..775d000c4 --- /dev/null +++ b/tests/packages/same-name-sharedlibs/same_name/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT diff --git a/tests/packages/same-name-sharedlibs/same_name/competing/choice.c b/tests/packages/same-name-sharedlibs/same_name/competing/choice.c new file mode 100644 index 000000000..0e2acdf20 --- /dev/null +++ b/tests/packages/same-name-sharedlibs/same_name/competing/choice.c @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2026 The meson-python developers +// +// SPDX-License-Identifier: MIT + +int choice(void) { + return 7; +} diff --git a/tests/packages/same-name-sharedlibs/same_name/competing/meson.build b/tests/packages/same-name-sharedlibs/same_name/competing/meson.build new file mode 100644 index 000000000..efc69ac7f --- /dev/null +++ b/tests/packages/same-name-sharedlibs/same_name/competing/meson.build @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT + +shared_library( + 'choice', 'choice.c', + install: true, + install_dir: py.get_install_dir() / 'same_name/competing', +) diff --git a/tests/packages/same-name-sharedlibs/same_name/meson.build b/tests/packages/same-name-sharedlibs/same_name/meson.build new file mode 100644 index 000000000..ee4f3d1df --- /dev/null +++ b/tests/packages/same-name-sharedlibs/same_name/meson.build @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT + +subdir('competing') +subdir('private') + +py.install_sources('__init__.py', subdir: 'same_name') + +origin = build_machine.system() == 'darwin' ? '@loader_path' : '$ORIGIN' + +# Simulate an external library whose runtime path is supplied through linker +# flags (for example, -Wl,-rpath,... in LDFLAGS). Keep that library inside this +# fixture to make the test self-contained. The requested private installation +# path must take precedence over this preserved competing path. +py.extension_module( + 'choice', 'module.c', + link_with: private, + link_args: f'-Wl,-rpath,@origin@/competing', + install: true, + subdir: 'same_name', + install_rpath: '$ORIGIN/private', +) diff --git a/tests/packages/same-name-sharedlibs/same_name/module.c b/tests/packages/same-name-sharedlibs/same_name/module.c new file mode 100644 index 000000000..0b72ecc9a --- /dev/null +++ b/tests/packages/same-name-sharedlibs/same_name/module.c @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 The meson-python developers +// +// SPDX-License-Identifier: MIT + +#include + +extern int choice(void); + +static PyObject *value(PyObject *self, PyObject *args) { + return PyLong_FromLong(choice()); +} + +static PyMethodDef methods[] = { + {"value", value, METH_NOARGS, NULL}, + {NULL, NULL, 0, NULL}, +}; + +static struct PyModuleDef module = { + PyModuleDef_HEAD_INIT, "choice", NULL, -1, methods, +}; + +PyMODINIT_FUNC PyInit_choice(void) { + return PyModule_Create(&module); +} diff --git a/tests/packages/same-name-sharedlibs/same_name/private/choice.c b/tests/packages/same-name-sharedlibs/same_name/private/choice.c new file mode 100644 index 000000000..40925a806 --- /dev/null +++ b/tests/packages/same-name-sharedlibs/same_name/private/choice.c @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2026 The meson-python developers +// +// SPDX-License-Identifier: MIT + +int choice(void) { + return 42; +} diff --git a/tests/packages/same-name-sharedlibs/same_name/private/meson.build b/tests/packages/same-name-sharedlibs/same_name/private/meson.build new file mode 100644 index 000000000..1b69252e1 --- /dev/null +++ b/tests/packages/same-name-sharedlibs/same_name/private/meson.build @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2026 The meson-python developers +# +# SPDX-License-Identifier: MIT + +private = shared_library( + 'choice', 'choice.c', + install: true, + install_dir: py.get_install_dir() / 'same_name/private', +) diff --git a/tests/packages/sharedlib-in-package-legacy/meson.build b/tests/packages/sharedlib-in-package-legacy/meson.build new file mode 100644 index 000000000..20554829b --- /dev/null +++ b/tests/packages/sharedlib-in-package-legacy/meson.build @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2022 The meson-python developers +# +# SPDX-License-Identifier: MIT + +project('sharedlib-in-package-legacy', 'c', version: '1.0.0') + +py = import('python').find_installation(pure: false) + +subdir('mypkg') diff --git a/tests/packages/sharedlib-in-package-legacy/mypkg/__init__.py b/tests/packages/sharedlib-in-package-legacy/mypkg/__init__.py new file mode 100644 index 000000000..857d2e906 --- /dev/null +++ b/tests/packages/sharedlib-in-package-legacy/mypkg/__init__.py @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: 2024 The meson-python developers +# +# SPDX-License-Identifier: MIT + +import os +import sys + + +# start-literalinclude +def _append_to_sharedlib_load_path(): + """Ensure the shared libraries in this package can be loaded on Windows. + + Windows lacks a concept equivalent to RPATH: Python extension modules + cannot find DLLs installed outside the DLL search path. This function + ensures that the location of the shared libraries distributed inside this + Python package is in the DLL search path of the process. + + The Windows DLL search path includes the path to the object attempting + to load the DLL: it needs to be augmented only when the Python extension + modules and the DLLs they require are installed in separate directories. + Cygwin does not have the same default library search path: all locations + where the shared libraries are installed need to be added to the search + path. + + This function is very similar to the snippet inserted into the main + ``__init__.py`` of a package by ``delvewheel`` when it vendors external + shared libraries. + + .. note:: + + `os.add_dll_directory` is only available for Python 3.8 and later, and + in the Conda ``python`` packages it works as advertised only for + version 3.10 and later. For older Python versions, pre-loading the DLLs + with `ctypes.WinDLL` may be preferred. + """ + basedir = os.path.dirname(__file__) + subdir = os.path.join(basedir, 'sub') + if os.name == 'nt': + os.add_dll_directory(subdir) + elif sys.platform == 'cygwin': + os.environ['PATH'] = os.pathsep.join((os.environ['PATH'], basedir, subdir)) + + +_append_to_sharedlib_load_path() +# end-literalinclude + + +from ._example import example_prod, example_sum #noqa: E402 + + +__all__ = ['example_prod', 'example_sum'] diff --git a/tests/packages/sharedlib-in-package-legacy/mypkg/_examplemod.c b/tests/packages/sharedlib-in-package-legacy/mypkg/_examplemod.c new file mode 100644 index 000000000..080e03c18 --- /dev/null +++ b/tests/packages/sharedlib-in-package-legacy/mypkg/_examplemod.c @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2022 The meson-python developers +// +// SPDX-License-Identifier: MIT + +#include + +#include "examplelib.h" +#include "examplelib2.h" + +static PyObject* example_sum(PyObject* self, PyObject *args) +{ + int a, b; + if (!PyArg_ParseTuple(args, "ii", &a, &b)) { + return NULL; + } + + long result = sum(a, b); + + return PyLong_FromLong(result); +} + +static PyObject* example_prod(PyObject* self, PyObject *args) +{ + int a, b; + if (!PyArg_ParseTuple(args, "ii", &a, &b)) { + return NULL; + } + + long result = prod(a, b); + + return PyLong_FromLong(result); +} + +static PyMethodDef methods[] = { + {"example_prod", (PyCFunction)example_prod, METH_VARARGS, NULL}, + {"example_sum", (PyCFunction)example_sum, METH_VARARGS, NULL}, + {NULL, NULL, 0, NULL}, +}; + +static struct PyModuleDef module = { + PyModuleDef_HEAD_INIT, + "_example", + NULL, + -1, + methods, +}; + +PyMODINIT_FUNC PyInit__example(void) +{ + return PyModule_Create(&module); +} diff --git a/tests/packages/sharedlib-in-package/mypkg/examplelib.c b/tests/packages/sharedlib-in-package-legacy/mypkg/examplelib.c similarity index 100% rename from tests/packages/sharedlib-in-package/mypkg/examplelib.c rename to tests/packages/sharedlib-in-package-legacy/mypkg/examplelib.c diff --git a/tests/packages/sharedlib-in-package/mypkg/examplelib.h b/tests/packages/sharedlib-in-package-legacy/mypkg/examplelib.h similarity index 100% rename from tests/packages/sharedlib-in-package/mypkg/examplelib.h rename to tests/packages/sharedlib-in-package-legacy/mypkg/examplelib.h diff --git a/tests/packages/sharedlib-in-package-legacy/mypkg/meson.build b/tests/packages/sharedlib-in-package-legacy/mypkg/meson.build new file mode 100644 index 000000000..8d66edfbf --- /dev/null +++ b/tests/packages/sharedlib-in-package-legacy/mypkg/meson.build @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2022 The meson-python developers +# +# SPDX-License-Identifier: MIT + +if meson.get_compiler('c').get_id() in ['msvc', 'clang-cl', 'intel-cl'] + export_dll_args = ['-DMYPKG_DLL_EXPORTS'] + import_dll_args = ['-DMYPKG_DLL_IMPORTS'] +else + export_dll_args = [] + import_dll_args = [] +endif + +example_lib = shared_library( + 'examplelib', + 'examplelib.c', + c_args: export_dll_args, + install: true, + install_dir: py.get_install_dir() / 'mypkg', +) + +example_lib_dep = declare_dependency( + compile_args: import_dll_args, + link_with: example_lib, +) + +subdir('sub') + +py.extension_module( + '_example', + '_examplemod.c', + dependencies: [example_lib_dep, example_lib2_dep], + include_directories: 'sub', + install: true, + subdir: 'mypkg', + install_rpath: '$ORIGIN:$ORIGIN/sub', +) + +py.install_sources( + ['__init__.py'], + subdir: 'mypkg', +) diff --git a/tests/packages/sharedlib-in-package/mypkg/sub/examplelib2.c b/tests/packages/sharedlib-in-package-legacy/mypkg/sub/examplelib2.c similarity index 100% rename from tests/packages/sharedlib-in-package/mypkg/sub/examplelib2.c rename to tests/packages/sharedlib-in-package-legacy/mypkg/sub/examplelib2.c diff --git a/tests/packages/sharedlib-in-package/mypkg/sub/examplelib2.h b/tests/packages/sharedlib-in-package-legacy/mypkg/sub/examplelib2.h similarity index 100% rename from tests/packages/sharedlib-in-package/mypkg/sub/examplelib2.h rename to tests/packages/sharedlib-in-package-legacy/mypkg/sub/examplelib2.h diff --git a/tests/packages/sharedlib-in-package-legacy/mypkg/sub/meson.build b/tests/packages/sharedlib-in-package-legacy/mypkg/sub/meson.build new file mode 100644 index 000000000..7a1978d4a --- /dev/null +++ b/tests/packages/sharedlib-in-package-legacy/mypkg/sub/meson.build @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2022 The meson-python developers +# +# SPDX-License-Identifier: MIT + +example_lib2 = shared_library( + 'examplelib2', + 'examplelib2.c', + c_args: export_dll_args, + install: true, + install_dir: py.get_install_dir() / 'mypkg/sub', +) + +example_lib2_dep = declare_dependency( + compile_args: import_dll_args, + link_with: example_lib2, +) diff --git a/tests/packages/sharedlib-in-package-legacy/mypkg/sub/mypkg_dll.h b/tests/packages/sharedlib-in-package-legacy/mypkg/sub/mypkg_dll.h new file mode 100644 index 000000000..8460e6c7d --- /dev/null +++ b/tests/packages/sharedlib-in-package-legacy/mypkg/sub/mypkg_dll.h @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2022 The meson-python developers +// +// SPDX-License-Identifier: MIT + +#pragma once + +// MYPKG_DLL +// inspired by https://github.com/abseil/abseil-cpp/blob/20240116.2/absl/base/config.h#L736-L753 +// and https://github.com/scipy/scipy/blob/9ded83b51099eee745418ccbb30196db96c81f3f/scipy/_build_utils/src/scipy_dll.h +// +// When building the `examplelib` DLL, this macro expands to `__declspec(dllexport)` +// so we can annotate symbols appropriately as being exported. When used in +// headers consuming a DLL, this macro expands to `__declspec(dllimport)` so +// that consumers know the symbol is defined inside the DLL. In all other cases, +// the macro expands to nothing. +// Note: MYPKG_DLL_{EX,IM}PORTS are set in mypkg/meson.build +#if defined(MYPKG_DLL_EXPORTS) + #define MYPKG_DLL __declspec(dllexport) +#elif defined(MYPKG_DLL_IMPORTS) + #define MYPKG_DLL __declspec(dllimport) +#else + #define MYPKG_DLL +#endif diff --git a/tests/packages/sharedlib-in-package-legacy/pyproject.toml b/tests/packages/sharedlib-in-package-legacy/pyproject.toml new file mode 100644 index 000000000..2542e4395 --- /dev/null +++ b/tests/packages/sharedlib-in-package-legacy/pyproject.toml @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: 2022 The meson-python developers +# +# SPDX-License-Identifier: MIT + +[build-system] +build-backend = 'mesonpy' +requires = ['meson-python'] diff --git a/tests/packages/sharedlib-in-package/meson.build b/tests/packages/sharedlib-in-package/meson.build index 71921cfea..d311a58a7 100644 --- a/tests/packages/sharedlib-in-package/meson.build +++ b/tests/packages/sharedlib-in-package/meson.build @@ -6,4 +6,7 @@ project('sharedlib-in-package', 'c', version: '1.0.0') py = import('python').find_installation(pure: false) +origin = build_machine.system() == 'darwin' ? '@loader_path' : '$ORIGIN' + +subdir('src') subdir('mypkg') diff --git a/tests/packages/sharedlib-in-package/mypkg/__init__.py b/tests/packages/sharedlib-in-package/mypkg/__init__.py index 857d2e906..e4fbc2a9b 100644 --- a/tests/packages/sharedlib-in-package/mypkg/__init__.py +++ b/tests/packages/sharedlib-in-package/mypkg/__init__.py @@ -45,7 +45,7 @@ def _append_to_sharedlib_load_path(): # end-literalinclude -from ._example import example_prod, example_sum #noqa: E402 +from ._example import prodsum # noqa: E402 -__all__ = ['example_prod', 'example_sum'] +__all__ = ['prodsum'] diff --git a/tests/packages/sharedlib-in-package/mypkg/_examplemod.c b/tests/packages/sharedlib-in-package/mypkg/_examplemod.c index 080e03c18..b4cc3f0c2 100644 --- a/tests/packages/sharedlib-in-package/mypkg/_examplemod.c +++ b/tests/packages/sharedlib-in-package/mypkg/_examplemod.c @@ -4,36 +4,23 @@ #include -#include "examplelib.h" -#include "examplelib2.h" +#include "lib.h" -static PyObject* example_sum(PyObject* self, PyObject *args) +static PyObject* example_prodsum(PyObject* self, PyObject *args) { - int a, b; - if (!PyArg_ParseTuple(args, "ii", &a, &b)) { - return NULL; - } + int a, b, x; - long result = sum(a, b); - - return PyLong_FromLong(result); -} - -static PyObject* example_prod(PyObject* self, PyObject *args) -{ - int a, b; - if (!PyArg_ParseTuple(args, "ii", &a, &b)) { + if (!PyArg_ParseTuple(args, "iii", &a, &b, &x)) { return NULL; } - long result = prod(a, b); + long result = prodsum(a, b, x); return PyLong_FromLong(result); } static PyMethodDef methods[] = { - {"example_prod", (PyCFunction)example_prod, METH_VARARGS, NULL}, - {"example_sum", (PyCFunction)example_sum, METH_VARARGS, NULL}, + {"prodsum", (PyCFunction)example_prodsum, METH_VARARGS, NULL}, {NULL, NULL, 0, NULL}, }; diff --git a/tests/packages/sharedlib-in-package/mypkg/meson.build b/tests/packages/sharedlib-in-package/mypkg/meson.build index 75904bed6..915d7c9cd 100644 --- a/tests/packages/sharedlib-in-package/mypkg/meson.build +++ b/tests/packages/sharedlib-in-package/mypkg/meson.build @@ -2,37 +2,19 @@ # # SPDX-License-Identifier: MIT -if meson.get_compiler('c').get_id() in ['msvc', 'clang-cl', 'intel-cl'] - export_dll_args = ['-DMYPKG_DLL_EXPORTS'] - import_dll_args = ['-DMYPKG_DLL_IMPORTS'] -else - export_dll_args = [] - import_dll_args = [] -endif - -example_lib = shared_library( - 'examplelib', - 'examplelib.c', - c_args: export_dll_args, - install: true, - install_dir: py.get_install_dir() / 'mypkg', -) - -example_lib_dep = declare_dependency( - compile_args: import_dll_args, - link_with: example_lib, -) - -subdir('sub') +# install_rpath is not exposed in the Meson introspection data in +# Meson versions prior to 1.6.0 and thus cannot be set by meson-python +# when building the Python wheel. Use link_args to set the RPATH when +# using older Meson. +kwargs = meson.version().version_compare('< 1.6') ? {'link_args': f'-Wl,-rpath,@origin@'} : {'install_rpath': f'@origin@'} py.extension_module( '_example', '_examplemod.c', - dependencies: [example_lib_dep, example_lib2_dep], - include_directories: 'sub', + dependencies: lib_dep, install: true, subdir: 'mypkg', - install_rpath: '$ORIGIN', + kwargs: kwargs, ) py.install_sources( diff --git a/tests/packages/sharedlib-in-package/src/lib.c b/tests/packages/sharedlib-in-package/src/lib.c new file mode 100644 index 000000000..0ec14522f --- /dev/null +++ b/tests/packages/sharedlib-in-package/src/lib.c @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2022 The meson-python developers +// +// SPDX-License-Identifier: MIT + +#include "lib.h" +#include "sublib.h" + +int prodsum(int a, int b, int x) { + return prod(a, x) + b; +} diff --git a/tests/packages/sharedlib-in-package/src/lib.h b/tests/packages/sharedlib-in-package/src/lib.h new file mode 100644 index 000000000..fb6a02d8a --- /dev/null +++ b/tests/packages/sharedlib-in-package/src/lib.h @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2022 The meson-python developers +// +// SPDX-License-Identifier: MIT + +#if defined(MYPKG_DLL_EXPORTS) + #define EXPORT __declspec(dllexport) +#elif defined(MYPKG_DLL_IMPORTS) + #define EXPORT __declspec(dllimport) +#else + #define EXPORT +#endif + +EXPORT int prodsum(int a, int b, int x); diff --git a/tests/packages/sharedlib-in-package/src/meson.build b/tests/packages/sharedlib-in-package/src/meson.build new file mode 100644 index 000000000..78ae3c9a2 --- /dev/null +++ b/tests/packages/sharedlib-in-package/src/meson.build @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: 2022 The meson-python developers +# +# SPDX-License-Identifier: MIT + +if meson.get_compiler('c').get_id() in ['msvc', 'clang-cl', 'intel-cl'] + export_dll_args = ['-DMYPKG_DLL_EXPORTS'] + import_dll_args = ['-DMYPKG_DLL_IMPORTS'] +else + export_dll_args = [] + import_dll_args = [] +endif + +sublib = shared_library( + 'sublib', + 'sublib.c', + c_args: export_dll_args, + install: true, + install_dir: py.get_install_dir() / 'mypkg/sub', +) + +sublib_dep = declare_dependency( + compile_args: import_dll_args, + link_with: sublib, +) + +# install_rpath is not exposed in the Meson introspection data in +# Meson versions prior to 1.6.0 and thus cannot be set by meson-python +# when building the Python wheel. Use link_args to set the RPATH when +# using older Meson. +kwargs = meson.version().version_compare('< 1.6') ? {'link_args': f'-Wl,-rpath,@origin@/sub'} : {'install_rpath': f'@origin@/sub'} + +lib = shared_library( + 'lib', + 'lib.c', + dependencies: sublib_dep, + c_args: export_dll_args, + install: true, + install_dir: py.get_install_dir() / 'mypkg', + kwargs: kwargs, +) + +lib_dep = declare_dependency( + compile_args: import_dll_args, + link_with: lib, + include_directories: include_directories('.'), +) diff --git a/tests/packages/sharedlib-in-package/src/sublib.c b/tests/packages/sharedlib-in-package/src/sublib.c new file mode 100644 index 000000000..facfdf2e1 --- /dev/null +++ b/tests/packages/sharedlib-in-package/src/sublib.c @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2022 The meson-python developers +// +// SPDX-License-Identifier: MIT + +#include "sublib.h" + +int prod(int a, int b) { + return a * b; +} diff --git a/tests/packages/sharedlib-in-package/src/sublib.h b/tests/packages/sharedlib-in-package/src/sublib.h new file mode 100644 index 000000000..9fc7ae51d --- /dev/null +++ b/tests/packages/sharedlib-in-package/src/sublib.h @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2022 The meson-python developers +// +// SPDX-License-Identifier: MIT + +#if defined(MYPKG_DLL_EXPORTS) + #define EXPORT __declspec(dllexport) +#elif defined(MYPKG_DLL_IMPORTS) + #define EXPORT __declspec(dllimport) +#else + #define EXPORT +#endif + +EXPORT int prod(int a, int b); diff --git a/tests/test_rpath.py b/tests/test_rpath.py new file mode 100644 index 000000000..d450ea401 --- /dev/null +++ b/tests/test_rpath.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: 2025 The meson-python developers +# +# SPDX-License-Identifier: MIT + +import sys + +import pytest +import wheel.wheelfile + +from mesonpy._rpath import get_rpath, set_rpath + + +@pytest.mark.skipif(sys.platform in {'win32', 'cygwin'}, reason='requires RPATH support') +def test_rpath_get_set(wheel_sharedlib_in_package, tmp_path): + artifact = wheel.wheelfile.WheelFile(wheel_sharedlib_in_package) + artifact.extractall(tmp_path) + obj = list(tmp_path.joinpath('mypkg').glob('_example.*'))[0] + + rpath = get_rpath(obj) + assert rpath + + set_rpath(obj, rpath, []) + rpath = get_rpath(obj) + assert rpath == [] + + new_rpath = ['one', 'two'] + set_rpath(obj, rpath, new_rpath) + rpath = get_rpath(obj) + assert rpath == new_rpath + + new_rpath = ['one', 'three', 'two'] + set_rpath(obj, rpath, new_rpath) + rpath = get_rpath(obj) + assert rpath == new_rpath + + new_rpath = ['one'] + set_rpath(obj, rpath, new_rpath) + rpath = get_rpath(obj) + assert rpath == new_rpath + + new_rpath = [ + 'with two or three spaces', + 'trailing space ', + '/some funny absolute path/(library)', + '@loader_path/Mach-O relative', + '$ORIGIN/ELF relative', + ] + set_rpath(obj, rpath, new_rpath) + rpath = get_rpath(obj) + assert rpath == new_rpath + + new_rpath = ['alpha', 'beta'] + set_rpath(obj, rpath, new_rpath) + rpath = get_rpath(obj) + assert rpath == new_rpath diff --git a/tests/test_wheel.py b/tests/test_wheel.py index 3fb3b4295..a7cb150dc 100644 --- a/tests/test_wheel.py +++ b/tests/test_wheel.py @@ -33,6 +33,9 @@ INTERPRETER = tag.interpreter PLATFORM = adjust_packaging_platform_tag(tag.platform) +# Support for removing build RPATH entries requires Meson 1.9 or later. +BUILD_RPATH_SUPPORT = MESON_VERSION >= (1, 9) + def wheel_contents(artifact): # Sometimes directories have entries, sometimes not, so we filter them out. @@ -168,14 +171,70 @@ def test_local_lib(venv, wheel_link_against_local_lib): assert int(output) == 3 -def test_sharedlib_in_package(venv, wheel_sharedlib_in_package): - venv.pip('install', wheel_sharedlib_in_package) +@pytest.mark.skipif(MESON_VERSION < (1, 6, 0), reason='meson too old') +@pytest.mark.filterwarnings('ignore:translated "install_rpath" argument for ') +def test_sharedlib_in_package_legacy(venv, wheel_sharedlib_in_package_legacy): + venv.pip('install', wheel_sharedlib_in_package_legacy) output = venv.python('-c', 'import mypkg; print(mypkg.example_sum(2, 5))') assert int(output) == 7 output = venv.python('-c', 'import mypkg; print(mypkg.example_prod(6, 7))') assert int(output) == 42 +@pytest.mark.skipif(sys.platform in {'win32', 'cygwin'}, reason='requires RPATH support') +def test_sharedlib_in_package_rpath(package_sharedlib_in_package, tmp_path): + filename = mesonpy.build_wheel(tmp_path) + artifact = wheel.wheelfile.WheelFile(tmp_path / filename) + artifact.extractall(tmp_path) + + origin = '@loader_path' if sys.platform == 'darwin' else '$ORIGIN' + + def check(rpath, *expected): + entries = [] + for path in rpath: + if path.startswith(origin): + entries.append(path) + continue + # There may be RPATH entries added by the build environment. + # However, these should be absolute paths and not point into + # the source or build directory. + assert not os.path.commonpath((path, str(package_sharedlib_in_package))) == str(package_sharedlib_in_package) + assert not os.path.commonpath((path, str(tmp_path))) == str(tmp_path) + + assert entries == list(expected) or not BUILD_RPATH_SUPPORT and set(entries) >= set(expected) + + rpath = mesonpy._rpath.get_rpath(tmp_path / 'mypkg' / f'_example{EXT_SUFFIX}') + check(rpath, origin) + + rpath = mesonpy._rpath.get_rpath(tmp_path / 'mypkg' / f'liblib{LIB_SUFFIX}') + check(rpath, f'{origin}/sub') + + rpath = mesonpy._rpath.get_rpath(tmp_path / 'mypkg' / 'sub' / f'libsublib{LIB_SUFFIX}') + check(rpath) + + +@pytest.mark.skipif(sys.platform in {'win32', 'cygwin'}, reason='requires RPATH support') +def test_sharedlib_in_package_rpath_ldflags(package_sharedlib_in_package, tmp_path, monkeypatch): + origin = '@loader_path' if sys.platform == 'darwin' else '$ORIGIN' + extra_rpath = {f'{origin}/test-ldflags', '/usr/lib/test-ldflags'} + ldflags = ' '.join(f'-Wl,-rpath,{p}' for p in extra_rpath) + monkeypatch.setenv('LDFLAGS', ldflags) + + filename = mesonpy.build_wheel(tmp_path) + artifact = wheel.wheelfile.WheelFile(tmp_path / filename) + artifact.extractall(tmp_path) + + for path in f'_example{EXT_SUFFIX}', f'liblib{LIB_SUFFIX}', f'sub/libsublib{LIB_SUFFIX}': + rpath = set(mesonpy._rpath.get_rpath(tmp_path / 'mypkg' / path)) + assert extra_rpath <= rpath + + +def test_sharedlib_in_package(venv, wheel_sharedlib_in_package): + venv.pip('install', wheel_sharedlib_in_package) + output = venv.python('-c', 'import mypkg; print(mypkg.prodsum(2, 3, 4))') + assert int(output) == 11 + + @pytest.mark.skipif(MESON_VERSION < (1, 3, 0), reason='meson too old') def test_link_library_in_subproject(venv, wheel_link_library_in_subproject): venv.pip('install', wheel_link_library_in_subproject) @@ -184,17 +243,44 @@ def test_link_library_in_subproject(venv, wheel_link_library_in_subproject): @pytest.mark.skipif(sys.platform in {'win32', 'cygwin'}, reason='requires RPATH support') -def test_rpath(wheel_link_against_local_lib, tmp_path): - artifact = wheel.wheelfile.WheelFile(wheel_link_against_local_lib) +def test_link_against_local_lib_rpath(package_link_against_local_lib, tmp_path): + filename = mesonpy.build_wheel(tmp_path) + artifact = wheel.wheelfile.WheelFile(tmp_path / filename) artifact.extractall(tmp_path) origin = '@loader_path' if sys.platform == 'darwin' else '$ORIGIN' - expected = {f'{origin}/../.link_against_local_lib.mesonpy.libs', 'custom-rpath',} - rpath = set(mesonpy._rpath._get_rpath(tmp_path / 'example' / f'_example{EXT_SUFFIX}')) - # Verify that rpath is a superset of the expected one: linking to - # the Python runtime may require additional rpath entries. - assert rpath >= expected + def check(rpath, *expected): + entries = [] + for path in rpath: + if path.startswith(origin) or path in expected: + entries.append(path) + continue + # There may be RPATH entries added by the build environment. + # However, these should be absolute paths and not point into + # the source or build directory. + assert not os.path.commonpath((path, str(package_link_against_local_lib))) == str(package_link_against_local_lib) + assert not os.path.commonpath((path, str(tmp_path))) == str(tmp_path) + + assert entries == list(expected) or not BUILD_RPATH_SUPPORT and set(entries) >= set(expected) + + rpath = mesonpy._rpath.get_rpath(tmp_path / 'example' / f'_example{EXT_SUFFIX}') + check(rpath, 'custom-rpath', f'{origin}/../.link_against_local_lib.mesonpy.libs') + + +@pytest.mark.skipif(sys.platform in {'win32', 'cygwin'}, reason='requires RPATH support') +def test_link_against_local_lib_rpath_ldflags(package_link_against_local_lib, tmp_path, monkeypatch): + origin = '@loader_path' if sys.platform == 'darwin' else '$ORIGIN' + extra_rpath = {f'{origin}/test-ldflags', '/usr/lib/test-ldflags'} + ldflags = ' '.join(f'-Wl,-rpath,{p}' for p in extra_rpath) + monkeypatch.setenv('LDFLAGS', ldflags) + + filename = mesonpy.build_wheel(tmp_path) + artifact = wheel.wheelfile.WheelFile(tmp_path / filename) + artifact.extractall(tmp_path) + + rpath = set(mesonpy._rpath.get_rpath(tmp_path / 'example' / f'_example{EXT_SUFFIX}')) + assert extra_rpath <= rpath @pytest.mark.skipif(sys.platform in {'win32', 'cygwin'}, reason='requires RPATH support') @@ -203,11 +289,26 @@ def test_uneeded_rpath(wheel_purelib_and_platlib, tmp_path): artifact.extractall(tmp_path) origin = '@loader_path' if sys.platform == 'darwin' else '$ORIGIN' - rpath = mesonpy._rpath._get_rpath(tmp_path / f'plat{EXT_SUFFIX}') + rpath = mesonpy._rpath.get_rpath(tmp_path / f'plat{EXT_SUFFIX}') for path in rpath: assert origin not in path +@pytest.mark.skipif(sys.platform in {'win32', 'cygwin'}, reason='requires RPATH support') +@pytest.mark.filterwarnings('ignore:translated "install_rpath" argument for ') +def test_rpath_install_precedence(venv, wheel_same_name_sharedlibs, tmp_path): + artifact = wheel.wheelfile.WheelFile(wheel_same_name_sharedlibs) + artifact.extractall(tmp_path) + + origin = '@loader_path' if sys.platform == 'darwin' else '$ORIGIN' + rpath = mesonpy._rpath.get_rpath(tmp_path / 'same_name' / f'choice{EXT_SUFFIX}') + assert rpath.index(f'{origin}/private') < rpath.index(f'{origin}/competing') + + venv.pip('install', wheel_same_name_sharedlibs) + output = venv.python('-c', 'from same_name import choice; print(choice.value())') + assert int(output) == 42 + + @pytest.mark.skipif(sys.platform in {'win32', 'cygwin'}, reason='requires executable bit support') def test_executable_bit(wheel_executable_bit): artifact = wheel.wheelfile.WheelFile(wheel_executable_bit)