Skip to content
Draft
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
23 changes: 19 additions & 4 deletions python/grass/script/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,20 +137,35 @@ 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.
if path and Path(path).is_dir():
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)
Expand All @@ -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
Expand All @@ -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
Expand Down
42 changes: 42 additions & 0 deletions python/grass/script/tests/grass_script_setup_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading