Skip to content
Open
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
39 changes: 39 additions & 0 deletions python/grass/app/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"""

import collections
import ctypes
import os
import shutil
import subprocess
Expand Down Expand Up @@ -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")
Expand Down
12 changes: 8 additions & 4 deletions python/grass/script/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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.

Expand Down
27 changes: 27 additions & 0 deletions python/grass/script/tests/grass_script_setup_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading