diff --git a/lib/init/grass.py b/lib/init/grass.py index 7a7fb4f31cd..3091a60d3e0 100755 --- a/lib/init/grass.py +++ b/lib/init/grass.py @@ -2176,12 +2176,20 @@ 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) + # 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 grass_config_dir = create_grass_config_dir() diff --git a/python/grass/app/runtime.py b/python/grass/app/runtime.py index b34e5c0c0cf..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") @@ -28,59 +28,101 @@ 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 + if prefix: + self._custom_prefix = os.path.normpath(prefix) + self._custom_prefix = self._custom_prefix.removesuffix( + resource_paths.GISBASE + ) + else: + self._custom_prefix = None + 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): - 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 gisbase(self): - return self.__get_dir("GISBASE") + def config_projshare(self): + return self.env.get("GRASS_PROJSHARE", resource_paths.CONFIG_PROJSHARE) @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): + if self._custom_prefix: + return self._custom_prefix + return os.path.normpath(resource_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]) - path = getattr(res_paths, env_var) - return os.path.normpath(os.path.join(res_paths.GRASS_PREFIX, path)) + # Default to path from the installation + path = getattr(resource_paths, env_var) + return os.path.normpath(os.path.join(self.prefix, path)) def get_grass_config_dir(*, env): @@ -184,16 +226,13 @@ 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 ) # 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, ) 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..87918c90b6c --- /dev/null +++ b/python/grass/app/tests/grass_app_resource_paths_test.py @@ -0,0 +1,41 @@ +import os + +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 + + +@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) 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..739c3be6c96 --- /dev/null +++ b/python/grass/app/tests/grass_app_runtime_test.py @@ -0,0 +1,203 @@ +"""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 import resource_paths +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("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, path_type): + """Check that the prefix attribute is set from constructor""" + paths = RuntimePaths(env={}, prefix=path_type(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)) + + +@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) + + +@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""" + env = {} + 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(): + """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..c1faaf82b6b 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,25 @@ 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() + # 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, grass_config_dir=get_grass_config_dir(env=env), diff --git a/python/grass/script/tests/grass_script_setup_test.py b/python/grass/script/tests/grass_script_setup_test.py index 9f24ab7f8b6..ec1cc6cc491 100644 --- a/python/grass/script/tests/grass_script_setup_test.py +++ b/python/grass/script/tests/grass_script_setup_test.py @@ -2,9 +2,11 @@ import os import sys + import subprocess import json from textwrap import dedent +from pathlib import Path import pytest @@ -142,9 +144,13 @@ def test_init_finish_global_functions_capture_strerr(tmp_path, capture_stderr): gs.setup.init(r"{project}") crs_type = gs.parse_command("g.region", flags="p", format="json")["crs"]["type"] runtime_present = bool(os.environ.get("GISBASE")) + gisbase_exists = ( + os.path.exists(os.environ.get("GISBASE")) if runtime_present else False + ) result = {{ "session_file": os.environ["GISRC"], "runtime_present": runtime_present, + "gisbase_exists": gisbase_exists, "crs_type": crs_type }} gs.setup.finish() @@ -153,6 +159,7 @@ def test_init_finish_global_functions_capture_strerr(tmp_path, capture_stderr): result = run_in_subprocess(code, tmp_path=tmp_path) assert result["session_file"], "Expected file name from the subprocess" assert result["runtime_present"], RUNTIME_GISBASE_SHOULD_BE_PRESENT + assert result["gisbase_exists"], "Install directory should exist" assert not os.path.exists(result["session_file"]), SESSION_FILE_NOT_DELETED assert result["crs_type"] == "xy" @@ -435,3 +442,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