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..a3f35161fd7 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,176 @@ 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")
+
+ .. caution::
+
+ 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):
+ """
+ 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)
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