Skip to content

grass.script: new RegionManager context manager #5628

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 24 additions & 16 deletions doc/development/style_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
12 changes: 6 additions & 6 deletions doc/examples/notebooks/parallelization_tutorial.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
]
},
Expand All @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions python/grass/script/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@
raster_info,
raster_what,
MaskManager,
RegionManager,
RegionManagerEnv,
)
from .raster3d import mapcalc3d, raster3d_info
from .utils import (
Expand Down Expand Up @@ -118,6 +120,8 @@
"KeyValue",
"MaskManager",
"Popen",
"RegionManager",
"RegionManagerEnv",
"append_node_pid",
"append_random",
"append_uuid",
Expand Down
174 changes: 174 additions & 0 deletions python/grass/script/raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
tempfile,
run_command,
read_command,
region_env,
write_command,
feed_command,
warning,
Expand Down Expand Up @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
To set region within the context, do not call `g.region`, use `set_region` instead.
To set region within the context, do not call ``g.region``, use ``set_region`` instead.

I also forgot, reST single back quote is italics (emphasis), double them for code, or use the other syntax for with a column, and all

"""

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)
Loading
Loading