From b03556ea571afed29612dc87b78f05e7a0fafae2 Mon Sep 17 00:00:00 2001 From: Anna Petrasova Date: Thu, 8 May 2025 15:08:11 -0400 Subject: [PATCH 1/4] grass.script: new RegionManager context manager --- doc/development/style_guide.md | 40 ++-- .../notebooks/parallelization_tutorial.ipynb | 12 +- python/grass/script/__init__.py | 4 + python/grass/script/raster.py | 177 ++++++++++++++++++ 4 files changed, 211 insertions(+), 22 deletions(-) diff --git a/doc/development/style_guide.md b/doc/development/style_guide.md index 2d08ed03f9a..8a0257657c3 100644 --- a/doc/development/style_guide.md +++ b/doc/development/style_guide.md @@ -408,6 +408,7 @@ region. If you need to change the computational region, there are ways to change it only within your script, not affecting the current region. +See [Changing computational region](#changing-computational-region) for more details. #### Mapsets @@ -536,36 +537,43 @@ gs.try_remove(file_path) #### Changing computational region If a tool needs to change the computational region for part of the computation, -temporary region in Python API is the simplest way to do it: +use the _RegionManager_ context manager. +This makes any changes done in the tool local for the tool without influencing +other tools running in the same session. ```python -gs.use_temp_region() # From now on, use a separate region in the script. -# Set the computational region with g.region as needed. -grass.run_command('g.region', raster='input') -gs.del_temp_region() -# Original region applies now. +with gs.RegionManager(n=226000, s=222000, w=634000, e=638000): + stats = gs.parse_command("r.univar", map="elevation", format="json") ``` -This makes any changes done in the tool local for the tool without influencing -other tools running in the same session. - -If you need even more control, use the GRASS_REGION environment variable which -is passed to subprocesses. Python API has functions which help with the setup: +or ```python -os.environ["GRASS_REGION"] = gs.region_env(raster=input_raster) +with gs.RegionManager(): + gs.run_command("g.region", n=226000, s=222000, w=634000, e=638000) + stats = gs.parse_command("r.univar", map="elevation", format="json") ``` If different subprocesses need different regions, use different environments: ```python -env = os.environ.copy() -env["GRASS_REGION"] = gs.region_env(raster=input_raster) -gs.run_command("r.slope.aspect", elevation=input_raster, slope=slope, env=env) +with gs.RegionManager(raster=input_raster, env=os.environ.copy()) as manager: + gs.run_command("r.slope.aspect", elevation=input_raster, slope=slope, env=manager.env) ``` This approach makes the computational region completely safe for parallel -processes as no region-related files are modified. +processes as each subprocess has its own environment. + +If you can't use a context manager, you can use `gs.use_temp_region()` and +`gs.del_temp_region()`: + +```python +gs.use_temp_region() # From now on, use a separate region in the script. +# Set the computational region with g.region as needed. +grass.run_command('g.region', raster='input') +gs.del_temp_region() +# Original region applies now. +``` #### Changing raster mask diff --git a/doc/examples/notebooks/parallelization_tutorial.ipynb b/doc/examples/notebooks/parallelization_tutorial.ipynb index de069ce50eb..26c0788dd36 100644 --- a/doc/examples/notebooks/parallelization_tutorial.ipynb +++ b/doc/examples/notebooks/parallelization_tutorial.ipynb @@ -397,8 +397,9 @@ "source": [ "#### Safely modifying computational region in a single mapset\n", "\n", - "Sometimes modifying computational region in a script is needed. It is a good practice to not change the global computational region, which effectively modifies a file in a mapset,\n", - "but only change the environment variable `GRASS_REGION`.\n", + "Sometimes modifying computational region in a script is needed. It is a good practice to not change the global computational region, which effectively modifies a file in a mapset.\n", + "We will use `RegionManager` to modify the computational region in a safe way by passing a copy of the current environment to the context manager and passing that environment to the tools that are executed in parallel within the context manager.\n", + "\n", "Here, we modified the previous viewshed example to compute in parallel viewsheds with different extents:" ] }, @@ -415,10 +416,9 @@ "\n", "def viewshed(point):\n", " x, y, cat = point\n", - " # copy current environment, modify and pass it to r.viewshed\n", - " env = os.environ.copy()\n", - " env[\"GRASS_REGION\"] = gs.region_env(e=x + 300, w=x - 300, n=y + 300, s=y - 300, align=\"elevation\")\n", - " gs.run_command(\"r.viewshed\", input=\"elevation\", output=f\"viewshed_{cat}\", coordinates=(x, y), max_distance=300, env=env)\n", + " with gs.RegionManager(e=x + 300, w=x - 300, n=y + 300, s=y - 300, align=\"elevation\", env=os.environ.copy()):\n", + " gs.run_command(\"r.viewshed\", input=\"elevation\", output=f\"viewshed_{cat}\",\n", + " coordinates=(x, y), max_distance=300, env=env)\n", " return f\"viewshed_{cat}\"\n", "\n", "if __name__ == \"__main__\":\n", diff --git a/python/grass/script/__init__.py b/python/grass/script/__init__.py index 32eca18a5a7..6e9d71d3da9 100644 --- a/python/grass/script/__init__.py +++ b/python/grass/script/__init__.py @@ -76,6 +76,8 @@ raster_info, raster_what, MaskManager, + RegionManager, + RegionManagerEnv, ) from .raster3d import mapcalc3d, raster3d_info from .utils import ( @@ -118,6 +120,8 @@ "KeyValue", "MaskManager", "Popen", + "RegionManager", + "RegionManagerEnv", "append_node_pid", "append_random", "append_uuid", diff --git a/python/grass/script/raster.py b/python/grass/script/raster.py index b74362d5f6a..5534374293e 100644 --- a/python/grass/script/raster.py +++ b/python/grass/script/raster.py @@ -31,6 +31,7 @@ tempfile, run_command, read_command, + region_env, write_command, feed_command, warning, @@ -405,3 +406,179 @@ def __exit__(self, exc_type, exc_val, exc_tb): env=self.env, quiet=True, ) + + +class RegionManager: + """Context manager for temporarily setting the computational region. + + This context manager makes it possible to safely modify the computational region + (for example via `g.region`) within a `with` block. When the context exits, the original region + settings are automatically restored. This is useful in scripts or functions that need to + work with a specific region without permanently altering the user's working environment. + + The new region can be defined by passing `g.region` parameters when initializing the context, + or by calling `g.region` directly within the context. + + The original region is saved at the beginning of the context and restored at the end. + + Example with explicit region parameters: + + >>> with gs.RegionManager(n=226000, s=222000, w=634000, e=638000): + ... gs.parse_command("r.univar", map="elevation", format="json") + + Example matching a raster map's region: + + >>> with gs.RegionManager(raster="elevation"): + ... gs.run_command("r.slope.aspect", elevation="elevation", slope="slope") + + Example using g.region: + + >>> with gs.RegionManager(): + ... gs.run_command("g.region", n=226000, s=222000, w=634000, e=638000) + ... gs.parse_command("r.univar", map="elevation", format="json") + + Example using set_region(): + + >>> with gs.RegionManager() as manager: + ... manager.set_region(n=226000, s=222000, w=634000, e=638000) + ... gs.parse_command("r.univar", map="elevation", format="json") + + If no environment is provided, the global environment is used. When running parallel + processes in the same mapset that modify region settings, it is useful to use a copy + of the global environment. The following code creates the copy of the global environment + and lets the manager modify it. The copy is then available as the _env_ attribute. + + >>> with gs.RegionManager(raster="elevation", env=os.environ.copy()) as manager: + ... gs.run_command("r.univar", map="elevation", env=manager.env) + + In the background, this class manages the `WIND_OVERRIDE` environment variable + that holds the unique name of the saved region to use. + """ + + def __init__(self, env: dict[str, str] | None = None, **kwargs): + """ + Initializes the MaskManager. + + :param env: Environment to use. Defaults to modifying os.environ. + :param kwargs: Keyword arguments passed to `g.region` + """ + self.env = env if env is not None else os.environ + self._original_value = None + self.region_name = append_uuid(append_node_pid("region")) + self._region_inputs = kwargs or {} + + def set_region(self, **kwargs): + """Sets region. + + :param kwargs: Keyword arguments with g.region parameters + """ + run_command("g.region", **kwargs, env=self.env) + + def __enter__(self): + """Sets the `WIND_OVERRIDE` environment variable to the generated region name. + + :return: Returns the RegionManager instance. + """ + self._original_value = self.env.get("WIND_OVERRIDE") + run_command( + "g.region", save=self.region_name, env=self.env, **self._region_inputs + ) + self.env["WIND_OVERRIDE"] = self.region_name + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Restore the previous region state. + + Restores the original value of `WIND_OVERRIDE`. + + :param exc_type: Exception type, if any. + :param exc_val: Exception value, if any. + :param exc_tb: Traceback, if any. + """ + if self._original_value is not None: + self.env["WIND_OVERRIDE"] = self._original_value + else: + self.env.pop("WIND_OVERRIDE", None) + run_command( + "g.remove", + flags="f", + quiet=True, + type="region", + name=self.region_name, + env=self.env, + ) + + +class RegionManagerEnv: + """Context manager for temporarily setting the computational region. + + See :class:`RegionManager`. Unlike :class:`RegionManager`, this class uses + `GRASS_REGION` instead of `WIND_OVERRIDE`. The advantage is no files are written to disk. + The disadvantage is that simply calling `g.region` within the context will not affect + the temporary region, but the global one, which can be confusing. + + Example with explicit region parameters: + + >>> with gs.RegionManagerEnv(n=226000, s=222000, w=634000, e=638000): + ... gs.parse_command("r.univar", map="elevation", format="json") + + Example with set_region(): + + >>> with gs.RegionManagerEnv() as manager: + ... manager.set_region(n=226000, s=222000, w=634000, e=638000) + ... gs.parse_command("r.univar", map="elevation", format="json") + + This is identical to: + + >>> with gs.RegionManagerEnv() as manager: + manager.env["GRASS_REGION"] = gs.region_env() + ... gs.parse_command("r.univar", map="elevation", format="json") + + + This is *incorrect* usage (calling `g.region`): + >>> with gs.RegionManagerEnv(): + gs.run_command("g.region", raster="elevation") + ... gs.run_command("r.univar", map="elevation", env=manager.env) + + """ + + def __init__(self, env: dict[str, str] | None = None, **kwargs): + """ + Initializes the MaskManager. + + :param env: Environment to use. Defaults to modifying os.environ. + :param kwargs: Keyword arguments passed to `g.region` + """ + self.env = env if env is not None else os.environ + self._original_value = None + self._region_inputs = kwargs or {} + + def set_region(self, **kwargs): + """Sets region. + + :param kwargs: Keyword arguments with g.region parameters + """ + self.env["GRASS_REGION"] = region_env(**kwargs, env=self.env) + + def __enter__(self): + """Sets the `GRASS_REGION` environment variable to the generated region name. + + :return: Returns the RegionManagerEnv instance. + """ + self._original_value = self.env.get("GRASS_REGION") + self.env["GRASS_REGION"] = region_env(**self._region_inputs, env=self.env) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Restore the previous region state. + + Restores the original value of `WIND_OVERRIDE`. + + :param exc_type: Exception type, if any. + :param exc_val: Exception value, if any. + :param exc_tb: Traceback, if any. + """ + if self._original_value is not None: + self.env["GRASS_REGION"] = self._original_value + else: + self.env.pop("GRASS_REGION", None) From ff6719849cb754ba47f1e41114660c624d93f046 Mon Sep 17 00:00:00 2001 From: Anna Petrasova Date: Fri, 9 May 2025 13:47:03 -0400 Subject: [PATCH 2/4] forgot to add test file --- .../tests/grass_script_raster_region_test.py | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 python/grass/script/tests/grass_script_raster_region_test.py diff --git a/python/grass/script/tests/grass_script_raster_region_test.py b/python/grass/script/tests/grass_script_raster_region_test.py new file mode 100644 index 00000000000..f5ae8737d72 --- /dev/null +++ b/python/grass/script/tests/grass_script_raster_region_test.py @@ -0,0 +1,106 @@ +import grass.script as gs + + +def test_region_manager_with_g_region(session_2x2): + """Test RegionManager with g.region call.""" + assert "WIND_OVERRIDE" not in session_2x2.env + + with gs.RegionManager(env=session_2x2.env) as manager: + assert "WIND_OVERRIDE" in session_2x2.env + assert session_2x2.env["WIND_OVERRIDE"] == manager.region_name + region = gs.region(env=session_2x2.env) + assert region["rows"] == 2 + assert region["cols"] == 2 + + gs.run_command("g.region", n=10, s=0, e=10, w=0, res=1, env=session_2x2.env) + region = gs.region(env=session_2x2.env) + assert region["rows"] == 10 + assert region["cols"] == 10 + + manager.set_region(n=6, s=0, e=6, w=0, res=1) + region = gs.region(env=session_2x2.env) + assert region["rows"] == 6 + assert region["cols"] == 6 + + assert "WIND_OVERRIDE" not in session_2x2.env + region = gs.region(env=session_2x2.env) + assert region["rows"] == 2 + assert region["cols"] == 2 + + +def test_region_manager_with_region_parameters(session_2x2): + """Test RegionManager with input region parameters.""" + assert "WIND_OVERRIDE" not in session_2x2.env + + with gs.RegionManager(n=10, s=0, e=10, w=0, res=1, env=session_2x2.env) as manager: + assert "WIND_OVERRIDE" in session_2x2.env + assert session_2x2.env["WIND_OVERRIDE"] == manager.region_name + region = gs.region(env=session_2x2.env) + assert region["rows"] == 10 + assert region["cols"] == 10 + + gs.run_command("g.region", n=5, s=0, e=5, w=0, res=1, env=session_2x2.env) + region = gs.region(env=session_2x2.env) + assert region["rows"] == 5 + assert region["cols"] == 5 + + manager.set_region(n=6, s=0, e=6, w=0, res=1) + region = gs.region(env=session_2x2.env) + assert region["rows"] == 6 + assert region["cols"] == 6 + + assert "WIND_OVERRIDE" not in session_2x2.env + region = gs.region(env=session_2x2.env) + assert region["rows"] == 2 + assert region["cols"] == 2 + + +def test_region_manager_env(session_2x2): + """Test RegionManagerEnv simple use case.""" + assert "GRASS_REGION" not in session_2x2.env + + with gs.RegionManagerEnv(env=session_2x2.env) as manager: + assert "GRASS_REGION" in session_2x2.env + region = gs.region(env=session_2x2.env) + assert region["rows"] == 2 + assert region["cols"] == 2 + + manager.set_region(n=5, s=0, e=5, w=0, res=1) + region = gs.region(env=session_2x2.env) + assert region["rows"] == 5 + assert region["cols"] == 5 + + assert "GRASS_REGION" not in session_2x2.env + region = gs.region(env=session_2x2.env) + assert region["rows"] == 2 + assert region["cols"] == 2 + + +def test_region_manager_env_problem_with_g_region(session_2x2): + """Test RegionManagerEnv with region parameters and g.region call.""" + assert "GRASS_REGION" not in session_2x2.env + + with gs.RegionManagerEnv( + n=10, s=0, e=10, w=0, res=1, env=session_2x2.env + ) as manager: + assert "GRASS_REGION" in session_2x2.env + region = gs.region(env=session_2x2.env) + assert region["rows"] == 10 + assert region["cols"] == 10 + + # g.region will not change GRASS_REGION (but reads from it) + gs.run_command("g.region", res=2, env=session_2x2.env) + region = gs.region(env=session_2x2.env) + assert region["rows"] == 10 + assert region["cols"] == 10 + + manager.set_region(n=2, s=0, e=2, w=0, res=1) + region = gs.region(env=session_2x2.env) + assert region["rows"] == 2 + assert region["cols"] == 2 + + assert "GRASS_REGION" not in session_2x2.env + # region changed by g.region + region = gs.region(env=session_2x2.env) + assert region["rows"] == 5 + assert region["cols"] == 5 From b7528d5a5398d64f7bbbc4d66deb6ff8f737df75 Mon Sep 17 00:00:00 2001 From: Anna Petrasova Date: Mon, 2 Jun 2025 12:30:09 -0400 Subject: [PATCH 3/4] address review --- python/grass/script/raster.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/python/grass/script/raster.py b/python/grass/script/raster.py index 5534374293e..b8f46661071 100644 --- a/python/grass/script/raster.py +++ b/python/grass/script/raster.py @@ -535,11 +535,7 @@ class RegionManagerEnv: ... gs.parse_command("r.univar", map="elevation", format="json") - This is *incorrect* usage (calling `g.region`): - >>> with gs.RegionManagerEnv(): - gs.run_command("g.region", raster="elevation") - ... gs.run_command("r.univar", map="elevation", env=manager.env) - + Do not call `g.region` within the context. Instead, use the `set_region` method. """ def __init__(self, env: dict[str, str] | None = None, **kwargs): From e69677063e958c2dbe79c2eeb4494851baa1b4c5 Mon Sep 17 00:00:00 2001 From: Anna Petrasova Date: Mon, 2 Jun 2025 15:20:40 -0400 Subject: [PATCH 4/4] better formatting --- python/grass/script/raster.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python/grass/script/raster.py b/python/grass/script/raster.py index b8f46661071..a3f35161fd7 100644 --- a/python/grass/script/raster.py +++ b/python/grass/script/raster.py @@ -534,8 +534,9 @@ class RegionManagerEnv: manager.env["GRASS_REGION"] = gs.region_env() ... gs.parse_command("r.univar", map="elevation", format="json") + .. caution:: - Do not call `g.region` within the context. Instead, use the `set_region` method. + To set region within the context, do not call `g.region`, use `set_region` instead. """ def __init__(self, env: dict[str, str] | None = None, **kwargs):