Skip to content

Fix issues in loading GMT's shared library #977

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Mar 6, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 28 additions & 21 deletions pygmt/clib/loading.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,18 @@
from pygmt.exceptions import GMTCLibError, GMTCLibNotFoundError, GMTOSError


def load_libgmt():
def load_libgmt(lib_fullnames=None):
"""
Find and load ``libgmt`` as a :py:class:`ctypes.CDLL`.

By default, will look for the shared library in the directory specified by
the environment variable ``GMT_LIBRARY_PATH``. If it's not set, will let
ctypes try to find the library.
Will look for the GMT shared library in the directories determined by
clib_full_names().

Parameters
----------
lib_fullnames : list of str or None
List of possible full names of GMT's shared library. If ``None``, will
default to ``clib_full_names()``.

Returns
-------
Expand All @@ -33,22 +38,26 @@ def load_libgmt():
If there was any problem loading the library (couldn't find it or
couldn't access the functions).
"""
lib_fullnames = []
if lib_fullnames is None:
lib_fullnames = clib_full_names()

error = True
for libname in clib_full_names():
lib_fullnames.append(libname)
error_msg = []
failing_libs = []
for libname in lib_fullnames:
try:
libgmt = ctypes.CDLL(libname)
check_libgmt(libgmt)
error = False
break
except OSError as err:
error = err
if libname not in failing_libs: # skip the lib if it's known to fail
libgmt = ctypes.CDLL(libname)
check_libgmt(libgmt)
error = False
break
except (OSError, GMTCLibError) as err:
error_msg.append(f"Error loading GMT shared library at '{libname}'.\n{err}")
failing_libs.append(libname)

if error:
raise GMTCLibNotFoundError(
"Error loading the GMT shared library "
f"{', '.join(lib_fullnames)}.\n {error}."
)
raise GMTCLibNotFoundError("\n".join(error_msg))

return libgmt


Expand All @@ -66,16 +75,14 @@ def clib_names(os_name):
libnames : list of str
List of possible names of GMT's shared library.
"""
if os_name.startswith("linux"):
if os_name.startswith(("linux", "freebsd")):
libnames = ["libgmt.so"]
elif os_name == "darwin": # Darwin is macOS
libnames = ["libgmt.dylib"]
elif os_name == "win32":
libnames = ["gmt.dll", "gmt_w64.dll", "gmt_w32.dll"]
elif os_name.startswith("freebsd"): # FreeBSD
libnames = ["libgmt.so"]
else:
raise GMTOSError(f'Operating system "{os_name}" not supported.')
raise GMTOSError(f"Operating system '{os_name}' not supported.")
return libnames


Expand Down
159 changes: 144 additions & 15 deletions pygmt/tests/test_clib_loading.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
Test the functions that load libgmt.
"""
import ctypes
import shutil
import subprocess
import sys
Expand All @@ -12,15 +13,23 @@
from pygmt.exceptions import GMTCLibError, GMTCLibNotFoundError, GMTOSError


class FakedLibGMT: # pylint: disable=too-few-public-methods
"""
Class for faking a GMT library.
"""

def __init__(self, name):
self._name = name

def __str__(self):
return self._name


def test_check_libgmt():
"""
Make sure check_libgmt fails when given a bogus library.
"""
# create a fake library with a "_name" property
def libgmt():
pass

