From 2d444758b6f9d50c3279a81c029ac235f275f152 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 16 Feb 2024 15:39:18 -0500 Subject: [PATCH 01/13] grass.script: Create new location without a session The function create_location now works without a full session. Internally, it sets up a runtime environment to execute tools (modules) without the connection to a location (project) which is sufficient for executing g.proj. The new test covers the new functionality, but to actually test it, the test needs to run by itself because grass.script.setup.init (still) creates a global session. --- python/grass/script/core.py | 13 ++++- python/grass/script/setup.py | 50 ++++++++++++------- .../tests/grass_script_core_location_test.py | 19 +++++++ 3 files changed, 64 insertions(+), 18 deletions(-) diff --git a/python/grass/script/core.py b/python/grass/script/core.py index 4f039645a39..154b30f4764 100644 --- a/python/grass/script/core.py +++ b/python/grass/script/core.py @@ -1733,9 +1733,20 @@ def create_location( if datum_trans: kwargs["datum_trans"] = datum_trans + # Lazy-importing to avoid circular dependencies. + # pylint: disable=import-outside-toplevel + if os.environ.get("GISBASE"): + env = os.environ + else: + from grass.script.setup import setup_runtime_env + + env = setup_runtime_env(env=os.environ) + if epsg or proj4 or filename or wkt: # The names don't really matter here. - tmp_gisrc, env = create_environment(dbase, "", "") + tmp_gisrc, env = create_environment( + dbase, "", "", env=env + ) if epsg: ps = pipe_command( diff --git a/python/grass/script/setup.py b/python/grass/script/setup.py index 93aae0f81c1..b26b09c68b0 100644 --- a/python/grass/script/setup.py +++ b/python/grass/script/setup.py @@ -192,15 +192,29 @@ def ask_executable(arg): return None -def setup_runtime_env(gisbase): +def setup_runtime_env(gisbase=None, env=None): """Setup the runtime environment. - Modifies the global environment (os.environ) so that GRASS modules can run. + Modifies environment so that GRASS modules can run. It does not setup a session, + but only the system environment to execute commands. + + Returns modified copy of the environment provided with _env_. If _env_ is not + provided, modifies the global environment (os.environ). """ + if not gisbase: + gisbase = get_install_path() + # Accept Path objects. gisbase = os.fspath(gisbase) + + # If environment is not provided, use the global one. + if env: + env = env.copy() + else: + env = os.environ + # Set GISBASE - os.environ["GISBASE"] = gisbase + env["GISBASE"] = gisbase # define PATH path_addition = os.pathsep + os.path.join(gisbase, "bin") @@ -210,46 +224,48 @@ def setup_runtime_env(gisbase): # add addons to the PATH, use GRASS_ADDON_BASE if set # copied and simplified from lib/init/grass.py - addon_base = os.getenv("GRASS_ADDON_BASE") + addon_base = env.get("GRASS_ADDON_BASE") if not addon_base: if WINDOWS: config_dirname = f"GRASS{VERSION_MAJOR}" - addon_base = os.path.join(os.getenv("APPDATA"), config_dirname, "addons") + addon_base = os.path.join(env.get("APPDATA"), config_dirname, "addons") elif MACOS: version = f"{VERSION_MAJOR}.{VERSION_MINOR}" addon_base = os.path.join( - os.getenv("HOME"), "Library", "GRASS", version, "Addons" + env.get("HOME"), "Library", "GRASS", version, "Addons" ) else: config_dirname = f".grass{VERSION_MAJOR}" - addon_base = os.path.join(os.getenv("HOME"), config_dirname, "addons") - os.environ["GRASS_ADDON_BASE"] = addon_base + addon_base = os.path.join(env.get("HOME"), config_dirname, "addons") + env["GRASS_ADDON_BASE"] = addon_base if not WINDOWS: path_addition += os.pathsep + os.path.join(addon_base, "scripts") path_addition += os.pathsep + os.path.join(addon_base, "bin") - os.environ["PATH"] = path_addition + os.pathsep + os.getenv("PATH") + env["PATH"] = path_addition + os.pathsep + env.get("PATH") # define LD_LIBRARY_PATH - if "@LD_LIBRARY_PATH_VAR@" not in os.environ: - os.environ["@LD_LIBRARY_PATH_VAR@"] = "" - os.environ["@LD_LIBRARY_PATH_VAR@"] += os.pathsep + os.path.join(gisbase, "lib") + if "@LD_LIBRARY_PATH_VAR@" not in env: + env["@LD_LIBRARY_PATH_VAR@"] = "" + env["@LD_LIBRARY_PATH_VAR@"] += os.pathsep + os.path.join(gisbase, "lib") # Set GRASS_PYTHON and PYTHONPATH to find GRASS Python modules - if not os.getenv("GRASS_PYTHON"): + if not env.get("GRASS_PYTHON"): if WINDOWS: - os.environ["GRASS_PYTHON"] = "python3.exe" + env["GRASS_PYTHON"] = "python3.exe" else: - os.environ["GRASS_PYTHON"] = "python3" + env["GRASS_PYTHON"] = "python3" - path = os.getenv("PYTHONPATH") + path = env.get("PYTHONPATH") etcpy = os.path.join(gisbase, "etc", "python") if path: path = etcpy + os.pathsep + path else: path = etcpy - os.environ["PYTHONPATH"] = path + env["PYTHONPATH"] = path + + return env def init(path, location=None, mapset=None, grass_path=None): diff --git a/python/grass/script/tests/grass_script_core_location_test.py b/python/grass/script/tests/grass_script_core_location_test.py index c462df6174c..ea8b228a76b 100644 --- a/python/grass/script/tests/grass_script_core_location_test.py +++ b/python/grass/script/tests/grass_script_core_location_test.py @@ -58,6 +58,25 @@ def workload(queue): assert epsg == "EPSG:3358" +def test_without_session(tmp_path): + """Check that creation works outside of session. + + Assumes that there is no session for the test. This can be ensured by running only + this test with pylint outside a session. + """ + name = "desired" + gs.create_location(tmp_path, name, epsg="3358") + assert (tmp_path / name).exists() + wkt_file = tmp_path / name / "PERMANENT" / "PROJ_WKT" + assert wkt_file.exists() + with gs.setup.init(tmp_path / name): + gs.run_command("g.gisenv", set=f"GISDBASE={tmp_path}") + gs.run_command("g.gisenv", set=f"LOCATION_NAME={name}") + gs.run_command("g.gisenv", set="MAPSET=PERMANENT") + epsg = gs.parse_command("g.proj", flags="g")["srid"] + assert epsg == "EPSG:3358" + + def test_with_different_path(tmp_path): """Check correct EPSG is created with different path""" bootstrap_location = "bootstrap" From 7ea3486a202cf5ffb180ff18fc9e50c96a3a169f Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 16 Feb 2024 17:47:09 -0500 Subject: [PATCH 02/13] Refine the API: env is always modified to avoid confusion when it is modified and when it is not, copy is up to the user which makes it clear when that actually happens which is longer but more readable, env is keyword-only, gisbase path handling is documented --- python/grass/script/core.py | 3 ++- python/grass/script/setup.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/python/grass/script/core.py b/python/grass/script/core.py index 154b30f4764..912713c7666 100644 --- a/python/grass/script/core.py +++ b/python/grass/script/core.py @@ -1740,7 +1740,8 @@ def create_location( else: from grass.script.setup import setup_runtime_env - env = setup_runtime_env(env=os.environ) + env = os.environ.copy() + setup_runtime_env(env=env) if epsg or proj4 or filename or wkt: # The names don't really matter here. diff --git a/python/grass/script/setup.py b/python/grass/script/setup.py index b26b09c68b0..983893e3825 100644 --- a/python/grass/script/setup.py +++ b/python/grass/script/setup.py @@ -192,14 +192,18 @@ def ask_executable(arg): return None -def setup_runtime_env(gisbase=None, env=None): +def setup_runtime_env(gisbase=None, *, env=None): """Setup the runtime environment. Modifies environment so that GRASS modules can run. It does not setup a session, but only the system environment to execute commands. - Returns modified copy of the environment provided with _env_. If _env_ is not - provided, modifies the global environment (os.environ). + Modifies the environment provided with _env_. If _env_ is not + provided, modifies the global environment (os.environ). Pass a copy of the + environment if you don't want the source environment modified. + + 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). """ if not gisbase: gisbase = get_install_path() @@ -208,9 +212,7 @@ def setup_runtime_env(gisbase=None, env=None): gisbase = os.fspath(gisbase) # If environment is not provided, use the global one. - if env: - env = env.copy() - else: + if not env: env = os.environ # Set GISBASE @@ -265,8 +267,6 @@ def setup_runtime_env(gisbase=None, env=None): path = etcpy env["PYTHONPATH"] = path - return env - def init(path, location=None, mapset=None, grass_path=None): """Initialize system variables to run GRASS modules From 4a6fd681d44ca503dd9c076ca69c5e93cb15bdee Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 16 Feb 2024 20:08:03 -0500 Subject: [PATCH 03/13] grass.script: Provide env parameter in the g.message API This makes the message, verbose and other functions consistent with other wrappers around run_command family calls. While not needed for multiple-mapset situations and parallelization, it is necessary when calls with messages are used without global environment being set and only custom environment available in grass.script.setup. The PR aims at providing the interface, not updating all use cases (except the anticipated changes in grass.script.setup). This does not have any test since the API for that is missing. This will be tested indirectly in the future. --- python/grass/script/core.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/python/grass/script/core.py b/python/grass/script/core.py index 912713c7666..fda9f32babb 100644 --- a/python/grass/script/core.py +++ b/python/grass/script/core.py @@ -652,16 +652,16 @@ def exec_command( # interface to g.message -def message(msg, flag=None): +def message(msg, flag=None, env=None): """Display a message using `g.message` :param str msg: message to be displayed :param str flag: flags (given as string) """ - run_command("g.message", flags=flag, message=msg, errors="ignore") + run_command("g.message", flags=flag, message=msg, errors="ignore", env=env) -def debug(msg, debug=1): +def debug(msg, debug=1, env=None): """Display a debugging message using `g.message -d`. The visibility of a debug message at runtime is controlled by @@ -679,26 +679,26 @@ def debug(msg, debug=1): if sys.platform == "win32": msg = msg.replace("&", "^&") - run_command("g.message", flags="d", message=msg, debug=debug) + run_command("g.message", flags="d", message=msg, debug=debug, env=env) -def verbose(msg): +def verbose(msg, env=None): """Display a verbose message using `g.message -v` :param str msg: verbose message to be displayed """ - message(msg, flag="v") + message(msg, flag="v", env=env) -def info(msg): +def info(msg, env=None): """Display an informational message using `g.message -i` :param str msg: informational message to be displayed """ - message(msg, flag="i") + message(msg, flag="i", env=env) -def percent(i, n, s): +def percent(i, n, s, env=None): """Display a progress info message using `g.message -p` :: @@ -713,18 +713,18 @@ def percent(i, n, s): :param int n: total number of items :param int s: increment size """ - message("%d %d %d" % (i, n, s), flag="p") + message("%d %d %d" % (i, n, s), flag="p", env=env) -def warning(msg): +def warning(msg, env=None): """Display a warning message using `g.message -w` :param str msg: warning message to be displayed """ - message(msg, flag="w") + message(msg, flag="w", env=env) -def error(msg): +def error(msg, env=None): """Display an error message using `g.message -e` This function does not end the execution of the program. @@ -733,10 +733,10 @@ def error(msg): :param str msg: error message to be displayed """ - message(msg, flag="e") + message(msg, flag="e", env=env) -def fatal(msg): +def fatal(msg, env=None): """Display an error message using `g.message -e`, then abort or raise Raises exception when module global raise_on_error is 'True', abort @@ -749,7 +749,7 @@ def fatal(msg): if raise_on_error: raise ScriptError(msg) - error(msg) + error(msg, env=None) sys.exit(1) From 9a278a4fed57e9090b153e7841b09bdceb726322 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Mon, 19 Feb 2024 17:38:23 -0500 Subject: [PATCH 04/13] grass.script: Allow init modify only specified environment --- python/grass/experimental/tests/conftest.py | 6 +- .../tests/grass_script_mapset_session_test.py | 10 ++- python/grass/script/setup.py | 76 +++++++++++++------ python/grass/script/tests/conftest.py | 18 +++++ .../tests/grass_script_core_location_test.py | 58 +++++++++----- .../script/tests/grass_script_setup_test.py | 69 +++++++++++++---- 6 files changed, 172 insertions(+), 65 deletions(-) create mode 100644 python/grass/script/tests/conftest.py diff --git a/python/grass/experimental/tests/conftest.py b/python/grass/experimental/tests/conftest.py index 14e48246de6..69976ce0633 100644 --- a/python/grass/experimental/tests/conftest.py +++ b/python/grass/experimental/tests/conftest.py @@ -1,11 +1,11 @@ """Fixtures for grass.script""" import uuid +import os import pytest import grass.script as gs -import grass.script.setup as grass_setup import grass.experimental as experimental @@ -14,7 +14,7 @@ def xy_session(tmp_path): """Active session in an XY location (scope: function)""" location = "xy_test" gs.core._create_location_xy(tmp_path, location) # pylint: disable=protected-access - with grass_setup.init(tmp_path / location) as session: + with gs.setup.init(tmp_path / location, env=os.environ.copy()) as session: yield session @@ -29,7 +29,7 @@ def xy_session_for_module(tmp_path_factory): tmp_path = tmp_path_factory.mktemp("xy_session_for_module") location = "xy_test" gs.core._create_location_xy(tmp_path, location) # pylint: disable=protected-access - with grass_setup.init(tmp_path / location) as session: + with gs.setup.init(tmp_path / location, env=os.environ.copy()) as session: yield session diff --git a/python/grass/experimental/tests/grass_script_mapset_session_test.py b/python/grass/experimental/tests/grass_script_mapset_session_test.py index c4b70f4d18c..b38c6f1dc57 100644 --- a/python/grass/experimental/tests/grass_script_mapset_session_test.py +++ b/python/grass/experimental/tests/grass_script_mapset_session_test.py @@ -59,7 +59,7 @@ def test_create_overwrite(xy_session): """Session creates and creates again with overwrite""" name = "test_mapset_1" session_file = xy_session.env["GISRC"] - with experimental.MapsetSession(name, create=True) as session: + with experimental.MapsetSession(name, create=True, env=xy_session.env) as session: session_mapset = gs.read_command("g.mapset", flags="p", env=session.env).strip() assert name == session_mapset gs.run_command("r.mapcalc", expression="a = 1", env=session.env) @@ -69,7 +69,9 @@ def test_create_overwrite(xy_session): .split() ) assert len(rasters) == 1 and rasters[0] == "a" - with experimental.MapsetSession(name, create=True, overwrite=True) as session: + with experimental.MapsetSession( + name, create=True, overwrite=True, env=xy_session.env + ) as session: session_mapset = gs.read_command("g.mapset", flags="p", env=session.env).strip() assert name == session_mapset rasters = ( @@ -92,7 +94,7 @@ def test_ensure(xy_session): """Session ensures and does not delete""" name = "test_mapset_1" session_file = xy_session.env["GISRC"] - with experimental.MapsetSession(name, ensure=True) as session: + with experimental.MapsetSession(name, ensure=True, env=xy_session.env) as session: session_mapset = gs.read_command("g.mapset", flags="p", env=session.env).strip() assert name == session_mapset gs.run_command("r.mapcalc", expression="a = 1", env=session.env) @@ -102,7 +104,7 @@ def test_ensure(xy_session): .split() ) assert len(rasters) == 1 and rasters[0] == "a" - with experimental.MapsetSession(name, ensure=True) as session: + with experimental.MapsetSession(name, ensure=True, env=xy_session.env) as session: session_mapset = gs.read_command("g.mapset", flags="p", env=session.env).strip() assert name == session_mapset rasters = ( diff --git a/python/grass/script/setup.py b/python/grass/script/setup.py index 983893e3825..238088e1216 100644 --- a/python/grass/script/setup.py +++ b/python/grass/script/setup.py @@ -268,7 +268,7 @@ def setup_runtime_env(gisbase=None, *, env=None): env["PYTHONPATH"] = path -def init(path, location=None, mapset=None, grass_path=None): +def init(path, location=None, mapset=None, *, grass_path=None, env=None): """Initialize system variables to run GRASS modules This function is for running GRASS GIS without starting it with the @@ -320,6 +320,7 @@ def init(path, location=None, mapset=None, grass_path=None): :returns: reference to a session handle object which is a context manager """ + # The path heuristic always uses the global environment. grass_path = get_install_path(grass_path) if not grass_path: raise ValueError( @@ -351,15 +352,18 @@ def init(path, location=None, mapset=None, grass_path=None): ) ) - setup_runtime_env(grass_path) + # If environment is not provided, use the global one. + if not env: + env = os.environ + setup_runtime_env(grass_path, env=env) # TODO: lock the mapset? - os.environ["GIS_LOCK"] = str(os.getpid()) + env["GIS_LOCK"] = str(os.getpid()) - os.environ["GISRC"] = write_gisrc( + env["GISRC"] = write_gisrc( mapset_path.directory, mapset_path.location, mapset_path.mapset ) - return SessionHandle() + return SessionHandle(env=env) class SessionHandle: @@ -390,9 +394,27 @@ class SessionHandle: with gs.setup.init("~/grassdata/nc_spm_08/user1"): # ... use GRASS modules here # session ends automatically here + + The example above is modifying the global, process environment (`os.environ`). + If you don't want to modify the global environment, use the _env_ parameter + for the _init_ function to modify the provided environment instead. + This environment is then available as an attribute of the session object. + The attribute than needs to be passed to all calls of GRASS + tools and function which wrap them. + Context manager usage with custom environment:: + + # ... setup sys.path before import as needed + + import grass.script as gs + + with gs.setup.init("~/grassdata/nc_spm_08/user1", env=os.environ.copy()): + # ... use GRASS modules here with env parameter + gs.run_command("g.region", flags="p", env=session.env) + # session ends automatically here, global environment was never modifed """ - def __init__(self, active=True): + def __init__(self, *, env, active=True): + self._env = env self._active = active self._start_time = datetime.datetime.now(datetime.timezone.utc) @@ -403,7 +425,7 @@ def active(self): @property def env(self): - return os.environ + return self._env def __enter__(self): """Enter the context manager context. @@ -434,14 +456,14 @@ def finish(self): if not self.active: raise ValueError("Attempt to finish an already finished session") self._active = False - finish(start_time=self._start_time) + finish(env=self._env, start_time=self._start_time) # clean-up functions when terminating a GRASS session # these fns can only be called within a valid GRASS session -def clean_default_db(*, modified_after=None): +def clean_default_db(*, modified_after=None, env=None): """Clean (vacuum) the default db if it is SQLite When *modified_after* is set, database is cleaned only when it was modified @@ -451,11 +473,11 @@ def clean_default_db(*, modified_after=None): # pylint: disable=import-outside-toplevel import grass.script as gs - conn = gs.db_connection() + conn = gs.db_connection(env=env) if not conn or conn["driver"] != "sqlite": return # check if db exists - gis_env = gs.gisenv() + gis_env = gs.gisenv(env=env) database = conn["database"] database = database.replace("$GISDBASE", gis_env["GISDBASE"]) database = database.replace("$LOCATION_NAME", gis_env["LOCATION_NAME"]) @@ -477,8 +499,8 @@ def clean_default_db(*, modified_after=None): # Start the vacuum process, then show the message in parallel while # the vacuum is running. Finally, wait for the vacuum process to finish. # Error handling is the same as errors="ignore". - process = gs.start_command("db.execute", sql="VACUUM") - gs.verbose(_("Cleaning up default SQLite database...")) + process = gs.start_command("db.execute", sql="VACUUM", env=env) + gs.verbose(_("Cleaning up SQLite attribute database..."), env=env) process.wait() @@ -489,18 +511,23 @@ def call(cmd, **kwargs): return subprocess.call(cmd, **kwargs) -def clean_temp(): +def clean_temp(env=None): """Clean mapset temporary directory""" # Lazy-importing to reduce dependencies (this can be eventually removed). # pylint: disable=import-outside-toplevel import grass.script as gs - gs.verbose(_("Cleaning up temporary files...")) - gisbase = os.environ["GISBASE"] - call([os.path.join(gisbase, "etc", "clean_temp")], stdout=subprocess.DEVNULL) + if not env: + env = os.environ + + gs.verbose(_("Cleaning up temporary files..."), env=env) + gisbase = env["GISBASE"] + call( + [os.path.join(gisbase, "etc", "clean_temp")], stdout=subprocess.DEVNULL, env=env + ) -def finish(*, start_time=None): +def finish(*, env=None, start_time=None): """Terminate the GRASS session and clean up GRASS commands can no longer be used after this function has been @@ -518,13 +545,16 @@ def finish(*, start_time=None): Currently, it is used to do SQLite database vacuum only when database was modified since the session started. """ - clean_default_db(modified_after=start_time) - clean_temp() + if not env: + env = os.environ + + clean_default_db(modified_after=start_time, env=env) + clean_temp(env=env) # TODO: unlock the mapset? # unset the GISRC and delete the file from grass.script import utils as gutils - gutils.try_remove(os.environ["GISRC"]) - os.environ.pop("GISRC") + gutils.try_remove(env["GISRC"]) + del env["GISRC"] # remove gislock env var (not the gislock itself - os.environ.pop("GIS_LOCK") + del env["GIS_LOCK"] diff --git a/python/grass/script/tests/conftest.py b/python/grass/script/tests/conftest.py new file mode 100644 index 00000000000..e8b3516c6c1 --- /dev/null +++ b/python/grass/script/tests/conftest.py @@ -0,0 +1,18 @@ +"""Fixtures for Jupyter tests + +Fixture for grass.jupyter.TimeSeries test + +Fixture for ReprojectionRenderer test with simple GRASS location, raster, vector. +""" + +import pytest + + +@pytest.fixture +def mock_no_session(monkeypatch): + """Set the environment variables as if there would be no background session. + + There may or may not be a session in the background (we don't check either way). + """ + monkeypatch.delenv("GISRC", raising=False) + monkeypatch.delenv("GISBASE", raising=False) diff --git a/python/grass/script/tests/grass_script_core_location_test.py b/python/grass/script/tests/grass_script_core_location_test.py index ea8b228a76b..60ad7e4aec9 100644 --- a/python/grass/script/tests/grass_script_core_location_test.py +++ b/python/grass/script/tests/grass_script_core_location_test.py @@ -1,14 +1,15 @@ """Test functions in grass.script.setup""" import multiprocessing +import os + +import pytest import grass.script as gs -# All init tests change the global environment, but when it really matters, -# we use a separate process. -# Ideally, the functions would support env parameter and the test -# would mostly use that. +# This is useful when we want to ensure that function like init does +# not change the global environment. def run_in_subprocess(function): """Run function in a separate process @@ -30,15 +31,17 @@ def create_and_get_srid(tmp_path): gs.core._create_location_xy( tmp_path, bootstrap_location ) # pylint: disable=protected-access - with gs.setup.init(tmp_path / bootstrap_location): + with gs.setup.init(tmp_path / bootstrap_location, env=os.environ.copy()) as session: gs.create_location(tmp_path, desired_location, epsg="3358") assert (tmp_path / desired_location).exists() wkt_file = tmp_path / desired_location / "PERMANENT" / "PROJ_WKT" assert wkt_file.exists() - gs.run_command("g.gisenv", set=f"GISDBASE={tmp_path}") - gs.run_command("g.gisenv", set=f"LOCATION_NAME={desired_location}") - gs.run_command("g.gisenv", set="MAPSET=PERMANENT") - return gs.parse_command("g.proj", flags="g")["srid"] + gs.run_command("g.gisenv", set=f"GISDBASE={tmp_path}", env=session.env) + gs.run_command( + "g.gisenv", set=f"LOCATION_NAME={desired_location}", env=session.env + ) + gs.run_command("g.gisenv", set="MAPSET=PERMANENT", env=session.env) + return gs.parse_command("g.proj", flags="g", env=session.env)["srid"] def test_with_same_path(tmp_path): @@ -58,22 +61,31 @@ def workload(queue): assert epsg == "EPSG:3358" +@pytest.mark.usefixtures("mock_no_session") def test_without_session(tmp_path): """Check that creation works outside of session. Assumes that there is no session for the test. This can be ensured by running only - this test with pylint outside a session. + this test with pytest outside of a session. + + Also checks that the global environment is intact after calling the function. """ + name = "desired" gs.create_location(tmp_path, name, epsg="3358") + + # Check that the global environment is still intact. + assert not os.environ.get("GISRC"), "Session exists after the call" + assert not os.environ.get("GISBASE"), "Runtime exists after the call" + assert (tmp_path / name).exists() wkt_file = tmp_path / name / "PERMANENT" / "PROJ_WKT" assert wkt_file.exists() - with gs.setup.init(tmp_path / name): - gs.run_command("g.gisenv", set=f"GISDBASE={tmp_path}") - gs.run_command("g.gisenv", set=f"LOCATION_NAME={name}") - gs.run_command("g.gisenv", set="MAPSET=PERMANENT") - epsg = gs.parse_command("g.proj", flags="g")["srid"] + with gs.setup.init(tmp_path / name, env=os.environ.copy()) as session: + gs.run_command("g.gisenv", set=f"GISDBASE={tmp_path}", env=session.env) + gs.run_command("g.gisenv", set=f"LOCATION_NAME={name}", env=session.env) + gs.run_command("g.gisenv", set="MAPSET=PERMANENT", env=session.env) + epsg = gs.parse_command("g.proj", flags="g", env=session.env)["srid"] assert epsg == "EPSG:3358" @@ -87,15 +99,19 @@ def test_with_different_path(tmp_path): gs.core._create_location_xy( tmp_path_a, bootstrap_location ) # pylint: disable=protected-access - with gs.setup.init(tmp_path_a / bootstrap_location): + with gs.setup.init( + tmp_path_a / bootstrap_location, env=os.environ.copy() + ) as session: gs.create_location(tmp_path_b, desired_location, epsg="3358") assert (tmp_path_b / desired_location).exists() wkt_file = tmp_path_b / desired_location / "PERMANENT" / "PROJ_WKT" assert wkt_file.exists() - gs.run_command("g.gisenv", set=f"GISDBASE={tmp_path_b}") - gs.run_command("g.gisenv", set=f"LOCATION_NAME={desired_location}") - gs.run_command("g.gisenv", set="MAPSET=PERMANENT") - epsg = gs.parse_command("g.proj", flags="g")["srid"] + gs.run_command("g.gisenv", set=f"GISDBASE={tmp_path_b}", env=session.env) + gs.run_command( + "g.gisenv", set=f"LOCATION_NAME={desired_location}", env=session.env + ) + gs.run_command("g.gisenv", set="MAPSET=PERMANENT", env=session.env) + epsg = gs.parse_command("g.proj", flags="g", env=session.env)["srid"] assert epsg == "EPSG:3358" @@ -106,7 +122,7 @@ def test_files(tmp_path): gs.core._create_location_xy( tmp_path, bootstrap_location ) # pylint: disable=protected-access - with gs.setup.init(tmp_path / bootstrap_location): + with gs.setup.init(tmp_path / bootstrap_location, env=os.environ.copy()): description = "This is a test (not Gauss-Krüger or Křovák)" gs.create_location(tmp_path, desired_location, epsg="3358", desc=description) assert (tmp_path / desired_location).exists() diff --git a/python/grass/script/tests/grass_script_setup_test.py b/python/grass/script/tests/grass_script_setup_test.py index e4585594d28..1632348baa5 100644 --- a/python/grass/script/tests/grass_script_setup_test.py +++ b/python/grass/script/tests/grass_script_setup_test.py @@ -8,10 +8,8 @@ import grass.script as gs -# All init tests change the global environment, but when it really matters, -# we use a separate process. -# Ideally, the functions would support env parameter and the test -# would mostly use that. +# This is useful when we want to ensure that function like init does +# not change the global environment. def run_in_subprocess(function): """Run function in a separate process @@ -30,9 +28,10 @@ def test_init_as_context_manager(tmp_path): """Check that init function return value works as a context manager""" location = "test" gs.core._create_location_xy(tmp_path, location) # pylint: disable=protected-access - with gs.setup.init(tmp_path / location): - gs.run_command("g.region", flags="p") - session_file = os.environ["GISRC"] + with gs.setup.init(tmp_path / location, env=os.environ.copy()) as session: + gs.run_command("g.region", flags="p", env=session.env) + session_file = session.env["GISRC"] + assert os.path.exists(session_file) assert not os.path.exists(session_file) @@ -40,9 +39,9 @@ def test_init_session_finish(tmp_path): """Check that init works with finish on the returned session object""" location = "test" gs.core._create_location_xy(tmp_path, location) # pylint: disable=protected-access - session = gs.setup.init(tmp_path / location) - gs.run_command("g.region", flags="p") - session_file = os.environ["GISRC"] + session = gs.setup.init(tmp_path / location, env=os.environ.copy()) + gs.run_command("g.region", flags="p", env=session.env) + session_file = session.env["GISRC"] session.finish() with pytest.raises(ValueError): session.finish() @@ -54,10 +53,11 @@ def test_init_finish_global_functions(tmp_path): """Check that init and finish global functions work""" location = "test" gs.core._create_location_xy(tmp_path, location) # pylint: disable=protected-access - gs.setup.init(tmp_path / location) - gs.run_command("g.region", flags="p") - session_file = os.environ["GISRC"] - gs.setup.finish() + env = os.environ.copy() + gs.setup.init(tmp_path / location, env=env) + gs.run_command("g.region", flags="p", env=env) + session_file = env["GISRC"] + gs.setup.finish(env=env) assert not os.path.exists(session_file) @@ -79,3 +79,44 @@ def init_finish(queue): session_file = run_in_subprocess(init_finish) assert session_file, "Expected file name from the subprocess" assert not os.path.exists(session_file), "Session file not deleted" + + +@pytest.mark.usefixtures("mock_no_session") +def test_init_as_context_manager_env_attribute(tmp_path): + """Check that session has global environment as attribute""" + + def workload(queue): + location = "test" + gs.core._create_location_xy( + tmp_path, location + ) # pylint: disable=protected-access + with gs.setup.init(tmp_path / location) as session: + gs.run_command("g.region", flags="p", env=session.env) + session_file = os.environ["GISRC"] + queue.put((session_file, os.path.exists(session_file))) + + session_file, file_existed = run_in_subprocess(workload) + assert session_file, "Expected file name from the subprocess" + assert file_existed, "File should have been present" + assert not os.path.exists(session_file), "Session file not deleted" + assert not os.environ.get("GISRC") + assert not os.environ.get("GISBASE") + + +@pytest.mark.usefixtures("mock_no_session") +def test_init_environment_isolation(tmp_path): + """Check that only the provided environment is modified""" + location = "test" + gs.core._create_location_xy(tmp_path, location) # pylint: disable=protected-access + env = os.environ.copy() + with gs.setup.init(tmp_path / location, env=env) as session: + gs.run_command("g.region", flags="p", env=session.env) + assert env.get("GISBASE") + assert env.get("GISRC") + # Check that the global environment is intact. + assert not os.environ.get("GISRC") + assert not os.environ.get("GISBASE") + assert not env.get("GISRC") + # We test if the global environment is intact after closing the session. + assert not os.environ.get("GISRC") + assert not os.environ.get("GISBASE") From cdf0592abbba0ad12fd0b5b8e9952aef4497c1da Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Mon, 19 Feb 2024 20:09:03 -0500 Subject: [PATCH 05/13] Fix doc for conftest [skip ci] --- python/grass/script/tests/conftest.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/python/grass/script/tests/conftest.py b/python/grass/script/tests/conftest.py index e8b3516c6c1..02df4e811ef 100644 --- a/python/grass/script/tests/conftest.py +++ b/python/grass/script/tests/conftest.py @@ -1,9 +1,4 @@ -"""Fixtures for Jupyter tests - -Fixture for grass.jupyter.TimeSeries test - -Fixture for ReprojectionRenderer test with simple GRASS location, raster, vector. -""" +"""Fixtures for grass.script""" import pytest From 94eed4488740ee09d90c80f8a8ba7b1646d3f996 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Mon, 19 Feb 2024 23:05:10 -0500 Subject: [PATCH 06/13] Document recommended fixture usage [skip ci] --- python/grass/script/tests/conftest.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/python/grass/script/tests/conftest.py b/python/grass/script/tests/conftest.py index 02df4e811ef..ce666fbdeef 100644 --- a/python/grass/script/tests/conftest.py +++ b/python/grass/script/tests/conftest.py @@ -7,6 +7,13 @@ def mock_no_session(monkeypatch): """Set the environment variables as if there would be no background session. + Use with usefixtures (not as a paramter) to avoid warnings about an unused + parameter:: + + @pytest.mark.usefixtures("mock_no_session") + def test_session_handling(): + pass + There may or may not be a session in the background (we don't check either way). """ monkeypatch.delenv("GISRC", raising=False) From 8f23eb4e86e4cc02b167f4277b59f5916650dc4b Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Thu, 29 Feb 2024 20:30:11 -0500 Subject: [PATCH 07/13] Use session.env in tests for isolation --- scripts/v.db.univar/tests/conftest.py | 30 ++++- scripts/v.db.univar/tests/v_db_univar_test.py | 5 + scripts/v.dissolve/tests/conftest.py | 109 +++++++++++++++--- .../tests/v_dissolve_aggregate_test.py | 56 +++++---- .../tests/v_dissolve_geometry_test.py | 8 +- .../tests/v_dissolve_layers_test.py | 15 ++- scripts/v.dissolve/tests/v_dissolve_test.py | 12 +- temporal/t.rast.list/tests/conftest.py | 4 +- vector/v.fill.holes/tests/conftest.py | 30 ++++- .../v.fill.holes/tests/v_fill_holes_test.py | 30 +++-- 10 files changed, 226 insertions(+), 73 deletions(-) diff --git a/scripts/v.db.univar/tests/conftest.py b/scripts/v.db.univar/tests/conftest.py index 6edbc788018..0d121bbb97a 100644 --- a/scripts/v.db.univar/tests/conftest.py +++ b/scripts/v.db.univar/tests/conftest.py @@ -1,5 +1,7 @@ """Fixtures for v.db.univar tests""" +import os + from types import SimpleNamespace import pytest @@ -16,9 +18,9 @@ def updates_as_transaction(table, cat_column, column, cats, values): return "\n".join(sql) -def value_update_by_category(map_name, layer, column_name, cats, values): +def value_update_by_category(map_name, layer, column_name, cats, values, env): """Update column value for multiple rows based on category""" - db_info = gs.vector_db(map_name)[layer] + db_info = gs.vector_db(map_name, env=env)[layer] table = db_info["table"] database = db_info["database"] driver = db_info["driver"] @@ -31,7 +33,7 @@ def value_update_by_category(map_name, layer, column_name, cats, values): values=values, ) gs.write_command( - "db.execute", input="-", database=database, driver=driver, stdin=sql + "db.execute", input="-", database=database, driver=driver, stdin=sql, env=env ) @@ -44,13 +46,27 @@ def simple_dataset(tmp_path_factory): column_name = "double_value" num_points = 10 gs.core._create_location_xy(tmp_path, location) # pylint: disable=protected-access - with gs.setup.init(tmp_path / location): - gs.run_command("g.region", s=0, n=80, w=0, e=120, b=0, t=50, res=10, res3=10) - gs.run_command("v.random", output=map_name, npoints=num_points, seed=42) + with gs.setup.init(tmp_path / location, env=os.environ.copy()) as session: + gs.run_command( + "g.region", + s=0, + n=80, + w=0, + e=120, + b=0, + t=50, + res=10, + res3=10, + env=session.env, + ) + gs.run_command( + "v.random", output=map_name, npoints=num_points, seed=42, env=session.env + ) gs.run_command( "v.db.addtable", map=map_name, columns=f"{column_name} double precision", + env=session.env, ) cats = list(range(1, 1 + num_points)) values = [float(i) + 0.11 for i in range(100, 100 + num_points)] @@ -60,8 +76,10 @@ def simple_dataset(tmp_path_factory): column_name=column_name, cats=cats, values=values, + env=session.env, ) yield SimpleNamespace( + session=session, vector_name=map_name, column_name=column_name, values=values, diff --git a/scripts/v.db.univar/tests/v_db_univar_test.py b/scripts/v.db.univar/tests/v_db_univar_test.py index 84b55243b0a..820bc6c807d 100644 --- a/scripts/v.db.univar/tests/v_db_univar_test.py +++ b/scripts/v.db.univar/tests/v_db_univar_test.py @@ -42,6 +42,7 @@ def test_basic_stats(simple_dataset): map=simple_dataset.vector_name, column=simple_dataset.column_name, format="json", + env=simple_dataset.session.env, ) ) assert "statistics" in data @@ -72,6 +73,7 @@ def test_extra_stats(simple_dataset): column=simple_dataset.column_name, flags="e", format="json", + env=simple_dataset.session.env, ) ) stats = data["statistics"] @@ -93,6 +95,7 @@ def test_quartiles_default_percentile(simple_dataset): column=simple_dataset.column_name, flags="e", format="json", + env=simple_dataset.session.env, ) ) assert "statistics" in data @@ -134,6 +137,7 @@ def test_percentiles(simple_dataset): flags="e", percentile=percentiles, format="json", + env=simple_dataset.session.env, ) ) stats = data["statistics"] @@ -160,6 +164,7 @@ def test_fixed_values(simple_dataset): flags="e", percentile=percentiles, format="json", + env=simple_dataset.session.env, ) ) assert "statistics" in data diff --git a/scripts/v.dissolve/tests/conftest.py b/scripts/v.dissolve/tests/conftest.py index b74969999b1..55c49f1cf95 100644 --- a/scripts/v.dissolve/tests/conftest.py +++ b/scripts/v.dissolve/tests/conftest.py @@ -1,5 +1,7 @@ """Fixtures for v.dissolve tests""" +import os + from types import SimpleNamespace import pytest @@ -24,14 +26,14 @@ def updates_as_transaction(table, cat_column, column, column_quote, cats, values return "\n".join(sql) -def value_update_by_category(map_name, layer, column_name, cats, values): +def value_update_by_category(map_name, layer, column_name, cats, values, env): """Update column value for multiple rows based on category""" - db_info = gs.vector_db(map_name)[layer] + db_info = gs.vector_db(map_name, env=env)[layer] table = db_info["table"] database = db_info["database"] driver = db_info["driver"] cat_column = "cat" - column_type = gs.vector_columns(map_name, layer)[column_name] + column_type = gs.vector_columns(map_name, layer, env=env)[column_name] column_quote = bool(column_type["type"] in ("CHARACTER", "TEXT")) sql = updates_as_transaction( table=table, @@ -42,7 +44,7 @@ def value_update_by_category(map_name, layer, column_name, cats, values): values=values, ) gs.write_command( - "db.execute", input="-", database=database, driver=driver, stdin=sql + "db.execute", input="-", database=database, driver=driver, stdin=sql, env=env ) @@ -64,10 +66,29 @@ def dataset(tmp_path_factory): num_points = len(cats) gs.core._create_location_xy(tmp_path, location) # pylint: disable=protected-access - with grass_setup.init(tmp_path / location): - gs.run_command("g.region", s=0, n=80, w=0, e=120, b=0, t=50, res=10, res3=10) - gs.run_command("v.random", output=point_map_name, npoints=num_points, seed=42) - gs.run_command("v.voronoi", input=point_map_name, output=map_name) + with grass_setup.init(tmp_path / location, env=os.environ.copy()) as session: + gs.run_command( + "g.region", + s=0, + n=80, + w=0, + e=120, + b=0, + t=50, + res=10, + res3=10, + env=session.env, + ) + gs.run_command( + "v.random", + output=point_map_name, + npoints=num_points, + seed=42, + env=session.env, + ) + gs.run_command( + "v.voronoi", input=point_map_name, output=map_name, env=session.env + ) gs.run_command( "v.db.addtable", map=map_name, @@ -76,6 +97,7 @@ def dataset(tmp_path_factory): f"{float_column_name} double precision", f"{str_column_name} text", ], + env=session.env, ) value_update_by_category( map_name=map_name, @@ -83,6 +105,7 @@ def dataset(tmp_path_factory): column_name=int_column_name, cats=cats, values=int_values, + env=session.env, ) value_update_by_category( map_name=map_name, @@ -90,6 +113,7 @@ def dataset(tmp_path_factory): column_name=float_column_name, cats=cats, values=float_values, + env=session.env, ) value_update_by_category( map_name=map_name, @@ -97,8 +121,10 @@ def dataset(tmp_path_factory): column_name=str_column_name, cats=cats, values=str_values, + env=session.env, ) yield SimpleNamespace( + session=session, vector_name=map_name, int_column_name=int_column_name, int_values=int_values, @@ -127,10 +153,29 @@ def discontinuous_dataset(tmp_path_factory): num_points = len(cats) gs.core._create_location_xy(tmp_path, location) # pylint: disable=protected-access - with grass_setup.init(tmp_path / location): - gs.run_command("g.region", s=0, n=80, w=0, e=120, b=0, t=50, res=10, res3=10) - gs.run_command("v.random", output=point_map_name, npoints=num_points, seed=42) - gs.run_command("v.voronoi", input=point_map_name, output=map_name) + with grass_setup.init(tmp_path / location, env=os.environ.copy()) as session: + gs.run_command( + "g.region", + s=0, + n=80, + w=0, + e=120, + b=0, + t=50, + res=10, + res3=10, + env=session.env, + ) + gs.run_command( + "v.random", + output=point_map_name, + npoints=num_points, + seed=42, + env=session.env, + ) + gs.run_command( + "v.voronoi", input=point_map_name, output=map_name, env=session.env + ) gs.run_command( "v.db.addtable", map=map_name, @@ -139,6 +184,7 @@ def discontinuous_dataset(tmp_path_factory): f"{float_column_name} double precision", f"{str_column_name} text", ], + env=session.env, ) value_update_by_category( map_name=map_name, @@ -146,6 +192,7 @@ def discontinuous_dataset(tmp_path_factory): column_name=int_column_name, cats=cats, values=int_values, + env=session.env, ) value_update_by_category( map_name=map_name, @@ -153,6 +200,7 @@ def discontinuous_dataset(tmp_path_factory): column_name=float_column_name, cats=cats, values=float_values, + env=session.env, ) value_update_by_category( map_name=map_name, @@ -160,8 +208,10 @@ def discontinuous_dataset(tmp_path_factory): column_name=str_column_name, cats=cats, values=str_values, + env=session.env, ) yield SimpleNamespace( + session=session, vector_name=map_name, int_column_name=int_column_name, int_values=int_values, @@ -193,18 +243,40 @@ def dataset_layer_2(tmp_path_factory): layer = 2 gs.core._create_location_xy(tmp_path, location) # pylint: disable=protected-access - with grass_setup.init(tmp_path / location): - gs.run_command("g.region", s=0, n=80, w=0, e=120, b=0, t=50, res=10, res3=10) - gs.run_command("v.random", output=point_map_name, npoints=num_points, seed=42) + with grass_setup.init(tmp_path / location, env=os.environ.copy()) as session: + gs.run_command( + "g.region", + s=0, + n=80, + w=0, + e=120, + b=0, + t=50, + res=10, + res3=10, + env=session.env, + ) + gs.run_command( + "v.random", + output=point_map_name, + npoints=num_points, + seed=42, + env=session.env, + ) gs.run_command( "v.category", input=point_map_name, layer=[1, layer], output=point_map_name_layer_2, option="transfer", + env=session.env, ) gs.run_command( - "v.voronoi", input=point_map_name_layer_2, layer=layer, output=map_name + "v.voronoi", + input=point_map_name_layer_2, + layer=layer, + output=map_name, + env=session.env, ) gs.run_command( "v.db.addtable", @@ -215,6 +287,7 @@ def dataset_layer_2(tmp_path_factory): f"{float_column_name} double precision", f"{str_column_name} text", ], + env=session.env, ) value_update_by_category( map_name=map_name, @@ -222,6 +295,7 @@ def dataset_layer_2(tmp_path_factory): column_name=int_column_name, cats=cats, values=int_values, + env=session.env, ) value_update_by_category( map_name=map_name, @@ -229,6 +303,7 @@ def dataset_layer_2(tmp_path_factory): column_name=float_column_name, cats=cats, values=float_values, + env=session.env, ) value_update_by_category( map_name=map_name, @@ -236,8 +311,10 @@ def dataset_layer_2(tmp_path_factory): column_name=str_column_name, cats=cats, values=str_values, + env=session.env, ) yield SimpleNamespace( + session=session, vector_name=map_name, int_column_name=int_column_name, int_values=int_values, diff --git a/scripts/v.dissolve/tests/v_dissolve_aggregate_test.py b/scripts/v.dissolve/tests/v_dissolve_aggregate_test.py index 1c2b6d45123..68ca178b864 100644 --- a/scripts/v.dissolve/tests/v_dissolve_aggregate_test.py +++ b/scripts/v.dissolve/tests/v_dissolve_aggregate_test.py @@ -28,8 +28,9 @@ def test_aggregate_methods(dataset, aggregate_methods): output=dissolved_vector, aggregate_column=dataset.float_column_name, aggregate_method=aggregate_methods, + env=dataset.session.env, ) - columns = gs.vector_columns(dissolved_vector) + columns = gs.vector_columns(dissolved_vector, env=dataset.session.env) stats_columns = [ f"{dataset.float_column_name}_{method}" for method in aggregate_methods ] @@ -50,13 +51,14 @@ def test_aggregate_two_columns(dataset): output=dissolved_vector, aggregate_column=aggregate_columns, aggregate_method=aggregate_methods, + env=dataset.session.env, ) stats_columns = [ f"{column}_{method}" for method in aggregate_methods for column in aggregate_columns ] - columns = gs.vector_columns(dissolved_vector) + columns = gs.vector_columns(dissolved_vector, env=dataset.session.env) assert sorted(columns.keys()) == sorted( ["cat", dataset.str_column_name] + stats_columns ) @@ -81,16 +83,17 @@ def test_aggregate_column_result(dataset, backend): aggregate_method=stats, result_column=stats_columns, aggregate_backend=backend, + env=dataset.session.env, ) - vector_info = gs.vector_info(dissolved_vector) + vector_info = gs.vector_info(dissolved_vector, env=dataset.session.env) assert vector_info["level"] == 2 assert vector_info["centroids"] == 3 assert vector_info["areas"] == 3 assert vector_info["num_dblinks"] == 1 assert vector_info["attribute_primary_key"] == "cat" - columns = gs.vector_columns(dissolved_vector) + columns = gs.vector_columns(dissolved_vector, env=dataset.session.env) assert len(columns) == len(stats_columns) + 2 assert sorted(columns.keys()) == sorted( ["cat", dataset.str_column_name] + stats_columns @@ -111,9 +114,7 @@ def test_aggregate_column_result(dataset, backend): records = json.loads( gs.read_command( - "v.db.select", - map=dissolved_vector, - format="json", + "v.db.select", map=dissolved_vector, format="json", env=dataset.session.env ) )["records"] ref_unique_values = set(dataset.str_column_values) @@ -122,7 +123,10 @@ def test_aggregate_column_result(dataset, backend): assert set(actual_values) == ref_unique_values aggregate_n = [record["value_n"] for record in records] - assert sum(aggregate_n) == gs.vector_info(dataset.vector_name)["areas"] + assert ( + sum(aggregate_n) + == gs.vector_info(dataset.vector_name, env=dataset.session.env)["areas"] + ) assert sorted(aggregate_n) == [1, 2, 3] aggregate_sum = [record["value_sum"] for record in records] assert sorted(aggregate_sum) == [ @@ -199,16 +203,17 @@ def test_sqlite_agg_accepted(dataset): aggregate_column=dataset.float_column_name, aggregate_method=stats, aggregate_backend="sql", + env=dataset.session.env, ) - vector_info = gs.vector_info(dissolved_vector) + vector_info = gs.vector_info(dissolved_vector, env=dataset.session.env) assert vector_info["level"] == 2 assert vector_info["centroids"] == 3 assert vector_info["areas"] == 3 assert vector_info["num_dblinks"] == 1 assert vector_info["attribute_primary_key"] == "cat" - columns = gs.vector_columns(dissolved_vector) + columns = gs.vector_columns(dissolved_vector, env=dataset.session.env) assert len(columns) == len(expected_stats_columns) + 2 assert sorted(columns.keys()) == sorted( ["cat", dataset.str_column_name] + expected_stats_columns @@ -229,9 +234,7 @@ def test_sqlite_agg_accepted(dataset): records = json.loads( gs.read_command( - "v.db.select", - map=dissolved_vector, - format="json", + "v.db.select", map=dissolved_vector, format="json", env=dataset.session.env ) )["records"] ref_unique_values = set(dataset.str_column_values) @@ -240,7 +243,10 @@ def test_sqlite_agg_accepted(dataset): assert set(actual_values) == ref_unique_values aggregate_n = [record[f"{dataset.float_column_name}_count"] for record in records] - assert sum(aggregate_n) == gs.vector_info(dataset.vector_name)["areas"] + assert ( + sum(aggregate_n) + == gs.vector_info(dataset.vector_name, env=dataset.session.env)["areas"] + ) assert sorted(aggregate_n) == [1, 2, 3] @@ -255,12 +261,11 @@ def test_sqlite_concat(dataset): aggregate_column=f"group_concat({dataset.int_column_name})", result_column="concat_values text", aggregate_backend="sql", + env=dataset.session.env, ) records = json.loads( gs.read_command( - "v.db.select", - map=dissolved_vector, - format="json", + "v.db.select", map=dissolved_vector, format="json", env=dataset.session.env ) )["records"] # Order of records is ignored - they are just sorted. @@ -283,12 +288,11 @@ def test_sqlite_concat_with_two_parameters(dataset): aggregate_column=f"group_concat({dataset.int_column_name}, '{separator}')", result_column="concat_values text", aggregate_backend="sql", + env=dataset.session.env, ) records = json.loads( gs.read_command( - "v.db.select", - map=dissolved_vector, - format="json", + "v.db.select", map=dissolved_vector, format="json", env=dataset.session.env ) )["records"] # Order of records is ignored - they are just sorted. @@ -315,16 +319,17 @@ def test_duplicate_columns_and_methods_accepted(dataset): aggregate_column=[dataset.float_column_name, dataset.float_column_name], aggregate_method=stats, aggregate_backend="sql", + env=dataset.session.env, ) - vector_info = gs.vector_info(dissolved_vector) + vector_info = gs.vector_info(dissolved_vector, env=dataset.session.env) assert vector_info["level"] == 2 assert vector_info["centroids"] == 3 assert vector_info["areas"] == 3 assert vector_info["num_dblinks"] == 1 assert vector_info["attribute_primary_key"] == "cat" - columns = gs.vector_columns(dissolved_vector) + columns = gs.vector_columns(dissolved_vector, env=dataset.session.env) assert sorted(columns.keys()) == sorted( ["cat", dataset.str_column_name] + expected_stats_columns ), "Unexpected autogenerated column names" @@ -350,16 +355,17 @@ def test_sql_expressions_accepted(dataset): aggregate_column=aggregate_columns, result_column=result_columns, aggregate_backend="sql", + env=dataset.session.env, ) - vector_info = gs.vector_info(dissolved_vector) + vector_info = gs.vector_info(dissolved_vector, env=dataset.session.env) assert vector_info["level"] == 2 assert vector_info["centroids"] == 3 assert vector_info["areas"] == 3 assert vector_info["num_dblinks"] == 1 assert vector_info["attribute_primary_key"] == "cat" - columns = gs.vector_columns(dissolved_vector) + columns = gs.vector_columns(dissolved_vector, env=dataset.session.env) assert sorted(columns.keys()) == sorted( ["cat", dataset.str_column_name] + expected_stats_columns ) @@ -383,6 +389,7 @@ def test_no_methods_with_univar_and_result_columns_fail(dataset): result_column=result_columns, aggregate_backend="univar", errors="status", + env=dataset.session.env, ) != 0 ) @@ -400,6 +407,7 @@ def test_int_fails(dataset): aggregate_column=dataset.float_column_name, aggregate_method="n", errors="status", + env=dataset.session.env, ) != 0 ) diff --git a/scripts/v.dissolve/tests/v_dissolve_geometry_test.py b/scripts/v.dissolve/tests/v_dissolve_geometry_test.py index 71c950a2141..f67f14b8e90 100644 --- a/scripts/v.dissolve/tests/v_dissolve_geometry_test.py +++ b/scripts/v.dissolve/tests/v_dissolve_geometry_test.py @@ -19,9 +19,12 @@ def test_dissolve_discontinuous_str(discontinuous_dataset): input=dataset.vector_name, column=dataset.str_column_name, output=dissolved_vector, + env=discontinuous_dataset.session.env, ) - vector_info = gs.vector_info(dissolved_vector) + vector_info = gs.vector_info( + dissolved_vector, env=discontinuous_dataset.session.env + ) assert vector_info["level"] == 2 assert vector_info["centroids"] == 5 assert vector_info["areas"] == 5 @@ -40,7 +43,7 @@ def test_dissolve_discontinuous_str(discontinuous_dataset): assert vector_info["primitives"] == 23 assert vector_info["map3d"] == 0 - columns = gs.vector_columns(dissolved_vector) + columns = gs.vector_columns(dissolved_vector, env=discontinuous_dataset.session.env) assert len(columns) == 2 assert sorted(columns.keys()) == sorted(["cat", dataset.str_column_name]) column_info = columns[dataset.str_column_name] @@ -51,6 +54,7 @@ def test_dissolve_discontinuous_str(discontinuous_dataset): "v.db.select", map=dissolved_vector, format="json", + env=dataset.session.env, ) )["records"] ref_unique_values = set(dataset.str_column_values) diff --git a/scripts/v.dissolve/tests/v_dissolve_layers_test.py b/scripts/v.dissolve/tests/v_dissolve_layers_test.py index a13dc93315a..702aa8f8496 100644 --- a/scripts/v.dissolve/tests/v_dissolve_layers_test.py +++ b/scripts/v.dissolve/tests/v_dissolve_layers_test.py @@ -28,16 +28,21 @@ def test_layer_2(dataset_layer_2): aggregate_column=dataset.float_column_name, aggregate_method=stats, aggregate_backend="sql", + env=dataset_layer_2.session.env, ) - vector_info = gs.vector_info(dissolved_vector, layer=2) + vector_info = gs.vector_info( + dissolved_vector, layer=2, env=dataset_layer_2.session.env + ) assert vector_info["level"] == 2 assert vector_info["centroids"] == 3 assert vector_info["areas"] == 3 assert vector_info["num_dblinks"] == 1 assert vector_info["attribute_primary_key"] == "cat" - columns = gs.vector_columns(dissolved_vector, layer=2) + columns = gs.vector_columns( + dissolved_vector, layer=2, env=dataset_layer_2.session.env + ) assert len(columns) == len(expected_stats_columns) + 2 assert sorted(columns.keys()) == sorted( ["cat", dataset.str_column_name] + expected_stats_columns @@ -62,6 +67,7 @@ def test_layer_2(dataset_layer_2): map=dissolved_vector, layer=2, format="json", + env=dataset_layer_2.session.env, ) )["records"] ref_unique_values = set(dataset.str_column_values) @@ -70,5 +76,8 @@ def test_layer_2(dataset_layer_2): assert set(actual_values) == ref_unique_values aggregate_n = [record[f"{dataset.float_column_name}_count"] for record in records] - assert sum(aggregate_n) == gs.vector_info(dataset.vector_name)["areas"] + assert ( + sum(aggregate_n) + == gs.vector_info(dataset.vector_name, env=dataset_layer_2.session.env)["areas"] + ) assert sorted(aggregate_n) == [1, 2, 3] diff --git a/scripts/v.dissolve/tests/v_dissolve_test.py b/scripts/v.dissolve/tests/v_dissolve_test.py index f5d579f5139..5a0d09aa9f9 100644 --- a/scripts/v.dissolve/tests/v_dissolve_test.py +++ b/scripts/v.dissolve/tests/v_dissolve_test.py @@ -13,9 +13,10 @@ def test_dissolve_int(dataset): input=dataset.vector_name, column=dataset.int_column_name, output=dissolved_vector, + env=dataset.session.env, ) - vector_info = gs.vector_info(dissolved_vector) + vector_info = gs.vector_info(dissolved_vector, env=dataset.session.env) assert vector_info["level"] == 2 assert vector_info["centroids"] == 3 assert vector_info["areas"] == 3 @@ -42,9 +43,10 @@ def test_dissolve_str(dataset): input=dataset.vector_name, column=dataset.str_column_name, output=dissolved_vector, + env=dataset.session.env, ) - vector_info = gs.vector_info(dissolved_vector) + vector_info = gs.vector_info(dissolved_vector, env=dataset.session.env) assert vector_info["level"] == 2 assert vector_info["centroids"] == 3 assert vector_info["areas"] == 3 @@ -63,7 +65,7 @@ def test_dissolve_str(dataset): assert vector_info["primitives"] == 18 assert vector_info["map3d"] == 0 - columns = gs.vector_columns(dissolved_vector) + columns = gs.vector_columns(dissolved_vector, env=dataset.session.env) assert len(columns) == 2 assert sorted(columns.keys()) == sorted(["cat", dataset.str_column_name]) column_info = columns[dataset.str_column_name] @@ -71,9 +73,7 @@ def test_dissolve_str(dataset): records = json.loads( gs.read_command( - "v.db.select", - map=dissolved_vector, - format="json", + "v.db.select", map=dissolved_vector, format="json", env=dataset.session.env ) )["records"] ref_unique_values = set(dataset.str_column_values) diff --git a/temporal/t.rast.list/tests/conftest.py b/temporal/t.rast.list/tests/conftest.py index 393e76c186f..a9ac08e77c9 100644 --- a/temporal/t.rast.list/tests/conftest.py +++ b/temporal/t.rast.list/tests/conftest.py @@ -1,5 +1,7 @@ """Fixture for t.rast.list test""" +import os + from datetime import datetime from types import SimpleNamespace @@ -17,7 +19,7 @@ def space_time_raster_dataset(tmp_path_factory): tmp_path = tmp_path_factory.mktemp("raster_time_series") location = "test" gs.core._create_location_xy(tmp_path, location) # pylint: disable=protected-access - with gs.setup.init(tmp_path / location): + with gs.setup.init(tmp_path / location, env=os.environ.copy()): gs.run_command("g.region", s=0, n=80, w=0, e=120, b=0, t=50, res=10, res3=10) names = [f"precipitation_{i}" for i in range(1, 7)] max_values = [550, 450, 320, 510, 300, 650] diff --git a/vector/v.fill.holes/tests/conftest.py b/vector/v.fill.holes/tests/conftest.py index a86a07cc043..f2413728e71 100644 --- a/vector/v.fill.holes/tests/conftest.py +++ b/vector/v.fill.holes/tests/conftest.py @@ -1,5 +1,7 @@ """Fixture for v.fill.holes test""" +import os + from types import SimpleNamespace import pytest @@ -168,9 +170,14 @@ """ -def import_data(path, areas_name, areas_with_space_in_between): +def import_data(path, areas_name, areas_with_space_in_between, env): gs.write_command( - "v.in.ascii", input="-", output=areas_name, stdin=DATA, format="standard" + "v.in.ascii", + input="-", + output=areas_name, + stdin=DATA, + format="standard", + env=env, ) attributes = path / "test.csv" attributes.write_text(AREAS_WITH_SPACE_ATTRIBUTES) @@ -179,13 +186,16 @@ def import_data(path, areas_name, areas_with_space_in_between): # Attributes need to be created first because no vector map of the same name # can exist when table is imported (interally using v.in.ogr and vector part # is deleted). - gs.run_command("db.in.ogr", input=attributes, output=areas_with_space_in_between) + gs.run_command( + "db.in.ogr", input=attributes, output=areas_with_space_in_between, env=env + ) gs.write_command( "v.in.ascii", input="-", output=areas_with_space_in_between, stdin=AREAS_WITH_SPACE_GEOMETRY, format="standard", + env=env, ) # Our old cat column is now called cat_, so we need to rename it to cat, # but that's possible only on vector map level, so connect, rename, and @@ -194,15 +204,20 @@ def import_data(path, areas_name, areas_with_space_in_between): "v.db.connect", map=areas_with_space_in_between, table=areas_with_space_in_between, + env=env, ) gs.run_command( - "v.db.renamecolumn", map=areas_with_space_in_between, column=("cat_", "cat") + "v.db.renamecolumn", + map=areas_with_space_in_between, + column=("cat_", "cat"), + env=env, ) gs.run_command( "v.db.connect", map=areas_with_space_in_between, table=areas_with_space_in_between, flags="o", + env=env, ) @@ -216,12 +231,15 @@ def area_dataset(tmp_path_factory): areas_with_space_in_between = "areas_with_space_in_between" gs.core._create_location_xy(tmp_path, location) # pylint: disable=protected-access - with gs.setup.init(tmp_path / location): + with gs.setup.init(tmp_path / location, env=os.environ.copy()) as session: import_data( path=tmp_path, areas_name=areas_name, areas_with_space_in_between=areas_with_space_in_between, + env=session.env, ) yield SimpleNamespace( - name=areas_name, areas_with_space_in_between=areas_with_space_in_between + name=areas_name, + areas_with_space_in_between=areas_with_space_in_between, + session=session, ) diff --git a/vector/v.fill.holes/tests/v_fill_holes_test.py b/vector/v.fill.holes/tests/v_fill_holes_test.py index 5bac9c6519b..e3a6d7a00a2 100644 --- a/vector/v.fill.holes/tests/v_fill_holes_test.py +++ b/vector/v.fill.holes/tests/v_fill_holes_test.py @@ -8,9 +8,14 @@ def test_removal(area_dataset): """Check that holes are removed""" output = "test" - gs.run_command("v.fill.holes", input=area_dataset.name, output=output) - original_info = gs.vector_info(area_dataset.name) - info = gs.vector_info(output) + gs.run_command( + "v.fill.holes", + input=area_dataset.name, + output=output, + env=area_dataset.session.env, + ) + original_info = gs.vector_info(area_dataset.name, env=area_dataset.session.env) + info = gs.vector_info(output, env=area_dataset.session.env) removed = 4 assert info["nodes"] == original_info["nodes"] - removed assert info["points"] == original_info["points"] @@ -26,10 +31,15 @@ def test_no_change(area_dataset): """Check that space in between is not changed including attributes""" output = "no_change" gs.run_command( - "v.fill.holes", input=area_dataset.areas_with_space_in_between, output=output + "v.fill.holes", + input=area_dataset.areas_with_space_in_between, + output=output, + env=area_dataset.session.env, + ) + original_info = gs.vector_info( + area_dataset.areas_with_space_in_between, env=area_dataset.session.env ) - original_info = gs.vector_info(area_dataset.areas_with_space_in_between) - info = gs.vector_info(output) + info = gs.vector_info(output, env=area_dataset.session.env) for item in [ "nodes", "points", @@ -42,9 +52,11 @@ def test_no_change(area_dataset): ]: assert info[item] == original_info[item], item - records = json.loads(gs.read_command("v.db.select", map=output, format="json"))[ - "records" - ] + records = json.loads( + gs.read_command( + "v.db.select", map=output, format="json", env=area_dataset.session.env + ) + )["records"] assert len(records) == 2 assert records[0]["cat"] == 3 assert records[0]["name"] == "Left plot" From cdc9b41118c03ce7be4b831e4cf35e695db5897e Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Thu, 29 Feb 2024 20:57:22 -0500 Subject: [PATCH 08/13] Use session.env in temporal tests for isolation --- temporal/t.rast.list/tests/conftest.py | 21 ++++++++++-- .../t.rast.list/tests/t_rast_list_test.py | 34 ++++++++++++++++--- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/temporal/t.rast.list/tests/conftest.py b/temporal/t.rast.list/tests/conftest.py index a9ac08e77c9..fd3e520f4c0 100644 --- a/temporal/t.rast.list/tests/conftest.py +++ b/temporal/t.rast.list/tests/conftest.py @@ -19,12 +19,23 @@ def space_time_raster_dataset(tmp_path_factory): tmp_path = tmp_path_factory.mktemp("raster_time_series") location = "test" gs.core._create_location_xy(tmp_path, location) # pylint: disable=protected-access - with gs.setup.init(tmp_path / location, env=os.environ.copy()): - gs.run_command("g.region", s=0, n=80, w=0, e=120, b=0, t=50, res=10, res3=10) + with gs.setup.init(tmp_path / location, env=os.environ.copy()) as session: + gs.run_command( + "g.region", + s=0, + n=80, + w=0, + e=120, + b=0, + t=50, + res=10, + res3=10, + env=session.env, + ) names = [f"precipitation_{i}" for i in range(1, 7)] max_values = [550, 450, 320, 510, 300, 650] for name, value in zip(names, max_values): - gs.mapcalc(f"{name} = rand(0, {value})", seed=1) + gs.mapcalc(f"{name} = rand(0, {value})", seed=1, env=session.env) dataset_name = "precipitation" gs.run_command( "t.create", @@ -33,6 +44,7 @@ def space_time_raster_dataset(tmp_path_factory): output=dataset_name, title="Precipitation", description="Random series generated for tests", + env=session.env, ) dataset_file = tmp_path / "names.txt" dataset_file.write_text("\n".join(names)) @@ -44,12 +56,15 @@ def space_time_raster_dataset(tmp_path_factory): file=dataset_file, start="2001-01-01", increment="1 month", + env=session.env, ) times = [datetime(2001, i, 1) for i in range(1, len(names) + 1)] full_names = [f"{name}@PERMANENT" for name in names] yield SimpleNamespace( + session=session, name=dataset_name, raster_names=names, full_raster_names=full_names, start_times=times, + env=session, ) diff --git a/temporal/t.rast.list/tests/t_rast_list_test.py b/temporal/t.rast.list/tests/t_rast_list_test.py index abda190d123..415aaecf06b 100644 --- a/temporal/t.rast.list/tests/t_rast_list_test.py +++ b/temporal/t.rast.list/tests/t_rast_list_test.py @@ -17,14 +17,21 @@ def test_defaults(space_time_raster_dataset): """Check that the module runs with default parameters""" - gs.run_command("t.rast.list", input=space_time_raster_dataset.name) + gs.run_command( + "t.rast.list", + input=space_time_raster_dataset.name, + env=space_time_raster_dataset.session.env, + ) def test_line(space_time_raster_dataset): """Line format can be parsed and contains full names by default""" names = ( gs.read_command( - "t.rast.list", input=space_time_raster_dataset.name, format="line" + "t.rast.list", + input=space_time_raster_dataset.name, + format="line", + env=space_time_raster_dataset.session.env, ) .strip() .split(",") @@ -36,7 +43,10 @@ def test_json(space_time_raster_dataset): """Check JSON can be parsed and contains the right values""" result = json.loads( gs.read_command( - "t.rast.list", input=space_time_raster_dataset.name, format="json" + "t.rast.list", + input=space_time_raster_dataset.name, + format="json", + env=space_time_raster_dataset.session.env, ) ) assert "data" in result @@ -53,7 +63,10 @@ def test_yaml(space_time_raster_dataset): """Check JSON can be parsed and contains the right values""" result = yaml.safe_load( gs.read_command( - "t.rast.list", input=space_time_raster_dataset.name, format="yaml" + "t.rast.list", + input=space_time_raster_dataset.name, + format="yaml", + env=space_time_raster_dataset.session.env, ) ) assert "data" in result @@ -80,6 +93,7 @@ def test_csv(space_time_raster_dataset, separator, delimiter): columns=columns, format="csv", separator=separator, + env=space_time_raster_dataset.session.env, ) io_string = io.StringIO(text) reader = csv.DictReader( @@ -128,6 +142,7 @@ def test_columns_list(space_time_raster_dataset): method="list", columns=columns, format="json", + env=space_time_raster_dataset.session.env, ) ) data = result["data"] @@ -155,6 +170,7 @@ def test_columns_delta_gran(space_time_raster_dataset): method="gran", columns=columns, format="json", + env=space_time_raster_dataset.session.env, ) ) data = result["data"] @@ -171,6 +187,7 @@ def test_json_empty_result(space_time_raster_dataset): input=space_time_raster_dataset.name, format="json", where="FALSE", + env=space_time_raster_dataset.session.env, ) ) assert "data" in result @@ -187,6 +204,7 @@ def test_plain_empty_result(space_time_raster_dataset, output_format): format=output_format, where="FALSE", errors="status", + env=space_time_raster_dataset.session.env, ) assert return_code != 0 @@ -195,7 +213,10 @@ def test_plain_empty_result(space_time_raster_dataset, output_format): def test_no_header_accepted(space_time_raster_dataset, output_format): """Check that the no column names flag is accepted""" gs.run_command( - "t.rast.list", input=space_time_raster_dataset.name, format=output_format + "t.rast.list", + input=space_time_raster_dataset.name, + format=output_format, + env=space_time_raster_dataset.session.env, ) @@ -212,6 +233,7 @@ def test_no_header_rejected(space_time_raster_dataset, output_format): format=output_format, flags="u", errors="status", + env=space_time_raster_dataset.session.env, ) assert return_code != 0 @@ -225,6 +247,7 @@ def test_other_methods_json(space_time_raster_dataset, method): input=space_time_raster_dataset.name, format="json", method=method, + env=space_time_raster_dataset.session.env, ) ) assert "data" in result @@ -245,6 +268,7 @@ def test_gran_json(space_time_raster_dataset): format="json", method="gran", gran="15 days", + env=space_time_raster_dataset.session.env, ) ) assert "data" in result From a23965c6be4e7dfc465a467dc2f16e111cc09f20 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Sat, 2 Mar 2024 22:57:34 -0500 Subject: [PATCH 09/13] Revert temporary change "grass.script: Provide env parameter in the g.message API" This reverts commit 4a6fd681d44ca503dd9c076ca69c5e93cb15bdee. --- python/grass/script/core.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/python/grass/script/core.py b/python/grass/script/core.py index 7c74a49ef57..438bf2fab1c 100644 --- a/python/grass/script/core.py +++ b/python/grass/script/core.py @@ -659,16 +659,16 @@ def exec_command( # interface to g.message -def message(msg, flag=None, env=None): +def message(msg, flag=None): """Display a message using `g.message` :param str msg: message to be displayed :param str flag: flags (given as string) """ - run_command("g.message", flags=flag, message=msg, errors="ignore", env=env) + run_command("g.message", flags=flag, message=msg, errors="ignore") -def debug(msg, debug=1, env=None): +def debug(msg, debug=1): """Display a debugging message using `g.message -d`. The visibility of a debug message at runtime is controlled by @@ -686,26 +686,26 @@ def debug(msg, debug=1, env=None): if sys.platform == "win32": msg = msg.replace("&", "^&") - run_command("g.message", flags="d", message=msg, debug=debug, env=env) + run_command("g.message", flags="d", message=msg, debug=debug) -def verbose(msg, env=None): +def verbose(msg): """Display a verbose message using `g.message -v` :param str msg: verbose message to be displayed """ - message(msg, flag="v", env=env) + message(msg, flag="v") -def info(msg, env=None): +def info(msg): """Display an informational message using `g.message -i` :param str msg: informational message to be displayed """ - message(msg, flag="i", env=env) + message(msg, flag="i") -def percent(i, n, s, env=None): +def percent(i, n, s): """Display a progress info message using `g.message -p` :: @@ -720,18 +720,18 @@ def percent(i, n, s, env=None): :param int n: total number of items :param int s: increment size """ - message("%d %d %d" % (i, n, s), flag="p", env=env) + message("%d %d %d" % (i, n, s), flag="p") -def warning(msg, env=None): +def warning(msg): """Display a warning message using `g.message -w` :param str msg: warning message to be displayed """ - message(msg, flag="w", env=env) + message(msg, flag="w") -def error(msg, env=None): +def error(msg): """Display an error message using `g.message -e` This function does not end the execution of the program. @@ -740,10 +740,10 @@ def error(msg, env=None): :param str msg: error message to be displayed """ - message(msg, flag="e", env=env) + message(msg, flag="e") -def fatal(msg, env=None): +def fatal(msg): """Display an error message using `g.message -e`, then abort or raise Raises exception when module global raise_on_error is 'True', abort @@ -756,7 +756,7 @@ def fatal(msg, env=None): if raise_on_error: raise ScriptError(msg) - error(msg, env=None) + error(msg) sys.exit(1) From 0c76cdfa185683dcad0a970313d59eef673bf1c8 Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Thu, 2 May 2024 10:29:18 -0400 Subject: [PATCH 10/13] Apply test updates to new tests --- .../grass/script/tests/grass_script_core_location_test.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python/grass/script/tests/grass_script_core_location_test.py b/python/grass/script/tests/grass_script_core_location_test.py index 2faabc3d56f..fdffa340b83 100644 --- a/python/grass/script/tests/grass_script_core_location_test.py +++ b/python/grass/script/tests/grass_script_core_location_test.py @@ -121,8 +121,8 @@ def test_path_only(tmp_path): assert full_path.exists() assert mapset_path.exists() assert wkt_file.exists() - with gs.setup.init(full_path): - epsg = gs.parse_command("g.proj", flags="g")["srid"] + with gs.setup.init(full_path, env=os.environ.copy()) as session: + epsg = gs.parse_command("g.proj", flags="g", env=session.env)["srid"] assert epsg == "EPSG:3358" @@ -132,8 +132,8 @@ def test_create_project(tmp_path): assert (tmp_path / name).exists() wkt_file = tmp_path / name / "PERMANENT" / "PROJ_WKT" assert wkt_file.exists() - with gs.setup.init(tmp_path / name): - epsg = gs.parse_command("g.proj", flags="g")["srid"] + with gs.setup.init(tmp_path / name, env=os.environ.copy()) as session: + epsg = gs.parse_command("g.proj", flags="g", env=session.env)["srid"] assert epsg == "EPSG:3358" From 791c86894a11bcc875b0e3bfa7ce7b760171be5f Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Fri, 3 May 2024 11:31:54 -0400 Subject: [PATCH 11/13] Test that GISBASE is set --- .../script/tests/grass_script_setup_test.py | 71 +++++++++++++++++-- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/python/grass/script/tests/grass_script_setup_test.py b/python/grass/script/tests/grass_script_setup_test.py index 1632348baa5..595fad9e657 100644 --- a/python/grass/script/tests/grass_script_setup_test.py +++ b/python/grass/script/tests/grass_script_setup_test.py @@ -49,7 +49,7 @@ def test_init_session_finish(tmp_path): assert not os.path.exists(session_file) -def test_init_finish_global_functions(tmp_path): +def test_init_finish_global_functions_with_env(tmp_path): """Check that init and finish global functions work""" location = "test" gs.core._create_location_xy(tmp_path, location) # pylint: disable=protected-access @@ -62,8 +62,57 @@ def test_init_finish_global_functions(tmp_path): assert not os.path.exists(session_file) +def test_init_finish_global_functions_capture_strerr0(tmp_path): + """Check that init and finish global functions work with global env""" + + def init_finish(queue): + gs.set_capture_stderr(True) + location = "test" + gs.core._create_location_xy( # pylint: disable=protected-access + tmp_path, location + ) + gs.setup.init(tmp_path / location) + gs.run_command("g.region", flags="p") + runtime_present = bool(os.environ.get("GISBASE")) + queue.put((os.environ["GISRC"], runtime_present)) + gs.setup.finish() + + session_file, runtime_present = run_in_subprocess(init_finish) + assert session_file, "Expected file name from the subprocess" + assert runtime_present, "Runtime (GISBASE) should be present" + assert not os.path.exists(session_file), "Session file not deleted" + + +def test_init_finish_global_functions_capture_strerrX(tmp_path): + """Check that init and finish global functions work with global env""" + + def init_finish(queue): + gs.set_capture_stderr(True) + location = "test" + gs.core._create_location_xy( # pylint: disable=protected-access + tmp_path, location + ) + gs.setup.init(tmp_path / location) + gs.run_command("g.region", flags="p") + runtime_present = bool(os.environ.get("GISBASE")) + session_file = os.environ["GISRC"] + gs.setup.finish() + runtime_present_after = bool(os.environ.get("GISBASE")) + queue.put((session_file, runtime_present, runtime_present_after)) + + session_file, runtime_present, runtime_present_after = run_in_subprocess( + init_finish + ) + assert session_file, "Expected file name from the subprocess" + assert runtime_present, "Runtime (GISBASE) should be present" + assert not os.path.exists(session_file), "Session file not deleted" + # This is testing the current implementation behavior, but it is not required + # to be this way in terms of design. + assert runtime_present_after, "Runtime should continue to be present" + + def test_init_finish_global_functions_capture_strerr(tmp_path): - """Check that init and finish global functions work""" + """Check that init and finish global functions work with global env""" def init_finish(queue): gs.set_capture_stderr(True) @@ -73,11 +122,19 @@ def init_finish(queue): ) gs.setup.init(tmp_path / location) gs.run_command("g.region", flags="p") - queue.put(os.environ["GISRC"]) + runtime_present_during = bool(os.environ.get("GISBASE")) gs.setup.finish() + runtime_present_after = bool(os.environ.get("GISBASE")) + queue.put((os.environ["GISRC"], runtime_present_during, runtime_present_after)) - session_file = run_in_subprocess(init_finish) + session_file, runtime_present, runtime_present_after = run_in_subprocess( + init_finish + ) assert session_file, "Expected file name from the subprocess" + assert runtime_present, "Runtime (GISBASE) should be present" + # This is testing the current implementation behavior, but it is not required + # to be this way in terms of design. + assert runtime_present_after, "Runtime should continue to be present" assert not os.path.exists(session_file), "Session file not deleted" @@ -93,11 +150,13 @@ def workload(queue): with gs.setup.init(tmp_path / location) as session: gs.run_command("g.region", flags="p", env=session.env) session_file = os.environ["GISRC"] - queue.put((session_file, os.path.exists(session_file))) + runtime_present = bool(os.environ.get("GISBASE")) + queue.put((session_file, os.path.exists(session_file), runtime_present)) - session_file, file_existed = run_in_subprocess(workload) + session_file, file_existed, runtime_present = run_in_subprocess(workload) assert session_file, "Expected file name from the subprocess" assert file_existed, "File should have been present" + assert runtime_present, "Runtime (GISBASE) should be present" assert not os.path.exists(session_file), "Session file not deleted" assert not os.environ.get("GISRC") assert not os.environ.get("GISBASE") From 8a24759403f64d9e5bacbd2f9cff88d3300e176a Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Tue, 18 Jun 2024 14:26:28 -0400 Subject: [PATCH 12/13] More detailed and correctly written test for init in an isolated subprocess --- .../script/tests/grass_script_setup_test.py | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/python/grass/script/tests/grass_script_setup_test.py b/python/grass/script/tests/grass_script_setup_test.py index 595fad9e657..cd0939046ad 100644 --- a/python/grass/script/tests/grass_script_setup_test.py +++ b/python/grass/script/tests/grass_script_setup_test.py @@ -111,7 +111,7 @@ def init_finish(queue): assert runtime_present_after, "Runtime should continue to be present" -def test_init_finish_global_functions_capture_strerr(tmp_path): +def test_init_finish_global_functions_isolated(tmp_path): """Check that init and finish global functions work with global env""" def init_finish(queue): @@ -123,18 +123,45 @@ def init_finish(queue): gs.setup.init(tmp_path / location) gs.run_command("g.region", flags="p") runtime_present_during = bool(os.environ.get("GISBASE")) + session_file_variable_present_during = bool(os.environ.get("GISRC")) + session_file = os.environ.get("GISRC") + if session_file: + session_file_present_during = os.path.exists(session_file) + else: + session_file_present_during = False gs.setup.finish() + session_file_variable_present_after = bool(os.environ.get("GISRC")) runtime_present_after = bool(os.environ.get("GISBASE")) - queue.put((os.environ["GISRC"], runtime_present_during, runtime_present_after)) + queue.put( + ( + session_file, + session_file_variable_present_during, + session_file_present_during, + session_file_variable_present_after, + runtime_present_during, + runtime_present_after, + ) + ) - session_file, runtime_present, runtime_present_after = run_in_subprocess( - init_finish - ) - assert session_file, "Expected file name from the subprocess" - assert runtime_present, "Runtime (GISBASE) should be present" + ( + session_file, + session_file_variable_present_during, + session_file_present_during, + session_file_variable_present_after, + runtime_present_during, + runtime_present_after, + ) = run_in_subprocess(init_finish) + + # Runtime + assert runtime_present_during, "Runtime (GISBASE) should be present" # This is testing the current implementation behavior, but it is not required # to be this way in terms of design. - assert runtime_present_after, "Runtime should continue to be present" + assert runtime_present_after, "Expected GISBASE to be present when finished" + + # Session + assert session_file_present_during, "Expected session file to be present" + assert session_file_variable_present_during, "Variable GISRC should be present" + assert not session_file_variable_present_after, "Not expecting GISRC when finished" assert not os.path.exists(session_file), "Session file not deleted" From 7ae6883720d27bde3a1c7a727e5008cf120f184f Mon Sep 17 00:00:00 2001 From: Vaclav Petras Date: Tue, 18 Jun 2024 15:50:37 -0400 Subject: [PATCH 13/13] Update python/grass/script/setup.py Co-authored-by: Ondrej Pesek --- python/grass/script/setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/grass/script/setup.py b/python/grass/script/setup.py index 3ea9ee6a94f..dd6fd137485 100644 --- a/python/grass/script/setup.py +++ b/python/grass/script/setup.py @@ -399,8 +399,8 @@ class SessionHandle: If you don't want to modify the global environment, use the _env_ parameter for the _init_ function to modify the provided environment instead. This environment is then available as an attribute of the session object. - The attribute than needs to be passed to all calls of GRASS - tools and function which wrap them. + The attribute then needs to be passed to all calls of GRASS + tools and functions that wrap them. Context manager usage with custom environment:: # ... setup sys.path before import as needed