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