diff --git a/python/grass/app/runtime.py b/python/grass/app/runtime.py index 110013f69cb..98adf465564 100644 --- a/python/grass/app/runtime.py +++ b/python/grass/app/runtime.py @@ -12,6 +12,7 @@ """ import collections +import ctypes import os import shutil import subprocess @@ -329,6 +330,44 @@ def set_dynamic_library_path(variable_name, install_path, env): env[variable_name] += os.pathsep + os.path.join(install_path, "lib") +def preload_dynamic_libraries(install_path): + """Load GRASS dynamic libraries into the current process. + + The dynamic linker reads the library search path variable (set by + :func:`set_dynamic_library_path`) only at process startup, so setting it + in an already running process is not enough for the GRASS libraries to + find each other when a library is loaded later, e.g., through + :mod:`grass.lib`. Loading the libraries here by their full path works + around that because the dynamic linker resolves dependencies of any + library loaded later against the already loaded ones. + + Libraries can be loaded only after the libraries they depend on, so + loading is repeated until it makes no progress. Libraries which never + load, e.g., due to a missing optional system dependency, are silently + skipped; importing the corresponding :mod:`grass.lib` module fails + either way. + + On Windows, this is not needed because the loading of DLLs is driven by + directories from PATH, which is read when the DLL is loaded. + """ + if WINDOWS: + return + lib_path = Path(install_path) / "lib" + remaining = sorted(lib_path.glob("libgrass_*.so")) + sorted( + lib_path.glob("libgrass_*.dylib") + ) + while remaining: + failed = [] + for library in remaining: + try: + ctypes.CDLL(str(library)) + except OSError: + failed.append(library) + if len(failed) == len(remaining): + break + remaining = failed + + def set_python_path_variable(install_path, env): """Set PYTHONPATH to find GRASS Python package in subprocesses""" path = env.get("PYTHONPATH") diff --git a/python/grass/script/setup.py b/python/grass/script/setup.py index 454bbe5d0e2..e26e6118d54 100644 --- a/python/grass/script/setup.py +++ b/python/grass/script/setup.py @@ -214,6 +214,7 @@ def setup_runtime_env(gisbase=None, *, env=None): """ from grass.app.runtime import ( get_grass_config_dir, + preload_dynamic_libraries, set_dynamic_library_path, set_executable_paths, set_path_to_python_executable, @@ -252,6 +253,9 @@ def setup_runtime_env(gisbase=None, *, env=None): set_dynamic_library_path( variable_name=runtime_paths.ld_library_path_var, install_path=gisbase, env=env ) + # The variable set above applies only to newly started processes, so load + # the libraries into the current process for ctypes-based interfaces. + preload_dynamic_libraries(install_path=gisbase) set_python_path_variable(install_path=gisbase, env=env) set_path_to_python_executable(env=env) @@ -305,10 +309,10 @@ def init( standard main executable grass. No GRASS modules shall be called before call of this function but any module or user script can be called afterwards because a GRASS session has been set up. GRASS Python - libraries are usable as well in general but the ones using C - libraries through ``ctypes`` are not (which is caused by library - path not being updated for the current process which is a common - operating system limitation). + libraries are usable as well, including the ones using C libraries + through ``ctypes`` (``grass.lib``) which work because the C libraries + are loaded into the current process (operating systems don't apply + a modified library search path to an already running process). When the path or specified mapset does not exist, ValueError is raised. diff --git a/python/grass/script/tests/grass_script_setup_test.py b/python/grass/script/tests/grass_script_setup_test.py index 454ee04d736..2bb216b3ec2 100644 --- a/python/grass/script/tests/grass_script_setup_test.py +++ b/python/grass/script/tests/grass_script_setup_test.py @@ -204,6 +204,33 @@ def test_init_finish_global_functions_runtime_persists(tmp_path): assert result["region_data"]["crs"]["type"] == "xy" +@pytest.mark.usefixtures("mock_no_session") +def test_init_makes_ctypes_libraries_loadable(tmp_path): + """Check that grass.lib imports work after init in the global environment. + + The subprocess is started without the dynamic library search path variable, + so importing grass.lib works only if init loads the GRASS libraries + into the process. + """ + project = tmp_path / "test" + code = f""" + import json + import grass.script as gs + + gs.create_project(r"{project}") + with gs.setup.init(r"{project}"): + import grass.lib.gis as libgis + + libgis.G_gisinit("test") + print(json.dumps({{"gis_init_worked": True}})) + """ + env = os.environ.copy() + env.pop("LD_LIBRARY_PATH", None) + env.pop("DYLD_LIBRARY_PATH", None) + result = run_in_subprocess(code, tmp_path=tmp_path, env=env) + assert result["gis_init_worked"] + + @pytest.mark.usefixtures("mock_no_session") def test_init_finish_global_functions_set_environment(tmp_path): """Check that init and finish global functions work with global env.