diff --git a/python/grass/script/setup.py b/python/grass/script/setup.py index fa7b205e191..2d9d0a6892f 100644 --- a/python/grass/script/setup.py +++ b/python/grass/script/setup.py @@ -137,12 +137,27 @@ def get_install_path(path: str | Path | None = None) -> str: If an existing executable is called as a subprocess is called during the search and it fails, the CalledProcessError exception is propagated from the subprocess call. + + The executable is not queried when this function itself runs in a process + started by such a query. The *grass* command calls this function when the + installation path it was compiled with does not exist, so without this rule + the command and this function would keep starting each other. """ + # Set in the environment of the queried executable to mark the process + # which answers the query (see the docstring). + query_variable = "GRASS_INSTALL_PATH_QUERY" + can_query_executable = query_variable not in os.environ def ask_executable(arg): """Query the GRASS executable for the path""" + env = os.environ.copy() + env[query_variable] = "1" return subprocess.run( - [arg, "--config", "path"], text=True, check=True, capture_output=True + [arg, "--config", "path"], + text=True, + check=True, + capture_output=True, + env=env, ).stdout.strip() # Directory was provided as a parameter. @@ -150,7 +165,7 @@ def ask_executable(arg): return os.fspath(path) # Executable was provided as parameter. - if path and shutil.which(path): + if path and can_query_executable and shutil.which(path): # The path was provided by the user and it is an executable # (on path or provided with full path), so raise exception on failure. path_from_executable = ask_executable(path) @@ -166,7 +181,7 @@ def ask_executable(arg): # The variable is supported (here), documented, but not widely promoted # at this point (to be re-evaluated). grass_bin = os.environ.get("GRASSBIN") - if grass_bin and shutil.which(grass_bin): + if grass_bin and can_query_executable and shutil.which(grass_bin): path_from_executable = ask_executable(grass_bin) if Path(path_from_executable).is_dir(): return path_from_executable @@ -187,7 +202,7 @@ def ask_executable(arg): # with multiple installations (where an explicit setup is likely required). # However, it allows for non-standard installations with standard command. grass_bin = "grass" - if grass_bin and shutil.which(grass_bin): + if can_query_executable and shutil.which(grass_bin): path_from_executable = ask_executable(grass_bin) if Path(path_from_executable).is_dir(): return path_from_executable diff --git a/python/grass/script/tests/grass_script_setup_test.py b/python/grass/script/tests/grass_script_setup_test.py index 454ee04d736..a11cac06852 100644 --- a/python/grass/script/tests/grass_script_setup_test.py +++ b/python/grass/script/tests/grass_script_setup_test.py @@ -483,3 +483,45 @@ def test_init_removes_mapset_variables(tmp_path, variable): env[variable] = "value" with gs.setup.init(project, env=env) as session: assert variable not in session.env + + +@pytest.mark.skipif( + sys.platform.startswith("win"), reason="Uses a shebang to make a fake executable" +) +def test_get_install_path_executable_query_does_not_recurse(tmp_path, monkeypatch): + """Check that an executable which queries the path itself is asked only once + + The grass command calls get_install_path() when its own path is wrong and + get_install_path() runs the grass command as a last resort, so the two + would start each other forever without a guard. The fake executable records + each call and refuses to go deeper than a few levels so that a broken guard + fails the test instead of flooding the system with processes. + """ + monkeypatch.delenv("GRASSBIN", raising=False) + calls = tmp_path / "calls" + calls.mkdir() + executable = tmp_path / "fake_grass" + executable.write_text( + dedent( + f"""\ + #!{sys.executable} + import os + import sys + import uuid + from pathlib import Path + + depth = int(os.environ.get("FAKE_GRASS_DEPTH", "0")) + if depth > 2: + sys.exit("recursion not stopped") + os.environ["FAKE_GRASS_DEPTH"] = str(depth + 1) + Path({os.fspath(calls)!r}, str(uuid.uuid4())).touch() + + import grass.script as gs + + print(gs.setup.get_install_path(__file__)) + """ + ) + ) + executable.chmod(0o755) + assert gs.setup.get_install_path(executable) == gs.setup.get_install_path() + assert len(list(calls.iterdir())) == 1