diff --git a/src_py/_backend.py b/src_py/_backend.py index 1109e62..9b1ff49 100644 --- a/src_py/_backend.py +++ b/src_py/_backend.py @@ -8,6 +8,7 @@ _CAPI_MODULE: Any | None = None _PYBIND_MODULE: Any | None = None _PYBIND_IMPORT_ATTEMPTED = False +_PYBIND_IMPORT_ERROR: ImportError | None = None def _import_pybind_module() -> Any: @@ -32,12 +33,35 @@ def get_capi_module() -> Any: def get_pybind_module() -> Any | None: - global _PYBIND_MODULE, _PYBIND_IMPORT_ATTEMPTED + global _PYBIND_MODULE, _PYBIND_IMPORT_ATTEMPTED, _PYBIND_IMPORT_ERROR if _PYBIND_IMPORT_ATTEMPTED: return _PYBIND_MODULE _PYBIND_IMPORT_ATTEMPTED = True try: _PYBIND_MODULE = _import_pybind_module() - except ImportError: + except ImportError as exc: _PYBIND_MODULE = None + _PYBIND_IMPORT_ERROR = exc return _PYBIND_MODULE + + +def missing_capi_library_message() -> str: + """ + Explain why neither backend could load. + + The C API library is the fallback when the bundled ``_lbug`` extension fails to import, so a missing C API + library usually hides the real failure. Include that failure, such as a missing OpenSSL 3 runtime. + """ + msg = ( + "Could not find lbug C API shared library. " + "Set LBUG_C_API_LIB_PATH or download a shared lib (e.g. run " + "LBUG_LIB_KIND=shared bash scripts/download_lbug.sh)." + ) + if _PYBIND_IMPORT_ERROR is None: + return msg + msg += f" The bundled _lbug extension, which is tried first, failed to load: {_PYBIND_IMPORT_ERROR}" + if "libssl" in str(_PYBIND_IMPORT_ERROR) or "libcrypto" in str( + _PYBIND_IMPORT_ERROR + ): + msg += " Ladybug needs an OpenSSL 3 runtime; on macOS, install it with `brew install openssl@3`." + return msg diff --git a/src_py/_lbug_capi.py b/src_py/_lbug_capi.py index 332249d..95806a6 100644 --- a/src_py/_lbug_capi.py +++ b/src_py/_lbug_capi.py @@ -178,12 +178,9 @@ def _resolve_library_path() -> str: if found: return found - msg = ( - "Could not find lbug C API shared library. " - "Set LBUG_C_API_LIB_PATH or download a shared lib (e.g. run " - "LBUG_LIB_KIND=shared bash scripts/download_lbug.sh)." - ) - raise RuntimeError(msg) + from ._backend import missing_capi_library_message + + raise RuntimeError(missing_capi_library_message()) _dlopen_mode = getattr(ctypes, "RTLD_GLOBAL", 0) | getattr(ctypes, "RTLD_NOW", 0) diff --git a/test/test_backend_errors.py b/test/test_backend_errors.py new file mode 100644 index 0000000..1934051 --- /dev/null +++ b/test/test_backend_errors.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import ladybug._backend as backend + + +def test_missing_capi_message_without_pybind_error(monkeypatch) -> None: + monkeypatch.setattr(backend, "_PYBIND_IMPORT_ERROR", None) + msg = backend.missing_capi_library_message() + assert msg.startswith("Could not find lbug C API shared library.") + assert "_lbug extension" not in msg + + +def test_missing_capi_message_reports_pybind_error(monkeypatch) -> None: + error = ImportError( + "dlopen(_lbug.cpython-312-darwin.so, 0x0002): Library not loaded: /opt/homebrew/opt/openssl@3/lib/libssl.3.dylib" + ) + monkeypatch.setattr(backend, "_PYBIND_IMPORT_ERROR", error) + msg = backend.missing_capi_library_message() + assert "Library not loaded: /opt/homebrew/opt/openssl@3/lib/libssl.3.dylib" in msg + assert "brew install openssl@3" in msg + + +def test_failed_pybind_import_is_recorded(monkeypatch) -> None: + error = ImportError("Library not loaded: libcrypto.3.dylib") + + def fail() -> None: + raise error + + monkeypatch.setattr(backend, "_PYBIND_IMPORT_ATTEMPTED", False) + monkeypatch.setattr(backend, "_PYBIND_MODULE", None) + monkeypatch.setattr(backend, "_PYBIND_IMPORT_ERROR", None) + monkeypatch.setattr(backend, "_import_pybind_module", fail) + assert backend.get_pybind_module() is None + assert "libcrypto.3.dylib" in str(backend._PYBIND_IMPORT_ERROR)