diff --git a/lib/init/grass.py b/lib/init/grass.py
index 93b366f5cc3..d4d6e9f16cc 100644
--- a/lib/init/grass.py
+++ b/lib/init/grass.py
@@ -160,17 +160,6 @@ def try_remove(path):
pass
-def clean_env():
- gisrc = os.environ['GISRC']
- env_curr = read_gisrc(gisrc)
- env_new = {}
- for k, v in env_curr.items():
- if k.endswith('PID') or k.startswith('MONITOR'):
- continue
- env_new[k] = v
- write_gisrc(env_new, gisrc)
-
-
def is_debug():
"""Returns True if we are in debug mode
@@ -298,6 +287,12 @@ def wxpath(*args):
{tmp_location_detail}
--tmp-mapset {tmp_mapset}
{tmp_mapset_detail}
+ --no-lock {no_lock}
+ {no_lock_detail}
+ --no-clean {no_clean}
+ {no_clean_detail}
+ --clean-only {clean_only}
+ {clean_only_detail}
{params}:
GISDBASE {gisdbase}
@@ -363,6 +358,12 @@ def help_message(default_gui):
tmp_location_detail=_("created in a temporary directory and deleted at exit"),
tmp_mapset=_("create temporary mapset (use with the --exec flag)"),
tmp_mapset_detail=_("created in the specified location and deleted at exit"),
+ no_lock=_("do not lock the mapset (use with the --exec flag)"),
+ no_lock_detail=_("no .gislock will be created"),
+ no_clean=_("do not clean the mapset (use with the --exec flag)"),
+ no_clean_detail=_("temporary data in the mapset will be left untouched"),
+ clean_only=_("clean the mapset and exit"),
+ clean_only_detail=_("only cleans the mapset and then ends the session"),
)
)
s = t.substitute(CMD_NAME=CMD_NAME, DEFAULT_GUI=default_gui,
@@ -525,6 +526,31 @@ def write_gisrc(kv, filename):
f.close()
+def remove_session_specific_env_vars(env):
+ """Remove gis env variables specific to one session
+
+ Removes variables which shouldn't used by another session, e.g.,
+ d.mon related variables.
+ """
+ new_env = {}
+ for k, v in env.items():
+ if k.endswith('PID') or k.startswith('MONITOR'):
+ continue
+ new_env[k] = v
+ return new_env
+
+
+def copy_general_gis_env(source, target):
+ """Copy general (not session-specific) variables to a new rc file
+
+ :param source: name of a file to copy the variables from
+ :param target: name of a file to copy the variables to
+ """
+ curr_env = read_gisrc(source)
+ new_env = remove_session_specific_env_vars(curr_env)
+ write_gisrc(new_env, target)
+
+
def read_gui(gisrc, default_gui):
grass_gui = None
# At this point the GRASS user interface variable has been set from the
@@ -1568,9 +1594,13 @@ def lock_mapset(mapset_path, force_gislock_removal, user, grass_gui):
if msg:
if grass_gui == "wxpython":
call([os.getenv('GRASS_PYTHON'), wxpath("gis_set_error.py"), msg])
- # TODO: here we probably miss fatal or exit, needs to be added
- else:
- fatal(msg)
+ # After the dialog is dismissed, we exit using the same fatal
+ # as in the pure-command line mode. No difference when user does
+ # not see the command line and if the user does see it, then the
+ # error is repeated there which might be helpful for further
+ # investigation or when the user closed the dialog without reading
+ # the message.
+ fatal(msg)
debug("Mapset <{mapset}> locked using '{lockfile}'".format(
mapset=mapset_path, lockfile=lockfile))
return lockfile
@@ -1958,22 +1988,24 @@ def done_message():
message("")
-def clean_temp():
+def clean_mapset_temp():
+ """Clean current mapset temp dir
+
+ Cleans whatever is current mapset based on the GISRC env variable.
+ """
message(_("Cleaning up temporary files..."))
nul = open(os.devnull, 'w')
call([gpath("etc", "clean_temp")], stdout=nul)
nul.close()
-def clean_all():
+def clean_mapset():
+ """Perform full cleanup of the current mapset"""
from grass.script import setup as gsetup
# clean default sqlite db
gsetup.clean_default_db()
# remove leftover temp files
- clean_temp()
- # save 'last used' GISRC after removing variables which shouldn't
- # be saved, e.g. d.mon related
- clean_env()
+ clean_mapset_temp()
def grep(pattern, lines):
@@ -2083,6 +2115,10 @@ def __init__(self):
self.geofile = None
self.tmp_location = False
self.tmp_mapset = False
+ self.no_lock = False
+ self.no_clean = False
+ self.clean_only = False
+ self.exec_present = False
def parse_cmdline(argv, default_gui):
@@ -2125,6 +2161,12 @@ def parse_cmdline(argv, default_gui):
params.tmp_location = True
elif i == "--tmp-mapset":
params.tmp_mapset = True
+ elif i == "--no-lock":
+ params.no_lock = True
+ elif i == "--no-clean":
+ params.no_clean = True
+ elif i == "--clean-only":
+ params.clean_only = True
else:
args.append(i)
if len(args) > 1:
@@ -2140,7 +2182,7 @@ def parse_cmdline(argv, default_gui):
return params
-def validate_cmdline(params):
+def validate_cmdline(params, exec_present):
""" Validate the cmdline params and exit if necessary. """
if params.exit_grass and not params.create_new:
fatal(_("Flag -e requires also flag -c"))
@@ -2163,6 +2205,12 @@ def validate_cmdline(params):
" --tmp-location, mapset name <{}> provided"
).format(params.mapset)
)
+ if params.clean_only and params.no_clean:
+ fatal(_("Flags --no-clean and --clean-only are mutually exclusive"))
+ if params.clean_only and exec_present:
+ fatal(_("Flag --clean-only cannot be used with --exec"))
+ if params.clean_only and params.grass_gui:
+ fatal(_("Flag --clean-only cannot be used with --text, --gui, or --gtext"))
def main():
@@ -2204,10 +2252,13 @@ def main():
batch_job = sys.argv[index + 1:]
clean_argv = sys.argv[1:index]
params = parse_cmdline(clean_argv, default_gui=default_gui)
+ # Parsing does not deal with --exec, but we know --exec is there.
+ params.exec_present = True
except ValueError:
params = parse_cmdline(sys.argv[1:], default_gui=default_gui)
- validate_cmdline(params)
- # For now, we allow, but not advertise/document, --tmp-location
+ validate_cmdline(params, params.exec_present)
+ # For now, we allow, but not advertise/document, --tmp-location,
+ # --tmp-mapset, --no-clean, and --no-lock
# without --exec (usefulness to be evaluated).
grass_gui = params.grass_gui # put it to variable, it is used a lot
@@ -2321,18 +2372,26 @@ def main():
location = mapset_settings.full_mapset
- # check and create .gislock file
- lock_mapset(mapset_settings.full_mapset, user=user,
- force_gislock_removal=params.force_gislock_removal,
- grass_gui=grass_gui)
- # unlock the mapset which is current at the time of turning off
- # in case mapset was changed
- atexit.register(lambda: unlock_gisrc_mapset(gisrc, gisrcrc))
- # We now own the mapset (set and lock), so we can clean temporary
- # files which previous session may have left behind. We do it even
- # for first time user because the cost is low and first time user
- # doesn't necessarily mean that the mapset is used for the first time.
- clean_temp()
+ if not params.no_lock:
+ # check and create .gislock file
+ lock_mapset(mapset_settings.full_mapset, user=user,
+ force_gislock_removal=params.force_gislock_removal,
+ grass_gui=grass_gui)
+ # unlock the mapset which is current at the time of turning off
+ # in case mapset was changed
+ atexit.register(lambda: unlock_gisrc_mapset(gisrc, gisrcrc))
+ # We now own the mapset (set and lock), so we can clean temporary
+ # files which previous session may have left behind. We do it even
+ # for first time user because the cost is low and first time user
+ # doesn't necessarily mean that the mapset is used for the first time.
+ if not params.no_clean:
+ clean_mapset_temp()
+ # We always clean at the end like with unlocking. This is the same as
+ # calling the function explicitly before each exit, but additionally
+ # it also cleans the mapset on failure.
+ # This is the full proper clean up as opposed to the simplified one
+ # we do at the start of the session.
+ atexit.register(clean_mapset)
# build user fontcap if specified but not present
make_fontcap()
@@ -2340,18 +2399,14 @@ def main():
# TODO: is this really needed? Modules should call this when/if required.
ensure_db_connected(location)
- # Display the version and license info
- # only non-error, interactive version continues from here
if batch_job:
returncode = run_batch_job(batch_job)
- clean_all()
sys.exit(returncode)
- elif params.exit_grass:
- # clean always at exit, cleans whatever is current mapset based on
- # the GISRC env variable
- clean_all()
+ elif params.exit_grass or params.clean_only:
sys.exit(0)
else:
+ # Display the version and license info. Everything should be okay
+ # (no error occured). Interactive version continues from here.
show_banner()
say_hello()
show_info(shellname=shellname,
@@ -2384,14 +2439,18 @@ def main():
# close GUI if running
close_gui()
- # here we are at the end of grass session
- clean_all()
- if not params.tmp_location:
- writefile(gisrcrc, readfile(gisrc))
- # After this point no more grass modules may be called
- # done message at last: no atexit.register()
- # or register done_message()
+ # Saving only here in an interactive session and only if not using
+ # tmp location or mapset. (Not saving anything although just l/m
+ # could be ignored.)
+ if not params.tmp_location and not params.tmp_mapset:
+ # Save general (not session-specific) gis env variables
+ # to user config dir (aka last used rc file).
+ copy_general_gis_env(gisrc, gisrcrc)
+
done_message()
+ # After this point no more grass modules may be called
+ # except what was registered using atexit in a proper order.
+
if __name__ == '__main__':
main()
diff --git a/lib/init/grass7.html b/lib/init/grass7.html
index 6c993227c85..e9637f3de69 100644
--- a/lib/init/grass7.html
+++ b/lib/init/grass7.html
@@ -5,6 +5,7 @@
SYNOPSIS
grass79 [-h | -help | --help] [-v | --version] |
[-c | -c geofile | -c EPSG:code[:datum_trans]] | -e | -f |
[--text | --gtext | --gui] | --config |
+[--no-lock] | [--no-clean] | [--clean-only] |
[--tmp-location | --tmp-mapset]
[[[<GISDBASE>/]<LOCATION>/]
<MAPSET>]
@@ -65,6 +66,19 @@ Flags:
location and deleted at the end of the execution
(use with the --exec flag).
+--no-lock
+ Do not lock the mapset and leave the responsibility for handling
+concurrent sessions on the user.
+(Use with the --exec flag. See also --no-clean.)
+
+--no-clean
+ Do not clean the mapset when starting and ending a session.
+(Use with the --exec flag.
+See also --no-lock and --clean-only.)
+
+--clean-only
+ Do only the cleanup of the mapset and exit.
+
Parameters:
@@ -451,6 +465,61 @@ Using temporary mapset
so the script is expected to export, link or otherwise preserve the
output data before ending.
+Multiple sessions in one mapset
+
+Multiple processes in GRASS GIS can run parallel in two basic ways.
+First, the processes can run in multiple sessions each using its own
+mapset (a mapset created by the user or on-the-fly by
+--tmp-mapset). This option often requires management of mapsets
+and collection of data from multiple mapsets, but it is the most robust
+one. Second, some processes, for example most of the raster operations,
+can run in parallel in one mapset. Although this typically means
+running in one session connected to one mapset, this can be also done
+by starting multiple sessions using --exec connected to one
+mapset. In this case, the standard mapset locking and cleaning needs to
+be disabled using --no-lock and --no-clean. This is
+advantageous when it is not possible to have all the parallel processes
+started within one session, for example, when each process is running
+on a different machine.
+
+
+In the following example, let's assume that a Python script called
+run.py was prepared, so that it can run multiple times in
+parallel and it takes one parameter which makes each run unique.
+Another script called post_process_all_runs.py will do
+the post-processing using results from all runs of run.py
+within one mapset.
+
+
+In the first step, let's create a new mapset to be used in the next
+steps:
+
+
+grass79 -e -c /path/to/mapset
+
+
+This is the list of commands to be executed in parallel. The actual
+parallel execution is beyond this example, but let's assume that
+something like GNU parallel or Python multiprocessing package executes
+the following commands in parallel.
+
+
+grass79 /path/to/mapset --no-lock --no-clean --exec run.py 1
+grass79 /path/to/mapset --no-lock --no-clean --exec run.py 2
+grass79 /path/to/mapset --no-lock --no-clean --exec run.py 3
+grass79 /path/to/mapset --no-lock --no-clean --exec run.py 4
+
+
+In a final step, assuming the above processes finished, the other script
+does the post-processing of what is now the mapset.
+
+
+grass79 /path/to/mapset --exec post_process_all_runs.py
+
+
+The above command is not using --no-clean, so the standard
+cleaning procedure omitted above happens here, so no extra cleaning step
+is needed.
Troubleshooting
Importantly, to avoid an "[Errno 8] Exec format error" there must be a
diff --git a/lib/init/testsuite/test_grass_clean_lock.py b/lib/init/testsuite/test_grass_clean_lock.py
new file mode 100644
index 00000000000..8b20b5273e6
--- /dev/null
+++ b/lib/init/testsuite/test_grass_clean_lock.py
@@ -0,0 +1,180 @@
+#!/usr/bin/env python3
+
+"""
+TEST: Test of grass --tmp-mapset
+
+AUTHOR(S): Vaclav Petras
+
+PURPOSE: Test that --tmp-mapset option of grass command works
+
+COPYRIGHT: (C) 2020 Vaclav Petras and the GRASS Development Team
+
+This program is free software under the GNU General Public
+License (>=v2). Read the file COPYING that comes with GRASS
+for details.
+"""
+
+import unittest
+import os
+import platform
+import shutil
+import subprocess
+from pathlib import Path
+
+
+# Note that unlike rest of GRASS GIS, here we are using unittest package
+# directly. The grass.gunittest machinery for mapsets is not needed here.
+# How this plays out together with the rest of testing framework is yet to be
+# determined.
+
+
+class TestCleanLock(unittest.TestCase):
+ """Tests --no-clean --no-lock options of grass command"""
+
+ # TODO: here we need a name of or path to the main GRASS GIS executable
+ executable = "grass"
+ # an arbitrary, but identifiable and fairly unique name
+ location = "test_tmp_mapset_xy"
+ mapset = os.path.join(location, "PERMANENT")
+
+ def setUp(self):
+ """Creates a location used in the tests"""
+ subprocess.check_call([self.executable, "-c", "XY", self.location, "-e"])
+ self.subdirs = os.listdir(self.location)
+
+ def tearDown(self):
+ """Deletes the location"""
+ shutil.rmtree(self.location, ignore_errors=True)
+
+ def test_command_runs(self):
+ """Check that correct parameters are accepted"""
+ return_code = subprocess.call(
+ [self.executable, self.mapset, "--no-clean", "--exec", "g.proj", "-g"]
+ )
+ self.assertEqual(
+ return_code,
+ 0,
+ msg=(
+ "Non-zero return code from {self.executable}"
+ " with --no-clean".format(**locals())
+ ),
+ )
+
+ return_code = subprocess.call(
+ [self.executable, self.mapset, "--no-lock", "--exec", "g.proj", "-g"]
+ )
+ self.assertEqual(
+ return_code,
+ 0,
+ msg=(
+ "Non-zero return code from {self.executable}"
+ " with --no-lock".format(**locals())
+ ),
+ )
+
+ return_code = subprocess.call(
+ [
+ self.executable,
+ self.mapset,
+ "--no-clean",
+ "--no-lock",
+ "--exec",
+ "g.proj",
+ "-g",
+ ]
+ )
+ self.assertEqual(
+ return_code,
+ 0,
+ msg=(
+ "Non-zero return code from {self.executable}"
+ " with --no-clean and --no-lock".format(**locals())
+ ),
+ )
+
+ return_code = subprocess.call([self.executable, self.mapset, "--clean-only"])
+ self.assertEqual(
+ return_code,
+ 0,
+ msg=(
+ "Non-zero return code from {self.executable}"
+ " with --clean-only".format(**locals())
+ ),
+ )
+
+ def test_clean_only_fails_with_exec(self):
+ """Check that using --clean-only fails when --exec is provided"""
+ return_code = subprocess.call(
+ [self.executable, self.mapset, "--clean-only", "--exec", "g.proj", "-g"]
+ )
+ self.assertNotEqual(
+ return_code,
+ 0,
+ msg=("Zero retrun code from {self.executable}".format(**locals())),
+ )
+
+ def test_cleaning_fake_tmp_file(self):
+ """Check that --no-clean does not delete existing temp files.
+
+ Then it checks that the file is deleted without --no-clean.
+
+ Assumes that clean_temp for cleaning tmp files in mapsets would delete
+ file with name xxx.yyy when there is no process with PID xxx.
+ It further assumes that there is no process with the PID we used,
+ so clean_temp would delete the file.
+ Finally, it assumes the naming and nesting of the tmp dir.
+ """
+ common_unix_max = 32768 # common max of unix PIDs
+ fake_pid = common_unix_max + 1
+ mapset_tmp_dir_name = Path(self.mapset) / ".tmp" / platform.node()
+ mapset_tmp_dir_name.mkdir(parents=True, exist_ok=True)
+ name = "{}.1".format(fake_pid)
+ fake_tmp_file = mapset_tmp_dir_name / name
+ fake_tmp_file.touch()
+ subprocess.check_call(
+ [self.executable, "--no-clean", self.mapset, "--exec", "g.proj", "-g"]
+ )
+ self.assertTrue(fake_tmp_file.exists(), msg="File should still exist")
+ subprocess.check_call([self.executable, self.mapset, "--exec", "g.proj", "-g"])
+ self.assertFalse(fake_tmp_file.exists(), msg="File should have been deleted")
+
+ def test_cleaning_g_tempfile(self):
+ """Check that --no-clean does not delete existing temp files.
+
+ Then it checks that the file is deleted without --no-clean.
+
+ This makes no assumptions about inner workings of tmp file cleaning,
+ but it relies on g.tempfile to work correctly and that there no
+ other files in the tmp dir.
+ It still needs to assume that there is no process with the PID we used,
+ so clean_temp would delete the file.
+ """
+ common_unix_max = 32768 # common max of unix PIDs
+ fake_pid = common_unix_max + 1
+ mapset_tmp_dir_name = Path(self.mapset) / ".tmp" / platform.node()
+ # we need --no-clean even here otherwise we delete the file at exit
+ subprocess.check_call(
+ [
+ self.executable,
+ self.mapset,
+ "--no-clean",
+ "--exec",
+ "g.tempfile",
+ "pid={}".format(fake_pid),
+ ]
+ )
+ # another process
+ subprocess.check_call(
+ [self.executable, "--no-clean", self.mapset, "--exec", "g.proj", "-g"]
+ )
+ self.assertTrue(
+ os.listdir(str(mapset_tmp_dir_name)), msg="File should still exist"
+ )
+ subprocess.check_call([self.executable, self.mapset, "--exec", "g.proj", "-g"])
+ self.assertFalse(
+ os.listdir(str(mapset_tmp_dir_name)), msg="File should have been deleted"
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/lib/init/testsuite/test_grass_lock.sh b/lib/init/testsuite/test_grass_lock.sh
new file mode 100755
index 00000000000..37f2f02a487
--- /dev/null
+++ b/lib/init/testsuite/test_grass_lock.sh
@@ -0,0 +1,96 @@
+#!/usr/bin/env bash
+
+if [[ $# -eq 0 ]]
+then
+ # No arguments supplied, use default which is is small enough to run
+ # well on small machines and does not overwhelm a normal system.
+ NPROC=50
+ ROWS=200
+ COLS=200
+elif [[ $# -eq 3 ]]
+then
+ # Allow user to set a large number of processes.
+ NPROC=$1
+ ROWS=$2
+ COLS=$3
+else
+ >&2 echo "Usage:"
+ >&2 echo " $0"
+ >&2 echo " $0 "
+ >&2 echo "Example which takes a lot of resources:"
+ >&2 echo " $0 1000 500 500"
+ >&2 echo "Use zero or three parameters, not $#"
+ exit 1
+fi
+
+set -e # fail fast
+set -x # repeat commands
+
+GRASS="grass"
+LOCATION="$PWD/test_tmp_mapset_xy"
+MAPSET_PATH="${LOCATION}/PERMANENT"
+
+cleanup () {
+ rm -r "${LOCATION}"
+ exit 1
+}
+
+trap cleanup EXIT
+
+# Setup
+
+"${GRASS}" -e -c XY "${LOCATION}"
+
+"${GRASS}" "${MAPSET_PATH}" --exec g.region rows=$ROWS cols=$COLS
+
+# Test using sleep
+# This shows that --no-lock works.
+
+PARAM="--no-lock"
+# To check that it fails as expected, uncomment the following line.
+# PARAM=""
+
+# Sanity check.
+"${GRASS}" "${MAPSET_PATH}" --exec sleep 1
+
+# Specialized sanity check.
+"${GRASS}" "${MAPSET_PATH}" $PARAM --exec sleep 10 &
+
+for i in `seq 1 ${NPROC}`
+do
+ "${GRASS}" "${MAPSET_PATH}" $PARAM --exec sleep 10 &
+done
+
+wait
+
+# Test with computation
+# When this works, there should be no warnings about "No such file..."
+# Increasing number of processes and size of the region increases chance
+# of warnings and errors, but it is too much to have it in test
+# (1000 processes and rows=10000 cols=10000).
+
+PARAM="--no-clean"
+# To check that it fails as expected, uncomment the following line.
+# PARAM=""
+
+for i in `seq 1 ${NPROC}`
+do
+ "${GRASS}" "${MAPSET_PATH}" --no-lock $PARAM --exec \
+ r.mapcalc "a_$i = sqrt(sin(rand(0, 100)))" -s &
+done
+
+wait
+
+"${GRASS}" "${MAPSET_PATH}" --clean-only
+
+# Evaluate the computation
+# See how many raster maps are in the mapset
+
+EXPECTED="${NPROC}"
+NUM=$("${GRASS}" "${MAPSET_PATH}" --exec g.list type=raster pattern="*" | wc -l)
+
+if [ "$NUM" -ne "$EXPECTED" ]
+then
+ echo "Got $NUM but expected $EXPECTED maps"
+ exit 1
+fi