libgmt._name = "/path/to/libgmt.so" # pylint: disable=protected-access
libgmt = FakedLibGMT("/path/to/libgmt.so")
msg = (
# pylint: disable=protected-access
f"Error loading '{libgmt._name}'. "
Expand All @@ -33,6 +42,22 @@ def libgmt():
check_libgmt(libgmt)


def test_clib_names():
"""
Make sure we get the correct library name for different OS names.
"""
for linux in ["linux", "linux2", "linux3"]:
assert clib_names(linux) == ["libgmt.so"]
assert clib_names("darwin") == ["libgmt.dylib"]
assert clib_names("win32") == ["gmt.dll", "gmt_w64.dll", "gmt_w32.dll"]
for freebsd in ["freebsd10", "freebsd11", "freebsd12"]:
assert clib_names(freebsd) == ["libgmt.so"]
with pytest.raises(GMTOSError):
clib_names("meh")


###############################################################################
# Tests for load_libgmt
def test_load_libgmt():
"""
Test that loading libgmt works and doesn't crash.
Expand Down Expand Up @@ -64,18 +89,122 @@ def test_load_libgmt_with_a_bad_library_path(monkeypatch):
assert check_libgmt(load_libgmt()) is None


def test_clib_names():
class TestLibgmtBrokenLibs:
"""
Make sure we get the correct library name for different OS names.
Test that load_libgmt still works when a broken library is found.
"""
for linux in ["linux", "linux2", "linux3"]:
assert clib_names(linux) == ["libgmt.so"]
assert clib_names("darwin") == ["libgmt.dylib"]
assert clib_names("win32") == ["gmt.dll", "gmt_w64.dll", "gmt_w32.dll"]
for freebsd in ["freebsd10", "freebsd11", "freebsd12"]:
assert clib_names(freebsd) == ["libgmt.so"]
with pytest.raises(GMTOSError):
clib_names("meh")

# load the GMT library before mocking the ctypes.CDLL function
loaded_libgmt = load_libgmt()
invalid_path = "/invalid/path/to/libgmt.so"
faked_libgmt1 = FakedLibGMT("/path/to/faked/libgmt1.so")
faked_libgmt2 = FakedLibGMT("/path/to/faked/libgmt2.so")

def _mock_ctypes_cdll_return(self, libname):
"""
Mock the return value of ctypes.CDLL.

Parameters
----------
libname : str or FakedLibGMT or ctypes.CDLL
Path to the GMT library, a faked GMT library, or a working library
loaded as ctypes.CDLL.

Return
------
object
Either the loaded GMT library or the faked GMT library.
"""
if isinstance(libname, FakedLibGMT):
# libname is a faked GMT library, return the faked library
return libname
if isinstance(libname, str):
# libname is an invalid library path in string type,
# raise OSError like the original ctypes.CDLL
raise OSError(f"Unable to find '{libname}'")
# libname is a loaded GMT library
return self.loaded_libgmt

@pytest.fixture
def mock_ctypes(self, monkeypatch):
"""
Patch the ctypes.CDLL function.
"""
monkeypatch.setattr(ctypes, "CDLL", self._mock_ctypes_cdll_return)

def test_two_broken_libraries(self, mock_ctypes): # pylint: disable=unused-argument
"""
Case 1: two broken libraries.

Raise the GMTCLibNotFoundError exception. Error message should contain
information of both libraries that failed to load properly.
"""
# pylint: disable=protected-access
lib_fullnames = [self.faked_libgmt1, self.faked_libgmt2]
msg_regex = (
fr"Error loading GMT shared library at '{self.faked_libgmt1._name}'.\n"
fr"Error loading '{self.faked_libgmt1._name}'. Couldn't access.*\n"
fr"Error loading GMT shared library at '{self.faked_libgmt2._name}'.\n"
f"Error loading '{self.faked_libgmt2._name}'. Couldn't access.*"
)
with pytest.raises(GMTCLibNotFoundError, match=msg_regex):
load_libgmt(lib_fullnames=lib_fullnames)

def test_load_brokenlib_invalidpath(
self, mock_ctypes
): # pylint: disable=unused-argument
"""
Case 2: broken library + invalid path.

Raise the GMTCLibNotFoundError exception. Error message should contain
information of one library that failed to load and one invalid path.
"""
# pylint: disable=protected-access
lib_fullnames = [self.faked_libgmt1, self.invalid_path]
msg_regex = (
fr"Error loading GMT shared library at '{self.faked_libgmt1._name}'.\n"
fr"Error loading '{self.faked_libgmt1._name}'. Couldn't access.*\n"
fr"Error loading GMT shared library at '{self.invalid_path}'.\n"
f"Unable to find '{self.invalid_path}'"
)
with pytest.raises(GMTCLibNotFoundError, match=msg_regex):
load_libgmt(lib_fullnames=lib_fullnames)

def test_brokenlib_invalidpath_workinglib(
self, mock_ctypes
): # pylint: disable=unused-argument
"""
Case 3: broken library + invalid path + working library.
"""
lib_fullnames = [self.faked_libgmt1, self.invalid_path, self.loaded_libgmt]
assert check_libgmt(load_libgmt(lib_fullnames=lib_fullnames)) is None

def test_invalidpath_brokenlib_workinglib(
self, mock_ctypes
): # pylint: disable=unused-argument
"""
Case 4: invalid path + broken library + working library.
"""
lib_fullnames = [self.invalid_path, self.faked_libgmt1, self.loaded_libgmt]
assert check_libgmt(load_libgmt(lib_fullnames=lib_fullnames)) is None

def test_workinglib_brokenlib_invalidpath(
self, mock_ctypes
): # pylint: disable=unused-argument
"""
Case 5: working library + broken library + invalid path.
"""
lib_fullnames = [self.loaded_libgmt, self.faked_libgmt1, self.invalid_path]
assert check_libgmt(load_libgmt(lib_fullnames=lib_fullnames)) is None

def test_brokenlib_brokenlib_workinglib(
self, mock_ctypes
): # pylint: disable=unused-argument
"""
Case 6: repeating broken libraries + working library.
"""
lib_fullnames = [self.faked_libgmt1, self.faked_libgmt1, self.loaded_libgmt]
assert check_libgmt(load_libgmt(lib_fullnames=lib_fullnames)) is None


###############################################################################
Expand Down