From 3e41e1c49070888a56b63aef1b612a3dff35083b Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 10 Oct 2025 13:23:18 -0400 Subject: [PATCH 01/10] grass.app: Do env var setup in RuntimePaths In anticiaption of #5630 which adds multiple variables besides GISBASE, this modifies RuntimePaths to handle variables from a list (dict to be exact). Now, only one variable is handled that way and that is GISBASE. The GRASS_PREFIX variable is handled in a different way because the compile-time values of the other variables are without a prefix and the RuntimePaths is prefixing them when the variable is needed. The RuntimePaths object can now set environment variables when explicitly asked to do so. This hides the info about all the variables handled by the object inside the class code, so the caller does not need to know about a change from one GISBASE to multiple GISBASE-like variables. Both the main grass executable and the Python init function now use RuntimePaths to set up the GISBASE (and other GISBASE-like variables in the future). While both usages of RuntimePaths are similar, they are not completely the same. Python init takes optional, caller-provided gisbase which is used as a prefix (assuming prefix and gisbase are, in practice, the same), while the main executable always uses the RuntimePaths default. Both test if the gisbase from RuntimePaths exists, and if not, they go to get_install_path to get a fallback one. To allow for the fallback to take effect, RuntimePaths can take prefix in the constructor and will use it instead of the compile-time determined GRASS_PREFIX. --- lib/init/grass.py | 10 +- python/grass/app/runtime.py | 67 +++++-- python/grass/app/tests/grass_app_runtime.py | 83 -------- .../grass/app/tests/grass_app_runtime_test.py | 185 ++++++++++++++++++ python/grass/script/setup.py | 23 ++- 5 files changed, 256 insertions(+), 112 deletions(-) delete mode 100644 python/grass/app/tests/grass_app_runtime.py create mode 100644 python/grass/app/tests/grass_app_runtime_test.py diff --git a/lib/init/grass.py b/lib/init/grass.py index a599a1acbad..e8d869707d9 100755 --- a/lib/init/grass.py +++ b/lib/init/grass.py @@ -2179,12 +2179,18 @@ def main() -> None: GISBASE, \ CONFIG_PROJSHARE - runtime_paths = RuntimePaths() + runtime_paths = RuntimePaths(set_env_variables=True) CMD_NAME = runtime_paths.grass_exe_name GRASS_VERSION = runtime_paths.version GRASS_VERSION_MAJOR = runtime_paths.version_major GRASS_VERSION_GIT = runtime_paths.grass_version_git - GISBASE = get_install_path(runtime_paths.gisbase) + gisbase = runtime_paths.gisbase + if not os.path.isdir(gisbase): + gisbase = get_install_path(gisbase) + # Set the main prefix again. + # See also grass.script.setup.setup_runtime_env. + runtime_paths = RuntimePaths(set_env_variables=True, prefix=gisbase) + GISBASE = gisbase CONFIG_PROJSHARE = runtime_paths.config_projshare grass_config_dir = create_grass_config_dir() diff --git a/python/grass/app/runtime.py b/python/grass/app/runtime.py index b34e5c0c0cf..6b4ebd8eb3d 100644 --- a/python/grass/app/runtime.py +++ b/python/grass/app/runtime.py @@ -28,13 +28,36 @@ class RuntimePaths: """Get runtime paths to resources and basic GRASS build properties - The resource paths are also set as environmental variables. + The resource paths are accessible as attributes (e.g., `.gisbase`, `.etc_dir`) + and can optionally be exported to environment variables. + + Example: + + >>> paths = RuntimePaths(set_env_variables=True) + >>> paths.etc_dir + '/usr/lib/grass/etc' + >>> os.environ["GRASS_ETCDIR"] + '/usr/lib/grass/etc' """ - def __init__(self, env=None): + # Mapping of attribute names to environment variable name except the prefix. + _env_vars = { + "gisbase": "GISBASE", + } + + def __init__(self, *, env=None, set_env_variables=False, prefix=None): if env is None: env = os.environ self.env = env + self._custom_prefix = os.path.normpath(prefix) if prefix else prefix + if set_env_variables: + self.set_env_variables() + + def set_env_variables(self): + """Populate all GRASS-related environment variables.""" + self.env["GRASS_PREFIX"] = self.prefix + for env_var in self._env_vars.values(): + self.env[env_var] = self.__get_dir(env_var, use_env_values=False) @property def version(self): @@ -60,27 +83,40 @@ def grass_exe_name(self): def grass_version_git(self): return res_paths.GRASS_VERSION_GIT - @property - def gisbase(self): - return self.__get_dir("GISBASE") - - @property - def prefix(self): - return self.__get_dir("GRASS_PREFIX") - @property def config_projshare(self): return self.env.get("GRASS_PROJSHARE", res_paths.CONFIG_PROJSHARE) - def __get_dir(self, env_var): + @property + def prefix(self): + if self._custom_prefix: + return self._custom_prefix + return os.path.normpath(res_paths.GRASS_PREFIX) + + def __getattr__(self, name): + """Access paths by attributes.""" + if name in self._env_vars: + env_var = self._env_vars[name] + return self.__get_dir(env_var) + msg = f"{type(self).__name__!r} has no attribute {name!r}" + raise AttributeError(msg) + + def __dir__(self): + """List both static and dynamic attributes.""" + base_dir = set(super().__dir__()) + dynamic_dir = set(self._env_vars.keys()) + return sorted(base_dir | dynamic_dir) + + def __get_dir(self, env_var, *, use_env_values=True): """Get the directory stored in the environmental variable 'env_var' - If the environmental variable not yet set, it is retrived and + If the environmental variable not yet set, it is retrieved and set from resource_paths.""" - if env_var in self.env and len(self.env[env_var]) > 0: + if use_env_values and env_var in self.env and self.env[env_var]: return os.path.normpath(self.env[env_var]) + # Default to path from the installation path = getattr(res_paths, env_var) - return os.path.normpath(os.path.join(res_paths.GRASS_PREFIX, path)) + return os.path.normpath(os.path.join(self.prefix, path)) def get_grass_config_dir(*, env): @@ -184,9 +220,6 @@ def set_executable_paths(install_path, grass_config_dir, env): def set_paths(install_path, grass_config_dir): """Set variables with executable paths, library paths, and other paths""" - # Set main prefix. - # See also grass.script.setup.setup_runtime_env. - os.environ["GISBASE"] = install_path set_executable_paths( install_path=install_path, grass_config_dir=grass_config_dir, env=os.environ ) diff --git a/python/grass/app/tests/grass_app_runtime.py b/python/grass/app/tests/grass_app_runtime.py deleted file mode 100644 index af0bb4be677..00000000000 --- a/python/grass/app/tests/grass_app_runtime.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Tests of runtime setup, mostly focused on paths - -The install path (aka GISBASE) tests are assuming, or focusing on a non-FHS -installation. Their potential to fail is with broken builds and installs. -""" - -from pathlib import Path - -import pytest - -from grass.app.runtime import RuntimePaths -from grass.script.setup import get_install_path - - -def return_as_is(x): - """Return the parameter exactly as received""" - return x - - -def test_install_path_consistent(): - """Differently sourced install paths should be the same. - - Dynamically determined install path and compile-time install path should be - the same in a healthy installation. - - This is a non-FHS oriented test. - """ - assert get_install_path() == RuntimePaths().gisbase - - -@pytest.mark.parametrize("path_type", [str, Path, return_as_is]) -def test_install_path_used_as_result(path_type): - """Passing a valid compile-time install path should return the same path. - - If the path is not recognized as install path or there is a problem with the - dynamic determination of the install path, the test will fail. - - This is a non-FHS oriented test. - """ - path = RuntimePaths().gisbase - assert get_install_path(path_type(path)) == path - - -def test_consistent_install_path_returned(): - """Two subsequent calls should return the same result. - - The environment should not be modified by the call, so the result should - always be the same. - - This is a non-FHS oriented test. - """ - assert get_install_path() == get_install_path() - - -@pytest.mark.parametrize("path_type", [str, Path, return_as_is]) -def test_feeding_output_as_input_again(path_type): - """Passing result of the get_install_path back to it should give the same result. - - When a dynamically path gets returned by the function, the same path should be - the one returned again when called with that path (sort of like calling the same - function twice because we can't tell here if it is a newly computed path or the - provided path if they are the same). - - We use this to test different path types. - - This is a non-FHS oriented test. - """ - path = get_install_path() - assert path == get_install_path(path_type(path)) - - -@pytest.mark.parametrize("path_type", [str, Path, return_as_is]) -def test_passing_non_existent_path(path_type): - """Passing result of the get_install_path back to it should give the same result. - - When a dynamically path gets returned by the function, the same path should be - the one returned again when called with that path (sort of like calling the same - function twice because we can't tell here if it is a newly computed path or the - provided path if they are the same). - - This is a non-FHS oriented test. - """ - assert get_install_path(path_type("/does/not/exist")) == get_install_path() diff --git a/python/grass/app/tests/grass_app_runtime_test.py b/python/grass/app/tests/grass_app_runtime_test.py new file mode 100644 index 00000000000..d133adda2c8 --- /dev/null +++ b/python/grass/app/tests/grass_app_runtime_test.py @@ -0,0 +1,185 @@ +"""Tests of runtime setup, mostly focused on paths + +The install path (aka GISBASE) tests are assuming, or focusing on a non-FHS +installation. Their potential to fail is with broken builds and installs. +""" + +import os +from pathlib import Path + +import pytest + +from grass.app.runtime import RuntimePaths +from grass.script.setup import get_install_path + + +def return_as_is(x): + """Return the parameter exactly as received""" + return x + + +def test_prefix_set(): + """Check that the prefix attribute is set by default""" + paths = RuntimePaths(env={}) + assert paths.prefix + + +@pytest.mark.parametrize( + "custom_prefix", + ["/custom/prefix/path", "/custom/prefix/path/", "/path with spaces"], +) +def test_custom_prefix_set(custom_prefix): + """Check that the prefix attribute is set from constructor""" + paths = RuntimePaths(env={}, prefix=custom_prefix) + assert paths.prefix == os.path.normpath(custom_prefix) + + +def test_gisbase_prefixed(): + """Check that GISBASE should start with prefix""" + paths = RuntimePaths(env={}) + assert paths.gisbase.startswith(paths.prefix) + + +@pytest.mark.parametrize( + "custom_prefix", + ["/custom/prefix/path", "/custom/prefix/path/", "/path with spaces"], +) +def test_gisbase_with_custom_prefix(custom_prefix): + """Check that GISBASE should start with custom prefix""" + paths = RuntimePaths(env={}, prefix=custom_prefix) + assert paths.gisbase.startswith(os.path.normpath(custom_prefix)) + + +def test_env_gisbase_with_custom_prefix(): + """Check that GISBASE should start with custom prefix""" + custom_prefix = "/custom/prefix/path" + env = {} + RuntimePaths(env=env, prefix=custom_prefix, set_env_variables=True) + assert env["GISBASE"].startswith(custom_prefix) + + +def test_attr_access_does_not_modify_env(): + """Accessing attribute should not change environment.""" + env = {} + paths = RuntimePaths(env=env) + assert "GISBASE" not in env + value = paths.gisbase # access the attribute + assert value + assert "GISBASE" not in env, "env was modified unexpectedly" + + +def test_explicit_env_vars_set(): + """Explicit call should set the env vars.""" + env = {} + paths = RuntimePaths(env=env) + paths.set_env_variables() + assert "GISBASE" in env + assert env["GISBASE"] == paths.gisbase + + +def test_constructor_parameter_env_vars_set(): + """Constructor with parameter should set the env vars.""" + env = {} + paths = RuntimePaths(env=env, set_env_variables=True) + assert "GISBASE" in env + assert env["GISBASE"] == paths.gisbase + + +def test_dir_lists_dynamic_attributes_but_does_not_modify_env(): + """dir() should show dynamic attrs but not set env.""" + env = {} + paths = RuntimePaths(env=env) + listing = dir(paths) + assert "gisbase" in listing + assert "GISBASE" not in env, "dir() should not modify env" + + +def test_existing_env_value_is_respected(): + """If env already contains GISBASE, its value is used.""" + value = "/custom/path/to/grass" + env = {"GISBASE": value} + paths = RuntimePaths(env=env) + assert paths.gisbase == os.path.normpath(value) + assert env["GISBASE"] == value + + +def test_invalid_attribute_raises(): + """Unknown attribute access should raise AttributeError.""" + paths = RuntimePaths(env={}) + with pytest.raises(AttributeError): + assert paths.unknown_attribute # avoiding unused value + + +def test_returned_attribute_consistent(): + """Repeated accesses should return the same value.""" + paths = RuntimePaths(env={}) + first = paths.gisbase + second = paths.gisbase + assert first == second + assert first == RuntimePaths(env={}).gisbase + + +def test_install_path_consistent(): + """Differently sourced install paths should be the same. + + Dynamically determined install path and compile-time install path should be + the same in a healthy installation. + + This is a non-FHS oriented test. + """ + assert get_install_path() == RuntimePaths().gisbase + + +@pytest.mark.parametrize("path_type", [str, Path, return_as_is]) +def test_install_path_used_as_result(path_type): + """Passing a valid compile-time install path should return the same path. + + If the path is not recognized as install path or there is a problem with the + dynamic determination of the install path, the test will fail. + + This is a non-FHS oriented test. + """ + path = RuntimePaths().gisbase + assert get_install_path(path_type(path)) == path + + +def test_consistent_install_path_returned(): + """Two subsequent calls should return the same result. + + The environment should not be modified by the call, so the result should + always be the same. + + This is a non-FHS oriented test. + """ + assert get_install_path() == get_install_path() + + +@pytest.mark.parametrize("path_type", [str, Path, return_as_is]) +def test_feeding_output_as_input_again(path_type): + """Passing result of the get_install_path back to it should give the same result. + + When a dynamically path gets returned by the function, the same path should be + the one returned again when called with that path (sort of like calling the same + function twice because we can't tell here if it is a newly computed path or the + provided path if they are the same). + + We use this to test different path types. + + This is a non-FHS oriented test. + """ + path = get_install_path() + assert path == get_install_path(path_type(path)) + + +@pytest.mark.parametrize("path_type", [str, Path, return_as_is]) +def test_passing_non_existent_path(path_type): + """Passing result of the get_install_path back to it should give the same result. + + When a dynamically path gets returned by the function, the same path should be + the one returned again when called with that path (sort of like calling the same + function twice because we can't tell here if it is a newly computed path or the + provided path if they are the same). + + This is a non-FHS oriented test. + """ + assert get_install_path(path_type("/does/not/exist")) == get_install_path() diff --git a/python/grass/script/setup.py b/python/grass/script/setup.py index b70beb3ad7f..0d84504e8ba 100644 --- a/python/grass/script/setup.py +++ b/python/grass/script/setup.py @@ -212,12 +212,6 @@ def setup_runtime_env(gisbase=None, *, env=None): If _gisbase_ is not provided, a heuristic is used to find the path to GRASS installation (see the :func:`get_install_path` function for details). """ - gisbase = get_install_path(gisbase) - - # If environment is not provided, use the global one. - if not env: - env = os.environ - from grass.app.runtime import ( get_grass_config_dir, set_dynamic_library_path, @@ -227,10 +221,19 @@ def setup_runtime_env(gisbase=None, *, env=None): RuntimePaths, ) - runtime_paths = RuntimePaths(env=env) - # Set main prefix. - # See also grass.app.runtime.set_paths. - env["GISBASE"] = gisbase + # If environment is not provided, use the global one. + if not env: + env = os.environ + + runtime_paths = RuntimePaths(env=env, prefix=gisbase) + gisbase = runtime_paths.gisbase + if not os.path.isdir(gisbase): + gisbase = get_install_path(gisbase) + # Set the main prefix again. + # See also the main grass executable code. + runtime_paths = RuntimePaths(env=env, prefix=gisbase) + runtime_paths.set_env_variables() + set_executable_paths( install_path=gisbase, grass_config_dir=get_grass_config_dir(env=env), From 6aca879cf75d7616541f10b2e77f4977cfffec28 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 10 Oct 2025 14:25:24 -0400 Subject: [PATCH 02/10] Allow mixing of gisbase and prefix-only on the input which is needed for real installations. If the path is full GISBASE, the unique GISBASE part is removed, remove it to get the prefix only. The corresponding test passes with non-trivial GISBASE. Also, the consistency tests fail when the compile-time prefix path is non-sense. --- python/grass/app/runtime.py | 6 +++++- python/grass/app/tests/grass_app_runtime_test.py | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/python/grass/app/runtime.py b/python/grass/app/runtime.py index 6b4ebd8eb3d..14d28cf3c43 100644 --- a/python/grass/app/runtime.py +++ b/python/grass/app/runtime.py @@ -49,7 +49,11 @@ def __init__(self, *, env=None, set_env_variables=False, prefix=None): if env is None: env = os.environ self.env = env - self._custom_prefix = os.path.normpath(prefix) if prefix else prefix + if prefix: + self._custom_prefix = os.path.normpath(prefix) + self._custom_prefix = self._custom_prefix.removesuffix(res_paths.GISBASE) + else: + self._custom_prefix = None if set_env_variables: self.set_env_variables() diff --git a/python/grass/app/tests/grass_app_runtime_test.py b/python/grass/app/tests/grass_app_runtime_test.py index d133adda2c8..ca247da1000 100644 --- a/python/grass/app/tests/grass_app_runtime_test.py +++ b/python/grass/app/tests/grass_app_runtime_test.py @@ -9,6 +9,7 @@ import pytest +from grass.app import resource_paths from grass.app.runtime import RuntimePaths from grass.script.setup import get_install_path @@ -50,6 +51,18 @@ def test_gisbase_with_custom_prefix(custom_prefix): assert paths.gisbase.startswith(os.path.normpath(custom_prefix)) +@pytest.mark.parametrize( + "custom_prefix", + ["/custom/prefix/path", "/custom/prefix/path/", "/path with spaces"], +) +def test_gisbase_and_prefix_mix(custom_prefix): + """Check passing a custom prefix which is actually GISBASE""" + # resource_paths.GISBASE is just the unique part after the prefix. + custom_prefix = os.path.join(custom_prefix, resource_paths.GISBASE) + paths = RuntimePaths(env={}, prefix=custom_prefix) + assert paths.gisbase == os.path.normpath(custom_prefix) + + def test_env_gisbase_with_custom_prefix(): """Check that GISBASE should start with custom prefix""" custom_prefix = "/custom/prefix/path" From 7bdd6cd3ebf5a32fed40f97964675763fc03069c Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 10 Oct 2025 14:47:52 -0400 Subject: [PATCH 03/10] Avoid alias and use the module name directly --- python/grass/app/runtime.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/python/grass/app/runtime.py b/python/grass/app/runtime.py index 14d28cf3c43..407802c7a73 100644 --- a/python/grass/app/runtime.py +++ b/python/grass/app/runtime.py @@ -17,7 +17,7 @@ import subprocess import sys -from . import resource_paths as res_paths +from . import resource_paths # Get the system name WINDOWS = sys.platform.startswith("win") @@ -51,7 +51,9 @@ def __init__(self, *, env=None, set_env_variables=False, prefix=None): self.env = env if prefix: self._custom_prefix = os.path.normpath(prefix) - self._custom_prefix = self._custom_prefix.removesuffix(res_paths.GISBASE) + self._custom_prefix = self._custom_prefix.removesuffix( + resource_paths.GISBASE + ) else: self._custom_prefix = None if set_env_variables: @@ -65,37 +67,37 @@ def set_env_variables(self): @property def version(self): - return res_paths.GRASS_VERSION + return resource_paths.GRASS_VERSION @property def version_major(self): - return res_paths.GRASS_VERSION_MAJOR + return resource_paths.GRASS_VERSION_MAJOR @property def version_minor(self): - return res_paths.GRASS_VERSION_MINOR + return resource_paths.GRASS_VERSION_MINOR @property def ld_library_path_var(self): - return res_paths.LD_LIBRARY_PATH_VAR + return resource_paths.LD_LIBRARY_PATH_VAR @property def grass_exe_name(self): - return res_paths.GRASS_EXE_NAME + return resource_paths.GRASS_EXE_NAME @property def grass_version_git(self): - return res_paths.GRASS_VERSION_GIT + return resource_paths.GRASS_VERSION_GIT @property def config_projshare(self): - return self.env.get("GRASS_PROJSHARE", res_paths.CONFIG_PROJSHARE) + return self.env.get("GRASS_PROJSHARE", resource_paths.CONFIG_PROJSHARE) @property def prefix(self): if self._custom_prefix: return self._custom_prefix - return os.path.normpath(res_paths.GRASS_PREFIX) + return os.path.normpath(resource_paths.GRASS_PREFIX) def __getattr__(self, name): """Access paths by attributes.""" @@ -119,7 +121,7 @@ def __get_dir(self, env_var, *, use_env_values=True): if use_env_values and env_var in self.env and self.env[env_var]: return os.path.normpath(self.env[env_var]) # Default to path from the installation - path = getattr(res_paths, env_var) + path = getattr(resource_paths, env_var) return os.path.normpath(os.path.join(self.prefix, path)) @@ -230,7 +232,7 @@ def set_paths(install_path, grass_config_dir): # Set LD_LIBRARY_PATH (etc) to find GRASS shared libraries. # This works for subprocesses, but won't affect the current process. set_dynamic_library_path( - variable_name=res_paths.LD_LIBRARY_PATH_VAR, + variable_name=resource_paths.LD_LIBRARY_PATH_VAR, install_path=install_path, env=os.environ, ) From 986d3e42cb2afd370310a8899c13e6c55d30927b Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 10 Oct 2025 15:01:46 -0400 Subject: [PATCH 04/10] Add tests for different types of path --- .../script/tests/grass_script_setup_test.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/python/grass/script/tests/grass_script_setup_test.py b/python/grass/script/tests/grass_script_setup_test.py index e0c66ae1fd3..4ce6bc450bc 100644 --- a/python/grass/script/tests/grass_script_setup_test.py +++ b/python/grass/script/tests/grass_script_setup_test.py @@ -4,6 +4,7 @@ import os import sys from functools import partial +from pathlib import Path import pytest @@ -370,3 +371,28 @@ def test_init_lock_timeout_fail(tmp_path): gs.setup.init(project, env=os.environ.copy(), lock=True, timeout=2), ): pass + + +@pytest.mark.parametrize("project_path_type", [str, Path]) +@pytest.mark.parametrize("grass_path_type", [str, Path]) +def test_grass_path_types_in_init(tmp_path, project_path_type, grass_path_type): + """Check that different path types are accepted as path to grass""" + # We test with project path type just to be sure there is no interaction. + project = project_path_type(tmp_path / "test") + gs.create_project(project) + grass_path = grass_path_type(gs.setup.get_install_path()) + with gs.setup.init( + project, grass_path=grass_path, env=os.environ.copy() + ) as session: + gs.run_command("g.region", res=1, env=session.env) + + +@pytest.mark.parametrize("path_type", [str, Path]) +def test_grass_path_types_in_setup(tmp_path, path_type): + """Check that different path types are accepted as path to grass""" + # We test with project path type just to be sure there is no interaction. + grass_path = path_type(gs.setup.get_install_path()) + env = os.environ.copy() + gs.setup.setup_runtime_env(grass_path, env=env) + # At least before FHS, GISBASE is the way to detect an active runtime. + assert "GISBASE" in env From 21ea68f4e2e6f07bad4268238bf89547c9479586 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 10 Oct 2025 15:43:30 -0400 Subject: [PATCH 05/10] Test that resource paths are substituted during build --- .../tests/grass_app_resource_paths_test.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 python/grass/app/tests/grass_app_resource_paths_test.py diff --git a/python/grass/app/tests/grass_app_resource_paths_test.py b/python/grass/app/tests/grass_app_resource_paths_test.py new file mode 100644 index 00000000000..3cdea2cf36b --- /dev/null +++ b/python/grass/app/tests/grass_app_resource_paths_test.py @@ -0,0 +1,32 @@ +import pytest + +from grass.app import resource_paths + + +# This is just a sample, not trying to test all. +@pytest.mark.parametrize( + "name", + [ + "GRASS_VERSION", + "GRASS_VERSION_MAJOR", + "LD_LIBRARY_PATH_VAR", + "GRASS_VERSION_GIT", + ], +) +def test_non_path_values_substituted(name): + value = getattr(resource_paths, name) + assert not (value.startswith("@") and value.endswith("@")) + + +# This is just a sample, not trying to test all. +@pytest.mark.parametrize("name", ["GRASS_PREFIX", "GISBASE"]) +def test_path_values_substituted(name): + value = getattr(resource_paths, name) + assert not (value.startswith("@") and value.endswith("@")) + + +# GISBASE may be empty after build because everything goes to prefix. +@pytest.mark.parametrize("name", ["GRASS_PREFIX"]) +def test_value_not_empty(name): + value = getattr(resource_paths, name) + assert value From aeedf02698adc21432ac46266bc690d593452294 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 10 Oct 2025 15:50:48 -0400 Subject: [PATCH 06/10] Test different file types and more paths. Normalize all paths before testing (fails on Windows otherwise) --- .../grass/app/tests/grass_app_runtime_test.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/python/grass/app/tests/grass_app_runtime_test.py b/python/grass/app/tests/grass_app_runtime_test.py index ca247da1000..739c3be6c96 100644 --- a/python/grass/app/tests/grass_app_runtime_test.py +++ b/python/grass/app/tests/grass_app_runtime_test.py @@ -25,13 +25,14 @@ def test_prefix_set(): assert paths.prefix +@pytest.mark.parametrize("path_type", [str, Path]) @pytest.mark.parametrize( "custom_prefix", ["/custom/prefix/path", "/custom/prefix/path/", "/path with spaces"], ) -def test_custom_prefix_set(custom_prefix): +def test_custom_prefix_set(custom_prefix, path_type): """Check that the prefix attribute is set from constructor""" - paths = RuntimePaths(env={}, prefix=custom_prefix) + paths = RuntimePaths(env={}, prefix=path_type(custom_prefix)) assert paths.prefix == os.path.normpath(custom_prefix) @@ -63,12 +64,16 @@ def test_gisbase_and_prefix_mix(custom_prefix): assert paths.gisbase == os.path.normpath(custom_prefix) -def test_env_gisbase_with_custom_prefix(): +@pytest.mark.parametrize("path_type", [str, Path]) +@pytest.mark.parametrize( + "custom_prefix", + ["/custom/prefix/path", "/custom/prefix/path/", "/path with spaces"], +) +def test_env_gisbase_with_custom_prefix(custom_prefix, path_type): """Check that GISBASE should start with custom prefix""" - custom_prefix = "/custom/prefix/path" env = {} - RuntimePaths(env=env, prefix=custom_prefix, set_env_variables=True) - assert env["GISBASE"].startswith(custom_prefix) + RuntimePaths(env=env, prefix=path_type(custom_prefix), set_env_variables=True) + assert env["GISBASE"].startswith(os.path.normpath(custom_prefix)) def test_attr_access_does_not_modify_env(): From d260a02a2b8448e00c7a77c8f595328e1f44d889 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 10 Oct 2025 16:17:02 -0400 Subject: [PATCH 07/10] Add test for GISBASE existence to the test failing in subprocess --- python/grass/script/tests/grass_script_setup_test.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/python/grass/script/tests/grass_script_setup_test.py b/python/grass/script/tests/grass_script_setup_test.py index 4ce6bc450bc..2889397cb6c 100644 --- a/python/grass/script/tests/grass_script_setup_test.py +++ b/python/grass/script/tests/grass_script_setup_test.py @@ -83,7 +83,10 @@ def init_finish_global_functions_capture_strerr0_partial(tmp_path, queue): gs.setup.init(project) gs.run_command("g.region", flags="p") runtime_present = bool(os.environ.get("GISBASE")) - queue.put((os.environ["GISRC"], runtime_present)) + gisbase_exists = ( + os.path.exists(os.environ.get("GISBASE")) if runtime_present else False + ) + queue.put((os.environ["GISRC"], runtime_present, gisbase_exists)) gs.setup.finish() @@ -95,9 +98,10 @@ def test_init_finish_global_functions_capture_strerr0_partial(tmp_path): init_finish = partial( init_finish_global_functions_capture_strerr0_partial, tmp_path ) - session_file, runtime_present = run_in_subprocess(init_finish) + session_file, runtime_present, gisbase_exists = run_in_subprocess(init_finish) assert session_file, "Expected file name from the subprocess" assert runtime_present, RUNTIME_GISBASE_SHOULD_BE_PRESENT + assert gisbase_exists, "GISBASE directory should exist" assert not os.path.exists(session_file), SESSION_FILE_NOT_DELETED From 454604d5973df0934339f52f51a727e96e62d7f2 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 10 Oct 2025 16:37:16 -0400 Subject: [PATCH 08/10] Test that values are directories (for prefix only) --- python/grass/app/tests/grass_app_resource_paths_test.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/python/grass/app/tests/grass_app_resource_paths_test.py b/python/grass/app/tests/grass_app_resource_paths_test.py index 3cdea2cf36b..87918c90b6c 100644 --- a/python/grass/app/tests/grass_app_resource_paths_test.py +++ b/python/grass/app/tests/grass_app_resource_paths_test.py @@ -1,3 +1,5 @@ +import os + import pytest from grass.app import resource_paths @@ -30,3 +32,10 @@ def test_path_values_substituted(name): def test_value_not_empty(name): value = getattr(resource_paths, name) assert value + + +@pytest.mark.parametrize("name", ["GRASS_PREFIX"]) +def test_value_is_directory(name): + value = getattr(resource_paths, name) + assert os.path.exists(value) + assert os.path.isdir(value) From 2007c998c506faad83000479b1d53ddd820024b3 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Thu, 16 Oct 2025 17:26:23 -0400 Subject: [PATCH 09/10] When we provide a corrected gisbase as a prefix, a broken build will supply wrong gisbase to the prefix breaking gisbase again, so we need to manually fix it with a subsequent call. This is not nice, and we need to have it at two different places now, but it is not a overly complicated code. --- lib/init/grass.py | 2 ++ python/grass/script/setup.py | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/init/grass.py b/lib/init/grass.py index 401f5bd0814..3091a60d3e0 100755 --- a/lib/init/grass.py +++ b/lib/init/grass.py @@ -2187,6 +2187,8 @@ def main() -> None: # Set the main prefix again. # See also grass.script.setup.setup_runtime_env. runtime_paths = RuntimePaths(set_env_variables=True, prefix=gisbase) + # Do not trust the value it came up with, and use the one which we determined. + os.environ["GISBASE"] = gisbase GISBASE = gisbase CONFIG_PROJSHARE = runtime_paths.config_projshare diff --git a/python/grass/script/setup.py b/python/grass/script/setup.py index 0d84504e8ba..c1faaf82b6b 100644 --- a/python/grass/script/setup.py +++ b/python/grass/script/setup.py @@ -232,7 +232,13 @@ def setup_runtime_env(gisbase=None, *, env=None): # Set the main prefix again. # See also the main grass executable code. runtime_paths = RuntimePaths(env=env, prefix=gisbase) - runtime_paths.set_env_variables() + runtime_paths.set_env_variables() + # The mechanism already failed once to set it, so overwrite + # the path it set with what we know exists. (This will make + # it work when the variable is not substituted.) + os.environ["GISBASE"] = gisbase + else: + runtime_paths.set_env_variables() set_executable_paths( install_path=gisbase, From 5f3c06869049bf193f277d0957d7656deb31dfe9 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Thu, 16 Oct 2025 19:14:51 -0400 Subject: [PATCH 10/10] Fix missing empty line (missed with pre-commit) --- python/grass/script/tests/grass_script_setup_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/grass/script/tests/grass_script_setup_test.py b/python/grass/script/tests/grass_script_setup_test.py index 34fd9e7efa1..ec1cc6cc491 100644 --- a/python/grass/script/tests/grass_script_setup_test.py +++ b/python/grass/script/tests/grass_script_setup_test.py @@ -122,6 +122,7 @@ def test_init_finish_global_functions_with_env(tmp_path): assert not os.path.exists(session_file) + @pytest.mark.parametrize("capture_stderr", [True, False, None]) @pytest.mark.usefixtures("mock_no_session") def test_init_finish_global_functions_capture_strerr(tmp_path, capture_stderr):