diff --git a/findlibs/__init__.py b/findlibs/__init__.py index 438b2d9..97f133a 100644 --- a/findlibs/__init__.py +++ b/findlibs/__init__.py @@ -13,27 +13,72 @@ import importlib import os import sys +import warnings +from collections import defaultdict from pathlib import Path +from types import ModuleType __version__ = "0.0.5" -EXTENSIONS = { - "darwin": ".dylib", - "win32": ".dll", -} - - -def _find_in_package(lib_name: str, pkg_name: str) -> str | None: +EXTENSIONS = defaultdict( + lambda: ".so", + darwin=".dylib", + win32=".dll", +) +DYLIB_PATH = defaultdict( + lambda: "LD_LIBRARY_PATH", + darwin="DYLD_LIBRARY_PATH", + # win32? May be trickier +) + + +def _extend_dylib_path_with(p: str) -> None: + """See _find_in_package""" + current = os.environ.get(DYLIB_PATH[sys.platform], "") + if not current: + extended = p + else: + extended = f"{p}:{current}" + os.environ[DYLIB_PATH[sys.platform]] = extended + + +def _transitive_dylib_path_extension(module: ModuleType) -> None: + """See _find_in_package""" + # NOTE consider replacing hasattr with entrypoint-based declaration + # https://packaging.python.org/en/latest/specifications/entry-points/ + if hasattr(module, "findlibs_dependencies"): + for module_name in module.findlibs_dependencies: + try: + rec_into = importlib.import_module(module_name) + ext_path = str(Path(rec_into.__file__).parent) + _extend_dylib_path_with(ext_path) + _transitive_dylib_path_extension(rec_into) + except ImportError: + # NOTE we don't use ImportWarning here as thats off by default + warnings.warn( + f"unable to import {module_name} yet declared as dependency of {module.__name__}" + ) + + +def _find_in_package( + lib_name: str, pkg_name: str, trans_ext_dylib: bool = True +) -> str | None: """Tries to find the library in an installed python module `{pgk_name}libs`. This is a convention used by, for example, by newly built binary-only ecmwf - packages, such as eckit dlibs in the "eckitlib" python module.""" + packages, such as eckit dlibs in the "eckitlib" python module. + + If trans_ext_dylib is True, it additionally extends platform linker's dylib path + (LD_LIBRARY_PATH / DYLD_LIBRARY_PATH) with dependencies declared in the module's + init. This is needed if the `.so`s in the wheel don't have correct rpath""" # NOTE we could have searched for relative location wrt __file__ -- but that # breaks eg editable installs of findlibs, conda-venv combinations, etc. # The price we pay is that the binary packages have to be importible, ie, # the default output of auditwheel wont work try: module = importlib.import_module(pkg_name + "libs") - venv_wheel_lib = str((Path(module.__file__) / ".." / lib_name).resolve()) + if trans_ext_dylib: + _transitive_dylib_path_extension(module) + venv_wheel_lib = str((Path(module.__file__).parent / lib_name)) if os.path.exists(venv_wheel_lib): return venv_wheel_lib except ImportError: @@ -194,7 +239,7 @@ def find(lib_name: str, pkg_name: str | None = None) -> str | None: Path to selected library """ pkg_name = pkg_name or lib_name - extension = EXTENSIONS.get(sys.platform, ".so") + extension = EXTENSIONS[sys.platform] lib_name = "lib{}{}".format(lib_name, extension) sources = ( diff --git a/tests/transitive/__init__.py b/tests/transitive/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/transitive/modAlibs/__init__.py b/tests/transitive/modAlibs/__init__.py new file mode 100644 index 0000000..539462a --- /dev/null +++ b/tests/transitive/modAlibs/__init__.py @@ -0,0 +1 @@ +findlibs_dependencies = ["modBlibs"] diff --git a/tests/transitive/modAlibs/libmodA.so b/tests/transitive/modAlibs/libmodA.so new file mode 100644 index 0000000..e69de29 diff --git a/tests/transitive/modBlibs/__init__.py b/tests/transitive/modBlibs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/transitive/test_transitive.py b/tests/transitive/test_transitive.py new file mode 100644 index 0000000..2988605 --- /dev/null +++ b/tests/transitive/test_transitive.py @@ -0,0 +1,25 @@ +import importlib +import os +import sys +from pathlib import Path + +from findlibs import DYLIB_PATH, find + + +def test_transitive() -> None: + """There is a module modAlibs in this directory that mocks expected bin wheel contract: + - modulename ends with 'libs' + - contains libmodA.so + - inside __init__ there is the findlibs_dependencies, containing modBlibs + This test checks that when such module is findlibs-found, it extend the platform's dylib + env var with the full path to the modBlibs module + """ + + sys.path.append(str(Path(__file__).parent)) + + found = find("modA") + expected_found = str(Path(__file__).parent / "modAlibs" / "libmodA.so") + assert found == expected_found + + expected_dylib = str(Path(__file__).parent / "modBlibs") + assert expected_dylib in os.environ[DYLIB_PATH[sys.platform]]