From 2a40860f0d887dc6f2d092ccfaf592b206c1a353 Mon Sep 17 00:00:00 2001 From: Anna Petrasova Date: Tue, 25 Aug 2026 10:35:11 -0400 Subject: [PATCH 1/3] CI: Use the actual Python path of the relocated installation The installation directory is the configured prefix plus a directory with the version (grass86), so the etc/python subdirectory is not directly in the prefix and the step failed to import grass at all. Ask the grass command for the path before the installation is moved, since the command does not work afterwards. --- .github/workflows/macos.yml | 5 ++++- .github/workflows/pytest.yml | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 4af9cdd998a..e6c2dc701fa 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -133,12 +133,15 @@ jobs: - name: Run pytest for loading C libraries into the process shell: micromamba-shell {0} run: | + # The path is asked for before the move because the grass command + # does not work from the moved installation. + python_path="$(grass --config python_path)" # The installation is moved away from the prefix it was configured # with, so that the libraries cannot find each other through their # RUNPATH and the loading done by init is the only mechanism left. mv "${HOME}/install" "${HOME}/install-relocated" trap 'mv "${HOME}/install-relocated" "${HOME}/install"' EXIT - PYTHONPATH="${HOME}/install-relocated/etc/python" + PYTHONPATH="${HOME}/install-relocated${python_path#"${HOME}/install"}" export PYTHONPATH pytest python/grass/script/tests/grass_script_setup_load_libs_test.py \ --junitxml=pytest.load_libs.junit.xml diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 34529baac82..201f5a8222d 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -140,12 +140,15 @@ jobs: --junitxml=pytest.gunittest.junit.xml - name: Run pytest for loading C libraries into the process run: | + # The path is asked for before the move because the grass command + # does not work from the moved installation. + python_path="$(grass --config python_path)" # The installation is moved away from the prefix it was configured # with, so that the libraries cannot find each other through their # RUNPATH and the loading done by init is the only mechanism left. mv "${HOME}/install" "${HOME}/install-relocated" trap 'mv "${HOME}/install-relocated" "${HOME}/install"' EXIT - PYTHONPATH="${HOME}/install-relocated/etc/python" + PYTHONPATH="${HOME}/install-relocated${python_path#"${HOME}/install"}" export PYTHONPATH pytest python/grass/script/tests/grass_script_setup_load_libs_test.py \ --junitxml=pytest.load_libs.junit.xml From 5c75151755bf19742192bc9ffe1888892a9c3801 Mon Sep 17 00:00:00 2001 From: Anna Petrasova Date: Tue, 25 Aug 2026 10:35:25 -0400 Subject: [PATCH 2/3] grass.script: Report which session the C libraries see in load_libs tests On Windows, the first test fails with the subprocess exiting with 1 and no output at all, which is what a fatal error in the C library looks like from Python. To tell a wrong session from a failed call, the session the library resolved is written to stderr before the raster is opened, and the test with a custom environment now checks that the library works with the session which init created (it can pass without checking it because another session in the environment works too). The failure message now also includes both output streams. --- .../grass_script_setup_load_libs_test.py | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/python/grass/script/tests/grass_script_setup_load_libs_test.py b/python/grass/script/tests/grass_script_setup_load_libs_test.py index efeacee37a4..37442ae3645 100644 --- a/python/grass/script/tests/grass_script_setup_load_libs_test.py +++ b/python/grass/script/tests/grass_script_setup_load_libs_test.py @@ -38,7 +38,10 @@ def run_in_clean_environment(code, tmp_path): check=False, env=env, ) - assert result.returncode == 0, result.stderr + assert result.returncode == 0, ( + f"code failed with {result.returncode}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) return json.loads(result.stdout) @@ -48,6 +51,8 @@ def test_grass_lib_usable_with_load_libs(tmp_path): project = tmp_path / "test" code = f""" import json + import sys + import grass.script as gs gs.create_project(r"{project}") @@ -56,6 +61,17 @@ def test_grass_lib_usable_with_load_libs(tmp_path): import grass.lib.raster as libraster libgis.G_gisinit(b"test") + # An error in the C library ends the process before Python can + # report anything, so the session the library resolved is written + # out right away to tell a wrong session from a failed call. + print( + "session seen by the C library:", + libgis.G_gisdbase(), + libgis.G_location(), + libgis.G_mapset(), + file=sys.stderr, + flush=True, + ) gs.run_command("g.region", rows=2, cols=2) gs.mapcalc("ones = 1") fd = libraster.Rast_open_old(b"ones", b"") @@ -85,17 +101,28 @@ def test_grass_lib_usable_with_load_libs_and_custom_env(tmp_path): import grass.lib.gis as libgis libgis.G_gisinit(b"test") + session = [ + libgis.G_gisdbase().decode(), + libgis.G_location().decode(), + libgis.G_mapset().decode(), + ] print( json.dumps( {{ - "gis_init_worked": True, + "session_seen_by_c_library": session, "gisbase_in_global_env": "GISBASE" in os.environ, }} ) ) """ result = run_in_clean_environment(code, tmp_path=tmp_path) - assert result["gis_init_worked"] + # The library works with the session which init created, not with another + # one it may have found in the environment. + assert result["session_seen_by_c_library"] == [ + str(tmp_path), + project.name, + "PERMANENT", + ] # The session did not fall back to the global environment for the lookup. assert not result["gisbase_in_global_env"] From 73d5bee7be6367f0ec808e94d073a4d407316b9b Mon Sep 17 00:00:00 2001 From: Anna Petrasova Date: Tue, 25 Aug 2026 11:53:05 -0400 Subject: [PATCH 3/3] grass.script: Give the C libraries the session with load_libs The libraries read GISBASE and GISRC with getenv from the environment of the process, which is not the session environment (the env parameter of init) and, on Windows, not even the global environment, because the C runtime keeps a copy of the environment made when the process started. The variables are therefore set with G_putenv, which runs inside the libraries and so writes the environment they read. Without GISBASE, a library which reports an error fails again in the reporting itself, and the re-entrancy guard of G_fatal_error ends the process without a message, which is what the Windows and macOS failures looked like. A test now checks that a message from a library is reported, so that a silent exit is not mistaken for a passing test. The tests keep the completed process so that both output streams can be shown when the code in the subprocess fails. Written with the help of Claude Code. --- python/grass/app/runtime.py | 30 +++++++++++ python/grass/script/setup.py | 19 +++++++ .../grass_script_setup_load_libs_test.py | 53 ++++++++++++------- 3 files changed, 83 insertions(+), 19 deletions(-) diff --git a/python/grass/app/runtime.py b/python/grass/app/runtime.py index 28c4f5d6a96..6c02d1ebaa0 100644 --- a/python/grass/app/runtime.py +++ b/python/grass/app/runtime.py @@ -356,6 +356,36 @@ def register_library_search_path(install_path): _registered_library_dirs.add(lib_path) +# Variables the C libraries read with getenv: GISBASE to find the files of the +# installation and GISRC to find the session. +LIBRARY_ENVIRONMENT_VARIABLES = ("GISBASE", "GISRC") + + +def set_library_environment(env): + """Set variables from _env_ in the environment which the C libraries read + + The libraries use getenv, which reads the environment of the process, not + the session environment (the _env_ parameter of + :func:`grass.script.setup.init`). On Windows, the C runtime keeps its own + copy of the environment made when the process started, so there even + changes to the global environment do not reach the libraries. + + Without GISBASE, reporting an error fails in the same way as the call + which caused it, and the library ends the process without a message, so + the variables are needed even to see what is wrong. + """ + try: + from grass.lib.gis import G_putenv + except ImportError: + # The grass.lib package is generated during the build and an + # installation may be missing it. + return + for name in LIBRARY_ENVIRONMENT_VARIABLES: + value = env.get(name) + if value: + G_putenv(name, value) + + # Every other GRASS library needs libgrass_gis and libgrass_gis needs # libgrass_datetime, so these two are loaded before the rest. A library loaded # before the GRASS libraries it needs makes the dynamic linker resolve those diff --git a/python/grass/script/setup.py b/python/grass/script/setup.py index 5be02c1448e..df29926f110 100644 --- a/python/grass/script/setup.py +++ b/python/grass/script/setup.py @@ -222,6 +222,7 @@ def setup_runtime_env(gisbase=None, *, env=None, load_libs=False): register_library_search_path, set_dynamic_library_path, set_executable_paths, + set_library_environment, set_path_to_python_executable, set_python_path_variable, RuntimePaths, @@ -263,6 +264,9 @@ def setup_runtime_env(gisbase=None, *, env=None, load_libs=False): # the libraries into the current process for ctypes-based interfaces. register_library_search_path(install_path=gisbase) preload_dynamic_libraries(install_path=gisbase) + # The libraries read the installation path from their own environment. + # A session adds its variables to this later (see init). + set_library_environment(env) set_python_path_variable(install_path=gisbase, env=env) set_path_to_python_executable(env=env) @@ -325,6 +329,15 @@ def init( GRASS C stack and its dependencies (GDAL, PROJ, ...) into the process, which sessions using only tools don't need. + With *load_libs*, the session variables are also set in the environment + of the loaded libraries, which the libraries read for themselves and + which is shared by the whole process. Consequently, the last session + initialized in a process is the one :mod:`grass.lib` works with, even + when the sessions keep their variables in their own environments. + Subprocesses started without an explicit environment inherit that + environment, so they see the session even when it is otherwise limited + to *env*. + When the path or specified mapset does not exist, ValueError is raised. The :func:`get_install_path` function is used to determine where @@ -439,6 +452,12 @@ def init( env["GISRC"] = write_gisrc( mapset_path.directory, mapset_path.location, mapset_path.mapset ) + if load_libs: + # The libraries were loaded before the session file existed, so they + # learn about the session only now. + from grass.app.runtime import set_library_environment + + set_library_environment(env) return SessionHandle(env=env, locked=lock) diff --git a/python/grass/script/tests/grass_script_setup_load_libs_test.py b/python/grass/script/tests/grass_script_setup_load_libs_test.py index 37442ae3645..64296e711cc 100644 --- a/python/grass/script/tests/grass_script_setup_load_libs_test.py +++ b/python/grass/script/tests/grass_script_setup_load_libs_test.py @@ -25,6 +25,8 @@ def run_in_clean_environment(code, tmp_path): The variable is what a parent GRASS session or a manual setup uses to make the GRASS libraries available, so removing it leaves the loading done by init as the only mechanism which can make grass.lib work. + + :returns: the completed process, so that the output streams are available """ source_file = tmp_path / "code.py" source_file.write_text(dedent(code)) @@ -42,7 +44,7 @@ def run_in_clean_environment(code, tmp_path): f"code failed with {result.returncode}\n" f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" ) - return json.loads(result.stdout) + return result @pytest.mark.usefixtures("mock_no_session") @@ -51,8 +53,6 @@ def test_grass_lib_usable_with_load_libs(tmp_path): project = tmp_path / "test" code = f""" import json - import sys - import grass.script as gs gs.create_project(r"{project}") @@ -61,33 +61,24 @@ def test_grass_lib_usable_with_load_libs(tmp_path): import grass.lib.raster as libraster libgis.G_gisinit(b"test") - # An error in the C library ends the process before Python can - # report anything, so the session the library resolved is written - # out right away to tell a wrong session from a failed call. - print( - "session seen by the C library:", - libgis.G_gisdbase(), - libgis.G_location(), - libgis.G_mapset(), - file=sys.stderr, - flush=True, - ) gs.run_command("g.region", rows=2, cols=2) gs.mapcalc("ones = 1") fd = libraster.Rast_open_old(b"ones", b"") libraster.Rast_close(fd) print(json.dumps({{"raster_opened": True}})) """ - assert run_in_clean_environment(code, tmp_path=tmp_path)["raster_opened"] + result = run_in_clean_environment(code, tmp_path=tmp_path) + assert json.loads(result.stdout)["raster_opened"] @pytest.mark.usefixtures("mock_no_session") def test_grass_lib_usable_with_load_libs_and_custom_env(tmp_path): """Check that grass.lib is usable with load_libs and a custom environment - The grass.lib loader reads GISBASE from the global environment, so a - session which keeps its variables in its own environment needs the path - to the libraries passed to the loader directly. + A session which keeps its variables in its own environment reaches the + libraries only through the loader and through the environment of the + libraries themselves, because they read neither the session environment + nor, on Windows, the global one. """ project = tmp_path / "test" code = f""" @@ -115,7 +106,7 @@ def test_grass_lib_usable_with_load_libs_and_custom_env(tmp_path): ) ) """ - result = run_in_clean_environment(code, tmp_path=tmp_path) + result = json.loads(run_in_clean_environment(code, tmp_path=tmp_path).stdout) # The library works with the session which init created, not with another # one it may have found in the environment. assert result["session_seen_by_c_library"] == [ @@ -127,6 +118,30 @@ def test_grass_lib_usable_with_load_libs_and_custom_env(tmp_path): assert not result["gisbase_in_global_env"] +@pytest.mark.usefixtures("mock_no_session") +def test_library_messages_are_reported(tmp_path): + """Check that a message from the C libraries reaches the standard error + + Printing a message needs GISBASE, so a library which does not have it + fails to report the failure as well and ends the process without saying + anything at all. + """ + project = tmp_path / "test" + code = f""" + import os + import grass.script as gs + + gs.create_project(r"{project}") + with gs.setup.init(r"{project}", env=os.environ.copy(), load_libs=True): + import grass.lib.gis as libgis + + libgis.G_warning(b"message from the library") + print("{{}}") + """ + result = run_in_clean_environment(code, tmp_path=tmp_path) + assert "message from the library" in result.stderr + + @pytest.mark.usefixtures("mock_no_session") def test_libraries_not_loaded_by_default(tmp_path, monkeypatch): """Check that a session does not load the C libraries unless asked to"""