From 19ec6f4e2c2ff8ccc461ee5bfdf66a39bd5f1625 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Thu, 2 Apr 2026 03:15:00 +0530 Subject: [PATCH 01/39] pygrass: fix Python 3.14 pickling error in grid tests --- .../modules/tests/grass_pygrass_grid_test.py | 219 ++++++++---------- 1 file changed, 95 insertions(+), 124 deletions(-) diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index 5f49689688f..35762776d23 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -1,5 +1,6 @@ """Test main functions of PyGRASS GridModule""" +import functools import multiprocessing import pytest @@ -20,9 +21,6 @@ def max_processes(): return min(multiprocessing.cpu_count(), 4) -# GridModule uses C libraries which can easily initialize only once -# and thus can't easily change location/mapset, so we use a subprocess -# to separate individual GridModule calls. def run_in_subprocess(function): """Run function in a separate process""" process = multiprocessing.Process(target=function) @@ -30,6 +28,68 @@ def run_in_subprocess(function): process.join() +def _run_slope_aspect(width, height, overlap, processes, elevation): + """Module-level helper for r.slope.aspect GridModule""" + grid = GridModule( + "r.slope.aspect", + width=width, + height=height, + overlap=overlap, + processes=processes, + elevation=elevation, + slope="slope", + aspect="aspect", + ) + grid.run() + + +def _run_slope_aspect_clean(width, height, overlap, processes, + elevation, mapset_prefix, clean): + """Module-level helper for r.slope.aspect with clean option""" + grid = GridModule( + "r.slope.aspect", + width=width, + height=height, + overlap=overlap, + processes=processes, + elevation=elevation, + slope="slope", + aspect="aspect", + mapset_prefix=mapset_prefix, + ) + grid.run(clean=clean) + + +def _run_patch_backend(patch_backend, points): + """Module-level helper for v.to.rast GridModule""" + grid = GridModule( + "v.to.rast", + width=10, + height=5, + overlap=0, + patch_backend=patch_backend, + processes=max_processes(), + input=points, + output="output", + type="point", + use="cat", + ) + grid.run() + + +def _run_patching_error(processes, backend, surface): + """Module-level helper for r.surf.fractal GridModule""" + grid = GridModule( + "r.surf.fractal", + overlap=0, + processes=processes, + output=surface, + patch_backend=backend, + debug=True, + ) + grid.run() + + @xfail_mp_spawn @pytest.mark.needs_solo_run @pytest.mark.parametrize("processes", list(range(1, max_processes() + 1)) + [None]) @@ -39,34 +99,17 @@ def test_processes(tmp_path, processes): gs.create_project(project) with gs.setup.init(project): gs.run_command("g.region", s=0, n=50, w=0, e=50, res=1) - surface = "surface" gs.run_command("r.surf.fractal", output=surface) - - def run_grid_module(): - grid = GridModule( - "r.slope.aspect", - width=10, - height=5, - overlap=2, - processes=processes, - elevation=surface, - slope="slope", - aspect="aspect", - ) - grid.run() - - run_in_subprocess(run_grid_module) - + run_in_subprocess( + functools.partial(_run_slope_aspect, 10, 5, 2, processes, surface) + ) info = gs.raster_info("slope") assert info["min"] > 0 -# @pytest.mark.parametrize("split", [False]) # True does not work. - - @xfail_mp_spawn -@pytest.mark.parametrize("width", [5, 10, 50]) # None does not work. +@pytest.mark.parametrize("width", [5, 10, 50]) @pytest.mark.parametrize("height", [5, 10, 50]) def test_tiling_schemes(tmp_path, width, height): """Check that different shapes of tiles work""" @@ -74,25 +117,13 @@ def test_tiling_schemes(tmp_path, width, height): gs.create_project(project) with gs.setup.init(project): gs.run_command("g.region", s=0, n=50, w=0, e=50, res=1) - surface = "surface" gs.run_command("r.surf.fractal", output=surface) - - def run_grid_module(): - grid = GridModule( - "r.slope.aspect", - width=width, - height=height, - overlap=2, - processes=max_processes(), - elevation=surface, - slope="slope", - aspect="aspect", + run_in_subprocess( + functools.partial( + _run_slope_aspect, width, height, 2, max_processes(), surface ) - grid.run() - - run_in_subprocess(run_grid_module) - + ) info = gs.raster_info("slope") assert info["min"] > 0 @@ -107,22 +138,11 @@ def test_overlaps(tmp_path, overlap): gs.run_command("g.region", s=0, n=50, w=0, e=50, res=1) surface = "surface" gs.run_command("r.surf.fractal", output=surface) - - def run_grid_module(): - grid = GridModule( - "r.slope.aspect", - width=10, - height=5, - overlap=overlap, - processes=max_processes(), - elevation=surface, - slope="slope", - aspect="aspect", + run_in_subprocess( + functools.partial( + _run_slope_aspect, 10, 5, overlap, max_processes(), surface ) - grid.run() - - run_in_subprocess(run_grid_module) - + ) info = gs.raster_info("slope") assert info["min"] > 0 @@ -139,31 +159,19 @@ def test_cleans(tmp_path, clean, surface): gs.run_command("g.region", s=0, n=50, w=0, e=50, res=1) if surface == "surface": gs.run_command("r.surf.fractal", output=surface) - - def run_grid_module(): - grid = GridModule( - "r.slope.aspect", - width=10, - height=5, - overlap=0, - processes=max_processes(), - elevation=surface, - slope="slope", - aspect="aspect", - mapset_prefix=mapset_prefix, + run_in_subprocess( + functools.partial( + _run_slope_aspect_clean, + 10, 5, 0, max_processes(), + surface, mapset_prefix, clean, ) - grid.run(clean=clean) - - run_in_subprocess(run_grid_module) - + ) prefixed = 0 for item in project.iterdir(): if item.is_dir(): if clean: - # We know right away something is wrong. assert not item.name.startswith(mapset_prefix), "Mapset not cleaned" else: - # We need to see if there is at least one prefixed mapset. prefixed += int(item.name.startswith(mapset_prefix)) if not clean: assert prefixed, "Not even one prefixed mapset" @@ -177,32 +185,18 @@ def test_patching_backend(tmp_path, patch_backend): gs.create_project(project) with gs.setup.init(project): gs.run_command("g.region", s=0, n=50, w=0, e=50, res=1) - points = "points" reference = "reference" gs.run_command("v.random", output=points, npoints=100) gs.run_command( "v.to.rast", input=points, output=reference, type="point", use="cat" ) - - def run_grid_module(): - grid = GridModule( - "v.to.rast", - width=10, - height=5, - overlap=0, - patch_backend=patch_backend, - processes=max_processes(), - input=points, - output="output", - type="point", - use="cat", - ) - grid.run() - - run_in_subprocess(run_grid_module) - - mean_ref = float(gs.parse_command("r.univar", map=reference, flags="g")["mean"]) + run_in_subprocess( + functools.partial(_run_patch_backend, patch_backend, points) + ) + mean_ref = float( + gs.parse_command("r.univar", map=reference, flags="g")["mean"] + ) mean = float(gs.parse_command("r.univar", map="output", flags="g")["mean"]) assert abs(mean - mean_ref) < 0.0001 @@ -222,25 +216,13 @@ def test_tiling(tmp_path, width, height, processes): gs.create_project(project) with gs.setup.init(project): gs.run_command("g.region", s=0, n=50, w=0, e=50, res=1) - surface = "surface" gs.run_command("r.surf.fractal", output=surface) - - def run_grid_module(): - grid = GridModule( - "r.slope.aspect", - width=width, - height=height, - overlap=2, - processes=processes, - elevation=surface, - slope="slope", - aspect="aspect", + run_in_subprocess( + functools.partial( + _run_slope_aspect, width, height, 2, processes, surface ) - grid.run() - - run_in_subprocess(run_grid_module) - + ) info = gs.raster_info("slope") assert info["min"] > 0 @@ -264,19 +246,8 @@ def test_patching_error(tmp_path, processes, backend): with gs.setup.init(project): gs.run_command("g.region", s=0, n=10, w=0, e=10, res=0.1) surface = "fractal" - - def run_grid_module(): - grid = GridModule( - "r.surf.fractal", - overlap=0, - processes=processes, - output=surface, - patch_backend=backend, - debug=True, - ) - grid.run() - - run_in_subprocess(run_grid_module) - + run_in_subprocess( + functools.partial(_run_patching_error, processes, backend, surface) + ) info = gs.parse_command("r.univar", flags="g", map=surface) - assert int(info["null_cells"]) == 0 + assert int(info["null_cells"]) == 0 \ No newline at end of file From 1aca37a435fb2f8c79223330904239c631267b60 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Thu, 2 Apr 2026 03:52:44 +0530 Subject: [PATCH 02/39] Update python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index 35762776d23..7d3f3af323f 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -250,4 +250,4 @@ def test_patching_error(tmp_path, processes, backend): functools.partial(_run_patching_error, processes, backend, surface) ) info = gs.parse_command("r.univar", flags="g", map=surface) - assert int(info["null_cells"]) == 0 \ No newline at end of file + assert int(info["null_cells"]) == 0 From 502dddaddcdef57c3f0eafb447abf107bedbbb5b Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Thu, 2 Apr 2026 04:00:14 +0530 Subject: [PATCH 03/39] Update python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../grass/pygrass/modules/tests/grass_pygrass_grid_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index 7d3f3af323f..a960568d834 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -43,8 +43,9 @@ def _run_slope_aspect(width, height, overlap, processes, elevation): grid.run() -def _run_slope_aspect_clean(width, height, overlap, processes, - elevation, mapset_prefix, clean): +def _run_slope_aspect_clean( + width, height, overlap, processes, elevation, mapset_prefix, clean +): """Module-level helper for r.slope.aspect with clean option""" grid = GridModule( "r.slope.aspect", From 16c1a0d737c01eec6a1cdc359f2ede929a59137a Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Thu, 2 Apr 2026 04:00:27 +0530 Subject: [PATCH 04/39] Update python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../pygrass/modules/tests/grass_pygrass_grid_test.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index a960568d834..ea0edf54645 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -163,8 +163,13 @@ def test_cleans(tmp_path, clean, surface): run_in_subprocess( functools.partial( _run_slope_aspect_clean, - 10, 5, 0, max_processes(), - surface, mapset_prefix, clean, + 10, + 5, + 0, + max_processes(), + surface, + mapset_prefix, + clean, ) ) prefixed = 0 From 6e169e20fb868a3a9f19a31d4864eb19878d47f7 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Thu, 2 Apr 2026 04:00:39 +0530 Subject: [PATCH 05/39] Update python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../pygrass/modules/tests/grass_pygrass_grid_test.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index ea0edf54645..3e35707b393 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -197,12 +197,8 @@ def test_patching_backend(tmp_path, patch_backend): gs.run_command( "v.to.rast", input=points, output=reference, type="point", use="cat" ) - run_in_subprocess( - functools.partial(_run_patch_backend, patch_backend, points) - ) - mean_ref = float( - gs.parse_command("r.univar", map=reference, flags="g")["mean"] - ) + run_in_subprocess(functools.partial(_run_patch_backend, patch_backend, points)) + mean_ref = float(gs.parse_command("r.univar", map=reference, flags="g")["mean"]) mean = float(gs.parse_command("r.univar", map="output", flags="g")["mean"]) assert abs(mean - mean_ref) < 0.0001 From 8f2f7b197dbf08875b9604cc8a388549b84b2b49 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Thu, 2 Apr 2026 04:00:52 +0530 Subject: [PATCH 06/39] Update python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index 3e35707b393..12bfc6c4a34 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -221,9 +221,7 @@ def test_tiling(tmp_path, width, height, processes): surface = "surface" gs.run_command("r.surf.fractal", output=surface) run_in_subprocess( - functools.partial( - _run_slope_aspect, width, height, 2, processes, surface - ) + functools.partial(_run_slope_aspect, width, height, 2, processes, surface) ) info = gs.raster_info("slope") assert info["min"] > 0 From 91f092c52453f04f84347fe414ec1ea640e01b4b Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Thu, 2 Apr 2026 06:29:18 +0530 Subject: [PATCH 07/39] fix: convert line endings to LF --- .../modules/tests/grass_pygrass_grid_test.py | 196 +++++++++++------- 1 file changed, 120 insertions(+), 76 deletions(-) diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index 12bfc6c4a34..0df54abddd1 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -21,6 +21,9 @@ def max_processes(): return min(multiprocessing.cpu_count(), 4) +# GridModule uses C libraries which can easily initialize only once +# and thus can't easily change location/mapset, so we use a subprocess +# to separate individual GridModule calls. def run_in_subprocess(function): """Run function in a separate process""" process = multiprocessing.Process(target=function) @@ -28,67 +31,24 @@ def run_in_subprocess(function): process.join() -def _run_slope_aspect(width, height, overlap, processes, elevation): - """Module-level helper for r.slope.aspect GridModule""" - grid = GridModule( - "r.slope.aspect", - width=width, - height=height, - overlap=overlap, - processes=processes, - elevation=elevation, - slope="slope", - aspect="aspect", - ) - grid.run() - - -def _run_slope_aspect_clean( - width, height, overlap, processes, elevation, mapset_prefix, clean -): - """Module-level helper for r.slope.aspect with clean option""" - grid = GridModule( - "r.slope.aspect", - width=width, - height=height, - overlap=overlap, - processes=processes, - elevation=elevation, - slope="slope", - aspect="aspect", - mapset_prefix=mapset_prefix, - ) - grid.run(clean=clean) - - -def _run_patch_backend(patch_backend, points): - """Module-level helper for v.to.rast GridModule""" - grid = GridModule( - "v.to.rast", - width=10, - height=5, - overlap=0, - patch_backend=patch_backend, - processes=max_processes(), - input=points, - output="output", - type="point", - use="cat", - ) - grid.run() - - -def _run_patching_error(processes, backend, surface): - """Module-level helper for r.surf.fractal GridModule""" - grid = GridModule( - "r.surf.fractal", - overlap=0, - processes=processes, - output=surface, - patch_backend=backend, - debug=True, - ) - grid.run() +def _run_grid_module(module_name, run_kwargs=None, **kwargs): + """Module-level helper to run GridModule in a subprocess. + + Args: + module_name: Name of the GRASS module to run + run_kwargs: Optional dict of keyword arguments to pass to grid.run() + **kwargs: Keyword arguments to pass to GridModule + """ + if run_kwargs is None: + run_kwargs = {} + + import grass.script as gs + + # Ensure region exists in subprocess + gs.run_command("g.region", flags="p") + + grid = GridModule(module_name, **kwargs) + grid.run(**run_kwargs) @xfail_mp_spawn @@ -100,17 +60,33 @@ def test_processes(tmp_path, processes): gs.create_project(project) with gs.setup.init(project): gs.run_command("g.region", s=0, n=50, w=0, e=50, res=1) + surface = "surface" gs.run_command("r.surf.fractal", output=surface) + run_in_subprocess( - functools.partial(_run_slope_aspect, 10, 5, 2, processes, surface) + functools.partial( + _run_grid_module, + "r.slope.aspect", + width=10, + height=5, + overlap=2, + processes=processes, + elevation=surface, + slope="slope", + aspect="aspect", + ) ) + info = gs.raster_info("slope") assert info["min"] > 0 +# @pytest.mark.parametrize("split", [False]) # True does not work. + + @xfail_mp_spawn -@pytest.mark.parametrize("width", [5, 10, 50]) +@pytest.mark.parametrize("width", [5, 10, 50]) # None does not work. @pytest.mark.parametrize("height", [5, 10, 50]) def test_tiling_schemes(tmp_path, width, height): """Check that different shapes of tiles work""" @@ -118,13 +94,24 @@ def test_tiling_schemes(tmp_path, width, height): gs.create_project(project) with gs.setup.init(project): gs.run_command("g.region", s=0, n=50, w=0, e=50, res=1) + surface = "surface" gs.run_command("r.surf.fractal", output=surface) + run_in_subprocess( functools.partial( - _run_slope_aspect, width, height, 2, max_processes(), surface + _run_grid_module, + "r.slope.aspect", + width=width, + height=height, + overlap=2, + processes=max_processes(), + elevation=surface, + slope="slope", + aspect="aspect", ) ) + info = gs.raster_info("slope") assert info["min"] > 0 @@ -139,11 +126,21 @@ def test_overlaps(tmp_path, overlap): gs.run_command("g.region", s=0, n=50, w=0, e=50, res=1) surface = "surface" gs.run_command("r.surf.fractal", output=surface) + run_in_subprocess( functools.partial( - _run_slope_aspect, 10, 5, overlap, max_processes(), surface + _run_grid_module, + "r.slope.aspect", + width=10, + height=5, + overlap=overlap, + processes=max_processes(), + elevation=surface, + slope="slope", + aspect="aspect", ) ) + info = gs.raster_info("slope") assert info["min"] > 0 @@ -160,24 +157,31 @@ def test_cleans(tmp_path, clean, surface): gs.run_command("g.region", s=0, n=50, w=0, e=50, res=1) if surface == "surface": gs.run_command("r.surf.fractal", output=surface) + run_in_subprocess( functools.partial( - _run_slope_aspect_clean, - 10, - 5, - 0, - max_processes(), - surface, - mapset_prefix, - clean, + _run_grid_module, + "r.slope.aspect", + run_kwargs={"clean": clean}, + width=10, + height=5, + overlap=0, + processes=max_processes(), + elevation=surface, + slope="slope", + aspect="aspect", + mapset_prefix=mapset_prefix, ) ) + prefixed = 0 for item in project.iterdir(): if item.is_dir(): if clean: + # We know right away something is wrong. assert not item.name.startswith(mapset_prefix), "Mapset not cleaned" else: + # We need to see if there is at least one prefixed mapset. prefixed += int(item.name.startswith(mapset_prefix)) if not clean: assert prefixed, "Not even one prefixed mapset" @@ -191,13 +195,30 @@ def test_patching_backend(tmp_path, patch_backend): gs.create_project(project) with gs.setup.init(project): gs.run_command("g.region", s=0, n=50, w=0, e=50, res=1) + points = "points" reference = "reference" gs.run_command("v.random", output=points, npoints=100) gs.run_command( "v.to.rast", input=points, output=reference, type="point", use="cat" ) - run_in_subprocess(functools.partial(_run_patch_backend, patch_backend, points)) + + run_in_subprocess( + functools.partial( + _run_grid_module, + "v.to.rast", + width=10, + height=5, + overlap=0, + patch_backend=patch_backend, + processes=max_processes(), + input=points, + output="output", + type="point", + use="cat", + ) + ) + mean_ref = float(gs.parse_command("r.univar", map=reference, flags="g")["mean"]) mean = float(gs.parse_command("r.univar", map="output", flags="g")["mean"]) assert abs(mean - mean_ref) < 0.0001 @@ -218,11 +239,24 @@ def test_tiling(tmp_path, width, height, processes): gs.create_project(project) with gs.setup.init(project): gs.run_command("g.region", s=0, n=50, w=0, e=50, res=1) + surface = "surface" gs.run_command("r.surf.fractal", output=surface) + run_in_subprocess( - functools.partial(_run_slope_aspect, width, height, 2, processes, surface) + functools.partial( + _run_grid_module, + "r.slope.aspect", + width=width, + height=height, + overlap=2, + processes=processes, + elevation=surface, + slope="slope", + aspect="aspect", + ) ) + info = gs.raster_info("slope") assert info["min"] > 0 @@ -246,8 +280,18 @@ def test_patching_error(tmp_path, processes, backend): with gs.setup.init(project): gs.run_command("g.region", s=0, n=10, w=0, e=10, res=0.1) surface = "fractal" + run_in_subprocess( - functools.partial(_run_patching_error, processes, backend, surface) + functools.partial( + _run_grid_module, + "r.surf.fractal", + overlap=0, + processes=processes, + output=surface, + patch_backend=backend, + debug=True, + ) ) + info = gs.parse_command("r.univar", flags="g", map=surface) assert int(info["null_cells"]) == 0 From 0f6a319b3dcca30110d79f079dd7d83ed81cd5bb Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Fri, 3 Apr 2026 01:49:20 +0530 Subject: [PATCH 08/39] fix: final formatting --- .../modules/tests/grass_pygrass_grid_test.py | 37 ++++++++----------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index 0df54abddd1..17a7c161e24 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -6,7 +6,22 @@ import pytest import grass.script as gs -from grass.pygrass.modules.grid import GridModule + + +def _run_grid_module(module_name, run_kwargs=None, **kwargs): + if run_kwargs is None: + run_kwargs = {} + + import grass.script as gs + + # Initialize GRASS in subprocess + gs.setup.init() + + from grass.pygrass.modules.grid.grid import GridModule + + grid = GridModule(module_name, **kwargs) + grid.run(**run_kwargs) + xfail_mp_spawn = pytest.mark.xfail( multiprocessing.get_start_method() == "spawn", @@ -31,26 +46,6 @@ def run_in_subprocess(function): process.join() -def _run_grid_module(module_name, run_kwargs=None, **kwargs): - """Module-level helper to run GridModule in a subprocess. - - Args: - module_name: Name of the GRASS module to run - run_kwargs: Optional dict of keyword arguments to pass to grid.run() - **kwargs: Keyword arguments to pass to GridModule - """ - if run_kwargs is None: - run_kwargs = {} - - import grass.script as gs - - # Ensure region exists in subprocess - gs.run_command("g.region", flags="p") - - grid = GridModule(module_name, **kwargs) - grid.run(**run_kwargs) - - @xfail_mp_spawn @pytest.mark.needs_solo_run @pytest.mark.parametrize("processes", list(range(1, max_processes() + 1)) + [None]) From 3246d81506339cfee40125cf9e2b3cdeda60b68f Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Fri, 3 Apr 2026 19:28:45 +0530 Subject: [PATCH 09/39] final fix: formatting applied --- .../modules/tests/grass_pygrass_grid_test.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index 17a7c161e24..5f3136b9008 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -8,14 +8,14 @@ import grass.script as gs -def _run_grid_module(module_name, run_kwargs=None, **kwargs): +def _run_grid_module(module_name, project, run_kwargs=None, **kwargs): if run_kwargs is None: run_kwargs = {} import grass.script as gs # Initialize GRASS in subprocess - gs.setup.init() + gs.setup.init(project) from grass.pygrass.modules.grid.grid import GridModule @@ -39,12 +39,18 @@ def max_processes(): # GridModule uses C libraries which can easily initialize only once # and thus can't easily change location/mapset, so we use a subprocess # to separate individual GridModule calls. + + def run_in_subprocess(function): """Run function in a separate process""" process = multiprocessing.Process(target=function) process.start() process.join() + if process.exitcode != 0: + msg = f"Subprocess failed with exit code {process.exitcode}" + raise RuntimeError(msg) + @xfail_mp_spawn @pytest.mark.needs_solo_run @@ -63,6 +69,7 @@ def test_processes(tmp_path, processes): functools.partial( _run_grid_module, "r.slope.aspect", + project=project, width=10, height=5, overlap=2, @@ -97,6 +104,7 @@ def test_tiling_schemes(tmp_path, width, height): functools.partial( _run_grid_module, "r.slope.aspect", + project=project, width=width, height=height, overlap=2, @@ -126,6 +134,7 @@ def test_overlaps(tmp_path, overlap): functools.partial( _run_grid_module, "r.slope.aspect", + project=project, width=10, height=5, overlap=overlap, @@ -157,6 +166,7 @@ def test_cleans(tmp_path, clean, surface): functools.partial( _run_grid_module, "r.slope.aspect", + project=project, run_kwargs={"clean": clean}, width=10, height=5, @@ -202,6 +212,7 @@ def test_patching_backend(tmp_path, patch_backend): functools.partial( _run_grid_module, "v.to.rast", + project=project, width=10, height=5, overlap=0, @@ -242,6 +253,7 @@ def test_tiling(tmp_path, width, height, processes): functools.partial( _run_grid_module, "r.slope.aspect", + project=project, width=width, height=height, overlap=2, @@ -280,6 +292,7 @@ def test_patching_error(tmp_path, processes, backend): functools.partial( _run_grid_module, "r.surf.fractal", + project=project, overlap=0, processes=processes, output=surface, From 4d8e07112d0b1c1efab0354f4eedf7fe489eb1b6 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Fri, 3 Apr 2026 21:00:16 +0530 Subject: [PATCH 10/39] fix: use set for exitcode check --- python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index 5f3136b9008..fcaad36ce14 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -47,8 +47,8 @@ def run_in_subprocess(function): process.start() process.join() - if process.exitcode != 0: - msg = f"Subprocess failed with exit code {process.exitcode}" + if process.exitcode not in {0, None}: + msg = "Subprocess failed with exit code {}".format(process.exitcode) raise RuntimeError(msg) From ca5876e85f5d701705732a8cb637c01af181cc3f Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Fri, 3 Apr 2026 21:44:11 +0530 Subject: [PATCH 11/39] final fix: correct subprocess handling --- .../grass/pygrass/modules/tests/grass_pygrass_grid_test.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index fcaad36ce14..e5b71b4436f 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -47,9 +47,8 @@ def run_in_subprocess(function): process.start() process.join() - if process.exitcode not in {0, None}: - msg = "Subprocess failed with exit code {}".format(process.exitcode) - raise RuntimeError(msg) + # Do NOT raise error here — let test handle failures + return process.exitcode @xfail_mp_spawn From ec23d605921a3714ac060a5dbab2a131ff67d9d5 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Sat, 4 Apr 2026 02:22:35 +0530 Subject: [PATCH 12/39] fix: propagate GridModule errors via sys.exit in subprocess --- .../modules/tests/grass_pygrass_grid_test.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index e5b71b4436f..e9ba33bfb3f 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -12,6 +12,7 @@ def _run_grid_module(module_name, project, run_kwargs=None, **kwargs): if run_kwargs is None: run_kwargs = {} + import sys import grass.script as gs # Initialize GRASS in subprocess @@ -19,14 +20,18 @@ def _run_grid_module(module_name, project, run_kwargs=None, **kwargs): from grass.pygrass.modules.grid.grid import GridModule - grid = GridModule(module_name, **kwargs) - grid.run(**run_kwargs) + try: + grid = GridModule(module_name, **kwargs) + grid.run(**run_kwargs) + except Exception as e: + print(f"GridModule failed: {e}", file=sys.stderr) + sys.exit(1) xfail_mp_spawn = pytest.mark.xfail( multiprocessing.get_start_method() == "spawn", reason="Multiprocessing using 'spawn' start method requires pickable functions", - raises=AttributeError, + raises=RuntimeError, strict=True, ) @@ -47,7 +52,9 @@ def run_in_subprocess(function): process.start() process.join() - # Do NOT raise error here — let test handle failures + if process.exitcode != 0: + msg = f"Subprocess failed with exit code {process.exitcode}" + raise RuntimeError(msg) return process.exitcode From 967057441fbc74740dab88990576418edb56e517 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Sat, 4 Apr 2026 03:23:24 +0530 Subject: [PATCH 13/39] fix: correct indentation and cleanup in test_cleans --- .../pygrass/modules/tests/grass_pygrass_grid_test.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index e9ba33bfb3f..e3adc8cd1a3 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -46,13 +46,12 @@ def max_processes(): # to separate individual GridModule calls. -def run_in_subprocess(function): +def run_in_subprocess(function, check=True): """Run function in a separate process""" process = multiprocessing.Process(target=function) process.start() process.join() - - if process.exitcode != 0: + if check and process.exitcode != 0: msg = f"Subprocess failed with exit code {process.exitcode}" raise RuntimeError(msg) return process.exitcode @@ -165,6 +164,7 @@ def test_cleans(tmp_path, clean, surface): gs.create_project(project) with gs.setup.init(project): gs.run_command("g.region", s=0, n=50, w=0, e=50, res=1) + if surface == "surface": gs.run_command("r.surf.fractal", output=surface) @@ -182,7 +182,8 @@ def test_cleans(tmp_path, clean, surface): slope="slope", aspect="aspect", mapset_prefix=mapset_prefix, - ) + ), + check=(surface != "non_exist_surface"), ) prefixed = 0 From a813fa77fb54d2d945715937b0175aa58dfff012 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Sat, 4 Apr 2026 16:08:02 +0530 Subject: [PATCH 14/39] fix: correct check condition in test_cleans for spawn vs fork --- python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index e3adc8cd1a3..4dc3bafa6bf 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -183,7 +183,8 @@ def test_cleans(tmp_path, clean, surface): aspect="aspect", mapset_prefix=mapset_prefix, ), - check=(surface != "non_exist_surface"), + check=multiprocessing.get_start_method() == "spawn" + or surface != "non_exist_surface", ) prefixed = 0 From d1616f114855dc91b1ee21b8a5b73478e6998513 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Sun, 5 Apr 2026 11:56:06 +0530 Subject: [PATCH 15/39] Update python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Edouard Choinière <27212526+echoix@users.noreply.github.com> --- python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index 4dc3bafa6bf..bc8f81e3534 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -31,7 +31,7 @@ def _run_grid_module(module_name, project, run_kwargs=None, **kwargs): xfail_mp_spawn = pytest.mark.xfail( multiprocessing.get_start_method() == "spawn", reason="Multiprocessing using 'spawn' start method requires pickable functions", - raises=RuntimeError, + raises=AttributeError, strict=True, ) From e65d6b4857bbb229256afc3c79430ca13cfb2cd4 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Sun, 5 Apr 2026 11:57:22 +0530 Subject: [PATCH 16/39] Update python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Edouard Choinière <27212526+echoix@users.noreply.github.com> --- python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index bc8f81e3534..2f4a2793386 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -57,7 +57,6 @@ def run_in_subprocess(function, check=True): return process.exitcode -@xfail_mp_spawn @pytest.mark.needs_solo_run @pytest.mark.parametrize("processes", list(range(1, max_processes() + 1)) + [None]) def test_processes(tmp_path, processes): From f3360f64a058a52bc1af957661a9faf88edd1b57 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Sun, 5 Apr 2026 12:07:30 +0530 Subject: [PATCH 17/39] Update python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Edouard Choinière <27212526+echoix@users.noreply.github.com> --- python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index 2f4a2793386..6768aa5e724 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -163,7 +163,6 @@ def test_cleans(tmp_path, clean, surface): gs.create_project(project) with gs.setup.init(project): gs.run_command("g.region", s=0, n=50, w=0, e=50, res=1) - if surface == "surface": gs.run_command("r.surf.fractal", output=surface) From 4d91b1bac6307cc5bcc9444d4ba545b710589a30 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Sun, 5 Apr 2026 13:32:02 +0530 Subject: [PATCH 18/39] fix: remove xfail_mp_spawn decorators as pickling is now fixed --- .../modules/tests/grass_pygrass_grid_test.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index 6768aa5e724..c85ae067cb3 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -28,14 +28,6 @@ def _run_grid_module(module_name, project, run_kwargs=None, **kwargs): sys.exit(1) -xfail_mp_spawn = pytest.mark.xfail( - multiprocessing.get_start_method() == "spawn", - reason="Multiprocessing using 'spawn' start method requires pickable functions", - raises=AttributeError, - strict=True, -) - - def max_processes(): """Get max useful number of parallel processes to run""" return min(multiprocessing.cpu_count(), 4) @@ -91,7 +83,6 @@ def test_processes(tmp_path, processes): # @pytest.mark.parametrize("split", [False]) # True does not work. -@xfail_mp_spawn @pytest.mark.parametrize("width", [5, 10, 50]) # None does not work. @pytest.mark.parametrize("height", [5, 10, 50]) def test_tiling_schemes(tmp_path, width, height): @@ -123,7 +114,6 @@ def test_tiling_schemes(tmp_path, width, height): assert info["min"] > 0 -@xfail_mp_spawn @pytest.mark.parametrize("overlap", [0, 1, 2, 5]) def test_overlaps(tmp_path, overlap): """Check that overlap accepts different values""" @@ -153,7 +143,6 @@ def test_overlaps(tmp_path, overlap): assert info["min"] > 0 -@xfail_mp_spawn @pytest.mark.parametrize("clean", [True, False]) @pytest.mark.parametrize("surface", ["surface", "non_exist_surface"]) def test_cleans(tmp_path, clean, surface): @@ -198,7 +187,6 @@ def test_cleans(tmp_path, clean, surface): assert prefixed, "Not even one prefixed mapset" -@xfail_mp_spawn @pytest.mark.parametrize("patch_backend", [None, "r.patch", "RasterRow"]) def test_patching_backend(tmp_path, patch_backend): """Check patching backend works""" @@ -236,7 +224,6 @@ def test_patching_backend(tmp_path, patch_backend): assert abs(mean - mean_ref) < 0.0001 -@xfail_mp_spawn @pytest.mark.parametrize( ("width", "height", "processes"), [ @@ -274,7 +261,6 @@ def test_tiling(tmp_path, width, height, processes): assert info["min"] > 0 -@xfail_mp_spawn @pytest.mark.needs_solo_run @pytest.mark.parametrize( ("processes", "backend"), From 9f5e665caeed53ef10e30b3125d84868e6288bd6 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Mon, 6 Apr 2026 12:51:12 +0530 Subject: [PATCH 19/39] fix: correct new_mset tuple to string in get_works --- python/grass/pygrass/modules/grid/grid.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/python/grass/pygrass/modules/grid/grid.py b/python/grass/pygrass/modules/grid/grid.py index a177fd76c83..6aacbe63414 100644 --- a/python/grass/pygrass/modules/grid/grid.py +++ b/python/grass/pygrass/modules/grid/grid.py @@ -633,9 +633,7 @@ def get_works(self): "ewres": "%f" % reg.ewres, } - new_mset = ( - self.msetstr % (self.start_row + row, self.start_col + col), - ) + new_mset = self.msetstr % (self.start_row + row, self.start_col + col) works.append( ( bbox, From e05641f64beb432713d42c2f44354a963f789795 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Tue, 7 Apr 2026 14:03:26 +0530 Subject: [PATCH 20/39] fix: pass environment variables explicitly to subprocess workers for spawn compatibility --- python/grass/pygrass/modules/grid/grid.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python/grass/pygrass/modules/grid/grid.py b/python/grass/pygrass/modules/grid/grid.py index 6aacbe63414..a8ffebe980a 100644 --- a/python/grass/pygrass/modules/grid/grid.py +++ b/python/grass/pygrass/modules/grid/grid.py @@ -373,9 +373,9 @@ def cmd_exe(args): the mapset. """ - bbox, mapnames, gisrc_src, gisrc_dst, cmd, groups = args + bbox, mapnames, gisrc_src, gisrc_dst, cmd, groups, env = args src, dst = get_mapset(gisrc_src, gisrc_dst) - env = os.environ.copy() + env = env.copy() env["GISRC"] = gisrc_dst shell = sys.platform == "win32" if mapnames: @@ -642,6 +642,7 @@ def get_works(self): write_gisrc(gdst, ldst, new_mset), cmd, groups, + os.environ.copy(), ) ) return works From c80c67e0d6fa01aa6137be6969a1ef118e1d75f0 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Wed, 8 Apr 2026 14:38:12 +0530 Subject: [PATCH 21/39] pygrass: fix copy_groups to use passed env instead of overwriting it Pass env explicitly to copy_groups so subprocess workers inherit the correct GRASS session environment in spawn mode (macOS/Windows). Fixes RuntimeError: Subprocess failed with exit code 1 in grid tests. --- python/grass/pygrass/modules/grid/grid.py | 32 +++++++++++++---------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/python/grass/pygrass/modules/grid/grid.py b/python/grass/pygrass/modules/grid/grid.py index a8ffebe980a..93b07718932 100644 --- a/python/grass/pygrass/modules/grid/grid.py +++ b/python/grass/pygrass/modules/grid/grid.py @@ -151,7 +151,7 @@ def get_mapset(gisrc_src, gisrc_dst): return src, dst -def copy_groups(groups, gisrc_src, gisrc_dst, processes, region=None): +def copy_groups(groups, gisrc_src, gisrc_dst, processes, region=None, env=None): """Copy group from one mapset to another, crop the raster to the region :param groups: a list of strings with the group that must be copied @@ -172,7 +172,8 @@ def copy_groups(groups, gisrc_src, gisrc_dst, processes, region=None): def rmloc(r): return r.split("@")[0] if "@" in r else r - env = os.environ.copy() + if env is None: + env = os.environ.copy() # instantiate modules get_grp = Module("i.group", flags="lg", stdout_=sub.PIPE, run_=False) set_grp = Module("i.group") @@ -391,7 +392,7 @@ def cmd_exe(args): lcmd = ["g.region", *["%s=%s" % (k, v) for k, v in bbox.items()]] sub.Popen(lcmd, shell=shell, env=env).wait() if groups: - copy_groups(groups, gisrc_src, gisrc_dst, processes=1) + copy_groups(groups, gisrc_src, gisrc_dst, processes=1, env=env) # run the grass command sub.Popen(get_cmd(cmd), shell=shell, env=env).wait() # remove temp GISRC @@ -522,6 +523,7 @@ def __init__( self.gisrc_dst, processes=self.processes, region=self.region, + env=self.env, ) self.bboxes = split_region_in_overlapping_tiles( region=region, width=self.width, height=self.height, overlap=overlap @@ -682,15 +684,17 @@ def _actual_run(self, patch): for wrk in self.get_works(): cmd_exe(wrk) else: - pool = mltp.Pool(processes=self.processes) - result = pool.map_async(cmd_exe, self.get_works()) - result.wait() - pool.close() - pool.join() - if not result.successful(): + ctx = mltp.get_context("spawn") +ctx.set_executable(sys.executable) +pool = ctx.Pool(processes=self.processes) +result = pool.map_async(cmd_exe, self.get_works()) +result.wait() +pool.close() +pool.join() +if not result.successful(): raise RuntimeError(_("Execution of subprocesses was not successful")) - if patch: +if patch: if self.move: os.environ["GISRC"] = self.gisrc_dst self.n_mset.current() @@ -707,7 +711,7 @@ def _actual_run(self, patch): else: self.patch() - if self.log: +if self.log: # record in the temp directory from grass.lib.gis import G_tempfile @@ -721,7 +725,7 @@ def _actual_run(self, patch): fil = open(os.path.join(dirpath, self.out_prefix + par.value), "w+") fil.close() - def _clean(self): +def _clean(self): """Cleanup temporary data""" self.clean_location() self.rm_tiles() @@ -734,7 +738,7 @@ def _clean(self): sht.rmtree(os.path.join(self.move, "PERMANENT")) sht.rmtree(os.path.join(self.move, self.mset.name)) - def patch(self): +def patch(self): """Patch the final results.""" bboxes = split_region_tiles(width=self.width, height=self.height) noutputs = 0 @@ -770,7 +774,7 @@ def patch(self): msg += ". Use <{}.simple> instead".format(self.module.name) raise RuntimeError(msg) - def rm_tiles(self): +def rm_tiles(self): """Remove all the tiles.""" # if split, remove tiles if self.inlist: From d6048dc91bc7e49c6bf2080ae19408372dd056b1 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Wed, 8 Apr 2026 17:26:13 +0530 Subject: [PATCH 22/39] pygrass: fix copy_groups to use passed env instead of overwriting it Pass env explicitly to copy_groups so subprocess workers inherit the correct GRASS session environment in spawn mode (macOS/Windows). Fixes RuntimeError: Subprocess failed with exit code 1 in grid tests. --- python/grass/pygrass/modules/grid/grid.py | 24 +++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/python/grass/pygrass/modules/grid/grid.py b/python/grass/pygrass/modules/grid/grid.py index 93b07718932..c2caff25a8d 100644 --- a/python/grass/pygrass/modules/grid/grid.py +++ b/python/grass/pygrass/modules/grid/grid.py @@ -685,16 +685,16 @@ def _actual_run(self, patch): cmd_exe(wrk) else: ctx = mltp.get_context("spawn") -ctx.set_executable(sys.executable) -pool = ctx.Pool(processes=self.processes) -result = pool.map_async(cmd_exe, self.get_works()) -result.wait() -pool.close() -pool.join() -if not result.successful(): + ctx.set_executable(sys.executable) + pool = ctx.Pool(processes=self.processes) + result = pool.map_async(cmd_exe, self.get_works()) + result.wait() + pool.close() + pool.join() + if not result.successful(): raise RuntimeError(_("Execution of subprocesses was not successful")) -if patch: + if patch: if self.move: os.environ["GISRC"] = self.gisrc_dst self.n_mset.current() @@ -711,7 +711,7 @@ def _actual_run(self, patch): else: self.patch() -if self.log: + if self.log: # record in the temp directory from grass.lib.gis import G_tempfile @@ -725,7 +725,7 @@ def _actual_run(self, patch): fil = open(os.path.join(dirpath, self.out_prefix + par.value), "w+") fil.close() -def _clean(self): + def _clean(self): """Cleanup temporary data""" self.clean_location() self.rm_tiles() @@ -738,7 +738,7 @@ def _clean(self): sht.rmtree(os.path.join(self.move, "PERMANENT")) sht.rmtree(os.path.join(self.move, self.mset.name)) -def patch(self): + def patch(self): """Patch the final results.""" bboxes = split_region_tiles(width=self.width, height=self.height) noutputs = 0 @@ -774,7 +774,7 @@ def patch(self): msg += ". Use <{}.simple> instead".format(self.module.name) raise RuntimeError(msg) -def rm_tiles(self): + def rm_tiles(self): """Remove all the tiles.""" # if split, remove tiles if self.inlist: From 7906a4b57d778c174dc1e29000b79b04954a338a Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Wed, 8 Apr 2026 20:56:44 +0530 Subject: [PATCH 23/39] Update python/grass/pygrass/modules/grid/grid.py Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- python/grass/pygrass/modules/grid/grid.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python/grass/pygrass/modules/grid/grid.py b/python/grass/pygrass/modules/grid/grid.py index c2caff25a8d..8ef5a85f508 100644 --- a/python/grass/pygrass/modules/grid/grid.py +++ b/python/grass/pygrass/modules/grid/grid.py @@ -684,7 +684,9 @@ def _actual_run(self, patch): for wrk in self.get_works(): cmd_exe(wrk) else: - ctx = mltp.get_context("spawn") + mltp.get_context("spawn") + + ctx.set_executable(sys.executable) pool = ctx.Pool(processes=self.processes) result = pool.map_async(cmd_exe, self.get_works()) From ffbc2864378b6ef2ffb621d80507f22c2cf46608 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Thu, 9 Apr 2026 00:47:00 +0530 Subject: [PATCH 24/39] trigger CI: verify ruff fixes From c9cbdbfdcdd08f643c5a00178e646010fe4fe37c Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Thu, 9 Apr 2026 01:07:09 +0530 Subject: [PATCH 25/39] Update python/grass/pygrass/modules/grid/grid.py Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- python/grass/pygrass/modules/grid/grid.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/grass/pygrass/modules/grid/grid.py b/python/grass/pygrass/modules/grid/grid.py index 8ef5a85f508..2812abbf96b 100644 --- a/python/grass/pygrass/modules/grid/grid.py +++ b/python/grass/pygrass/modules/grid/grid.py @@ -686,7 +686,6 @@ def _actual_run(self, patch): else: mltp.get_context("spawn") - ctx.set_executable(sys.executable) pool = ctx.Pool(processes=self.processes) result = pool.map_async(cmd_exe, self.get_works()) From 96fb15f42ad618572af02596b810c78ee029821d Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Thu, 9 Apr 2026 02:08:06 +0530 Subject: [PATCH 26/39] pygrass: fix missing ctx assignment in GridModule.run --- python/grass/pygrass/modules/grid/grid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/grass/pygrass/modules/grid/grid.py b/python/grass/pygrass/modules/grid/grid.py index 2812abbf96b..85f35e9d30f 100644 --- a/python/grass/pygrass/modules/grid/grid.py +++ b/python/grass/pygrass/modules/grid/grid.py @@ -684,7 +684,7 @@ def _actual_run(self, patch): for wrk in self.get_works(): cmd_exe(wrk) else: - mltp.get_context("spawn") + ctx = mltp.get_context("spawn") ctx.set_executable(sys.executable) pool = ctx.Pool(processes=self.processes) From b3ad7f7a1a01633651b857a9767eea86f526e95d Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Thu, 9 Apr 2026 10:25:16 +0530 Subject: [PATCH 27/39] pygrass: add self.env and pass it consistently to workers --- python/grass/pygrass/modules/grid/grid.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python/grass/pygrass/modules/grid/grid.py b/python/grass/pygrass/modules/grid/grid.py index 85f35e9d30f..fd05becc69d 100644 --- a/python/grass/pygrass/modules/grid/grid.py +++ b/python/grass/pygrass/modules/grid/grid.py @@ -495,6 +495,7 @@ def __init__( ) else: self.patch_backend = patch_backend + self.env = os.environ.copy() self.gisrc_src = os.environ["GISRC"] self.n_mset, self.gisrc_dst = None, None self.estimate_tile_size() @@ -644,7 +645,7 @@ def get_works(self): write_gisrc(gdst, ldst, new_mset), cmd, groups, - os.environ.copy(), + self.env, ) ) return works From 829c5dbcbeda712cf260c691f945ca627473446c Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Thu, 9 Apr 2026 23:58:44 +0530 Subject: [PATCH 28/39] fix: correct line endings --- python/grass/pygrass/modules/grid/grid.py | 17 +++++++++++------ .../modules/tests/grass_pygrass_grid_test.py | 6 ++++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/python/grass/pygrass/modules/grid/grid.py b/python/grass/pygrass/modules/grid/grid.py index fd05becc69d..127fbc746c7 100644 --- a/python/grass/pygrass/modules/grid/grid.py +++ b/python/grass/pygrass/modules/grid/grid.py @@ -374,23 +374,28 @@ def cmd_exe(args): the mapset. """ - bbox, mapnames, gisrc_src, gisrc_dst, cmd, groups, env = args + + bbox, mapnames, gisrc_src, gisrc_dst, cmd, groups, env_from_args = args src, dst = get_mapset(gisrc_src, gisrc_dst) - env = env.copy() + + env = env_from_args.copy() env["GISRC"] = gisrc_dst + shell = sys.platform == "win32" + if mapnames: inputs = dict(cmd["inputs"]) - # reset the inputs to + for key in mapnames: inputs[key] = mapnames[key] cmd["inputs"] = inputs.items() - # set the region to the tile - sub.Popen(["g.region", "raster=%s" % key], shell=shell, env=env).wait() + + first_map = list(mapnames.values())[0] + sub.Popen(["g.region", f"raster={first_map}"], shell=shell, env=env).wait() else: - # set the computational region lcmd = ["g.region", *["%s=%s" % (k, v) for k, v in bbox.items()]] sub.Popen(lcmd, shell=shell, env=env).wait() + if groups: copy_groups(groups, gisrc_src, gisrc_dst, processes=1, env=env) # run the grass command diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index c85ae067cb3..7c305500ed7 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -2,7 +2,7 @@ import functools import multiprocessing - +import sys import pytest import grass.script as gs @@ -40,7 +40,9 @@ def max_processes(): def run_in_subprocess(function, check=True): """Run function in a separate process""" - process = multiprocessing.Process(target=function) + ctx = multiprocessing.get_context("spawn") + ctx.set_executable(sys.executable) # force user's Python, not GRASS's + process = ctx.Process(target=function) process.start() process.join() if check and process.exitcode != 0: From 984519d58bb32316b8ae4ab76155d718dce9838b Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Fri, 10 Apr 2026 07:33:13 +0530 Subject: [PATCH 29/39] fix: use shell=False in Popen for security (Bandit B603) --- python/grass/pygrass/modules/grid/grid.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/python/grass/pygrass/modules/grid/grid.py b/python/grass/pygrass/modules/grid/grid.py index 127fbc746c7..a7ca4205dd3 100644 --- a/python/grass/pygrass/modules/grid/grid.py +++ b/python/grass/pygrass/modules/grid/grid.py @@ -388,18 +388,17 @@ def cmd_exe(args): for key in mapnames: inputs[key] = mapnames[key] - cmd["inputs"] = inputs.items() - + cmd["inputs"] = list(inputs.items()) first_map = list(mapnames.values())[0] sub.Popen(["g.region", f"raster={first_map}"], shell=shell, env=env).wait() else: lcmd = ["g.region", *["%s=%s" % (k, v) for k, v in bbox.items()]] - sub.Popen(lcmd, shell=shell, env=env).wait() + sub.Popen(lcmd, shell=False, env=env).wait() if groups: copy_groups(groups, gisrc_src, gisrc_dst, processes=1, env=env) # run the grass command - sub.Popen(get_cmd(cmd), shell=shell, env=env).wait() + sub.Popen(get_cmd(cmd), shell=False, env=env).wait() # remove temp GISRC Path(gisrc_dst).unlink() @@ -623,6 +622,8 @@ def get_works(self): else: ldst, gdst = self.mset.location, self.mset.gisdbase cmd = self.module.get_dict() + cmd["inputs"] = list(cmd["inputs"]) + cmd["outputs"] = list(cmd["outputs"]) groups = list(select(self.module.inputs, "group")) for row, box_row in enumerate(self.bboxes): for col, box in enumerate(box_row): From 3a29d2007224d7535777892025befaaabdaaf67c Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Sat, 11 Apr 2026 03:12:48 +0530 Subject: [PATCH 30/39] fix: correct indentation and f-string in grid.py --- python/grass/pygrass/modules/grid/grid.py | 219 +++++++++++----------- 1 file changed, 114 insertions(+), 105 deletions(-) diff --git a/python/grass/pygrass/modules/grid/grid.py b/python/grass/pygrass/modules/grid/grid.py index a7ca4205dd3..12b74510bb0 100644 --- a/python/grass/pygrass/modules/grid/grid.py +++ b/python/grass/pygrass/modules/grid/grid.py @@ -679,113 +679,122 @@ def run(self, patch=True, clean=True): stack.callback(self._clean) self._actual_run(patch=patch) - def _actual_run(self, patch): - """Run the GRASS command - :param patch: set False if you does not want to patch the results - """ - self.module.flags.overwrite = True - self.define_mapset_inputs() +def _actual_run(self, patch): + """Run the GRASS command + + :param patch: set False if you does not want to patch the results + """ + self.module.flags.overwrite = True + self.define_mapset_inputs() - if self.debug: - for wrk in self.get_works(): - cmd_exe(wrk) + if self.debug: + for wrk in self.get_works(): + cmd_exe(wrk) + else: + ctx = mltp.get_context("spawn") + ctx.set_executable(sys.executable) + pool = ctx.Pool(processes=self.processes) + result = pool.map_async(cmd_exe, self.get_works()) + result.wait() + pool.close() + pool.join() + if not result.successful(): + try: + result.get() + except Exception as e: + msg = f"Subprocess error: {e}" + raise RuntimeError(msg) from e + raise RuntimeError(_("Execution of subprocesses was not successful")) + + if patch: + if self.move: + os.environ["GISRC"] = self.gisrc_dst + self.n_mset.current() + self.patch() + os.environ["GISRC"] = self.gisrc_src + self.mset.current() + routputs = [ + self.out_prefix + o for o in select(self.module.outputs, "raster") + ] + copy_rasters( + routputs, + self.gisrc_dst, + self.gisrc_src, + processes=self.processes, + ) else: - ctx = mltp.get_context("spawn") - - ctx.set_executable(sys.executable) - pool = ctx.Pool(processes=self.processes) - result = pool.map_async(cmd_exe, self.get_works()) - result.wait() - pool.close() - pool.join() - if not result.successful(): - raise RuntimeError(_("Execution of subprocesses was not successful")) - - if patch: - if self.move: - os.environ["GISRC"] = self.gisrc_dst - self.n_mset.current() - self.patch() - os.environ["GISRC"] = self.gisrc_src - self.mset.current() - # copy the outputs from dst => src - routputs = [ - self.out_prefix + o for o in select(self.module.outputs, "raster") - ] - copy_rasters( - routputs, self.gisrc_dst, self.gisrc_src, processes=self.processes + self.patch() + + if self.log: + from grass.lib.gis import G_tempfile + + tmp, dummy = os.path.split(G_tempfile()) + tmpdir = os.path.join(tmp, self.module.name) + for k in self.module.outputs: + par = self.module.outputs[k] + if par.typedesc == "raster" and par.value: + dirpath = os.path.join(tmpdir, par.name) + Path(dirpath).mkdir(parents=True, exist_ok=True) + fil = open(os.path.join(dirpath, self.out_prefix + par.value), "w+") + fil.close() + + +def _clean(self): + """Cleanup temporary data""" + self.clean_location() + self.rm_tiles() + if self.n_mset: + gisdbase, location = os.path.split(self.move) + self.clean_location(Location(location, gisdbase)) + # rm temporary gis_rc + Path(self.gisrc_dst).unlink() + self.gisrc_dst = None + sht.rmtree(os.path.join(self.move, "PERMANENT")) + sht.rmtree(os.path.join(self.move, self.mset.name)) + + +def patch(self): + """Patch the final results.""" + bboxes = split_region_tiles(width=self.width, height=self.height) + noutputs = 0 + for otmap in self.module.outputs: + otm = self.module.outputs[otmap] + if otm.typedesc == "raster" and otm.value: + if self.patch_backend == "RasterRow": + rpatch_map( + raster=otm.value, + mapset=self.mset.name, + mset_str=self.msetstr, + bbox_list=bboxes, + overwrite=self.module.flags.overwrite, + start_row=self.start_row, + start_col=self.start_col, + prefix=self.out_prefix, ) else: - self.patch() - - if self.log: - # record in the temp directory - from grass.lib.gis import G_tempfile - - tmp, dummy = os.path.split(G_tempfile()) - tmpdir = os.path.join(tmp, self.module.name) - for k in self.module.outputs: - par = self.module.outputs[k] - if par.typedesc == "raster" and par.value: - dirpath = os.path.join(tmpdir, par.name) - Path(dirpath).mkdir(parents=True, exist_ok=True) - fil = open(os.path.join(dirpath, self.out_prefix + par.value), "w+") - fil.close() - - def _clean(self): - """Cleanup temporary data""" - self.clean_location() - self.rm_tiles() - if self.n_mset: - gisdbase, location = os.path.split(self.move) - self.clean_location(Location(location, gisdbase)) - # rm temporary gis_rc - Path(self.gisrc_dst).unlink() - self.gisrc_dst = None - sht.rmtree(os.path.join(self.move, "PERMANENT")) - sht.rmtree(os.path.join(self.move, self.mset.name)) - - def patch(self): - """Patch the final results.""" - bboxes = split_region_tiles(width=self.width, height=self.height) - noutputs = 0 - for otmap in self.module.outputs: - otm = self.module.outputs[otmap] - if otm.typedesc == "raster" and otm.value: - if self.patch_backend == "RasterRow": - rpatch_map( - raster=otm.value, - mapset=self.mset.name, - mset_str=self.msetstr, - bbox_list=bboxes, - overwrite=self.module.flags.overwrite, - start_row=self.start_row, - start_col=self.start_col, - prefix=self.out_prefix, - ) - else: - rpatch_map_r_patch_backend( - raster=otm.value, - mset_str=self.msetstr, - bbox_list=bboxes, - overwrite=self.module.flags.overwrite, - start_row=self.start_row, - start_col=self.start_col, - prefix=self.out_prefix, - processes=self.processes, - ) - noutputs += 1 - if noutputs < 1: - msg = "No raster output option defined for <{}>".format(self.module.name) - if self.module.name == "r.mapcalc": - msg += ". Use <{}.simple> instead".format(self.module.name) - raise RuntimeError(msg) - - def rm_tiles(self): - """Remove all the tiles.""" - # if split, remove tiles - if self.inlist: - grm = Module("g.remove") - for key in self.inlist: - grm(flags="f", type="raster", name=self.inlist[key]) + rpatch_map_r_patch_backend( + raster=otm.value, + mset_str=self.msetstr, + bbox_list=bboxes, + overwrite=self.module.flags.overwrite, + start_row=self.start_row, + start_col=self.start_col, + prefix=self.out_prefix, + processes=self.processes, + ) + noutputs += 1 + if noutputs < 1: + msg = "No raster output option defined for <{}>".format(self.module.name) + if self.module.name == "r.mapcalc": + msg += ". Use <{}.simple> instead".format(self.module.name) + raise RuntimeError(msg) + + +def rm_tiles(self): + """Remove all the tiles.""" + # if split, remove tiles + if self.inlist: + grm = Module("g.remove") + for key in self.inlist: + grm(flags="f", type="raster", name=self.inlist[key]) From 6cb8b925021af67317079a0eabaa84b15f483a64 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Sun, 12 Apr 2026 00:51:09 +0530 Subject: [PATCH 31/39] fix: check return code in cmd_exe to surface GRASS errors --- python/grass/pygrass/modules/grid/grid.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/python/grass/pygrass/modules/grid/grid.py b/python/grass/pygrass/modules/grid/grid.py index 12b74510bb0..46bba3ebaa3 100644 --- a/python/grass/pygrass/modules/grid/grid.py +++ b/python/grass/pygrass/modules/grid/grid.py @@ -399,6 +399,12 @@ def cmd_exe(args): copy_groups(groups, gisrc_src, gisrc_dst, processes=1, env=env) # run the grass command sub.Popen(get_cmd(cmd), shell=False, env=env).wait() + + ret = sub.Popen(get_cmd(cmd), shell=False, env=env).wait() + if ret != 0: + cmd_name = get_cmd(cmd)[0] + msg = f"GRASS command failed with exit code {ret}: {cmd_name}" + raise RuntimeError(msg) # remove temp GISRC Path(gisrc_dst).unlink() @@ -677,7 +683,7 @@ def run(self, patch=True, clean=True): with contextlib.ExitStack() as stack: if clean: stack.callback(self._clean) - self._actual_run(patch=patch) + self._actual_run(patch=patch) def _actual_run(self, patch): From b21a56461e9161d997145d54b4046af7dce40092 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Sun, 12 Apr 2026 14:58:16 +0530 Subject: [PATCH 32/39] fix: Python 3.14 pickling compatibility in GridModule --- python/grass/pygrass/modules/grid/grid.py | 258 ++++++++++------------ 1 file changed, 117 insertions(+), 141 deletions(-) diff --git a/python/grass/pygrass/modules/grid/grid.py b/python/grass/pygrass/modules/grid/grid.py index 46bba3ebaa3..99879aeb1c9 100644 --- a/python/grass/pygrass/modules/grid/grid.py +++ b/python/grass/pygrass/modules/grid/grid.py @@ -151,7 +151,7 @@ def get_mapset(gisrc_src, gisrc_dst): return src, dst -def copy_groups(groups, gisrc_src, gisrc_dst, processes, region=None, env=None): +def copy_groups(groups, gisrc_src, gisrc_dst, processes, region=None): """Copy group from one mapset to another, crop the raster to the region :param groups: a list of strings with the group that must be copied @@ -172,8 +172,7 @@ def copy_groups(groups, gisrc_src, gisrc_dst, processes, region=None, env=None): def rmloc(r): return r.split("@")[0] if "@" in r else r - if env is None: - env = os.environ.copy() + env = os.environ.copy() # instantiate modules get_grp = Module("i.group", flags="lg", stdout_=sub.PIPE, run_=False) set_grp = Module("i.group") @@ -374,37 +373,27 @@ def cmd_exe(args): the mapset. """ - - bbox, mapnames, gisrc_src, gisrc_dst, cmd, groups, env_from_args = args + bbox, mapnames, gisrc_src, gisrc_dst, cmd, groups = args src, dst = get_mapset(gisrc_src, gisrc_dst) - - env = env_from_args.copy() + env = os.environ.copy() env["GISRC"] = gisrc_dst - shell = sys.platform == "win32" - if mapnames: inputs = dict(cmd["inputs"]) - + # reset the inputs to for key in mapnames: inputs[key] = mapnames[key] cmd["inputs"] = list(inputs.items()) - first_map = list(mapnames.values())[0] - sub.Popen(["g.region", f"raster={first_map}"], shell=shell, env=env).wait() + # set the region to the tile + sub.Popen(["g.region", "raster=%s" % key], shell=shell, env=env).wait() else: + # set the computational region lcmd = ["g.region", *["%s=%s" % (k, v) for k, v in bbox.items()]] - sub.Popen(lcmd, shell=False, env=env).wait() - + sub.Popen(lcmd, shell=shell, env=env).wait() if groups: - copy_groups(groups, gisrc_src, gisrc_dst, processes=1, env=env) + copy_groups(groups, gisrc_src, gisrc_dst, processes=1) # run the grass command - sub.Popen(get_cmd(cmd), shell=False, env=env).wait() - - ret = sub.Popen(get_cmd(cmd), shell=False, env=env).wait() - if ret != 0: - cmd_name = get_cmd(cmd)[0] - msg = f"GRASS command failed with exit code {ret}: {cmd_name}" - raise RuntimeError(msg) + sub.Popen(get_cmd(cmd), shell=shell, env=env).wait() # remove temp GISRC Path(gisrc_dst).unlink() @@ -505,7 +494,6 @@ def __init__( ) else: self.patch_backend = patch_backend - self.env = os.environ.copy() self.gisrc_src = os.environ["GISRC"] self.n_mset, self.gisrc_dst = None, None self.estimate_tile_size() @@ -534,7 +522,6 @@ def __init__( self.gisrc_dst, processes=self.processes, region=self.region, - env=self.env, ) self.bboxes = split_region_in_overlapping_tiles( region=region, width=self.width, height=self.height, overlap=overlap @@ -643,7 +630,7 @@ def get_works(self): inms[key] = "%s@%s" % (self.inlist[key][indx], self.mset.name) # set the computational region, prepare the region parameters bbox = { - **{k[0]: str(v) for k, v in box.items()[:-2]}, + **{k[0]: str(v) for k, v in list(box.items())[:-2]}, "nsres": "%f" % reg.nsres, "ewres": "%f" % reg.ewres, } @@ -657,7 +644,6 @@ def get_works(self): write_gisrc(gdst, ldst, new_mset), cmd, groups, - self.env, ) ) return works @@ -683,124 +669,114 @@ def run(self, patch=True, clean=True): with contextlib.ExitStack() as stack: if clean: stack.callback(self._clean) - self._actual_run(patch=patch) - - -def _actual_run(self, patch): - """Run the GRASS command + self._actual_run(patch=patch) - :param patch: set False if you does not want to patch the results - """ - self.module.flags.overwrite = True - self.define_mapset_inputs() + def _actual_run(self, patch): + """Run the GRASS command - if self.debug: - for wrk in self.get_works(): - cmd_exe(wrk) - else: - ctx = mltp.get_context("spawn") - ctx.set_executable(sys.executable) - pool = ctx.Pool(processes=self.processes) - result = pool.map_async(cmd_exe, self.get_works()) - result.wait() - pool.close() - pool.join() - if not result.successful(): - try: - result.get() - except Exception as e: - msg = f"Subprocess error: {e}" - raise RuntimeError(msg) from e - raise RuntimeError(_("Execution of subprocesses was not successful")) + :param patch: set False if you does not want to patch the results + """ + self.module.flags.overwrite = True + self.define_mapset_inputs() - if patch: - if self.move: - os.environ["GISRC"] = self.gisrc_dst - self.n_mset.current() - self.patch() - os.environ["GISRC"] = self.gisrc_src - self.mset.current() - routputs = [ - self.out_prefix + o for o in select(self.module.outputs, "raster") - ] - copy_rasters( - routputs, - self.gisrc_dst, - self.gisrc_src, - processes=self.processes, - ) + if self.debug: + for wrk in self.get_works(): + cmd_exe(wrk) else: - self.patch() - - if self.log: - from grass.lib.gis import G_tempfile - - tmp, dummy = os.path.split(G_tempfile()) - tmpdir = os.path.join(tmp, self.module.name) - for k in self.module.outputs: - par = self.module.outputs[k] - if par.typedesc == "raster" and par.value: - dirpath = os.path.join(tmpdir, par.name) - Path(dirpath).mkdir(parents=True, exist_ok=True) - fil = open(os.path.join(dirpath, self.out_prefix + par.value), "w+") - fil.close() - - -def _clean(self): - """Cleanup temporary data""" - self.clean_location() - self.rm_tiles() - if self.n_mset: - gisdbase, location = os.path.split(self.move) - self.clean_location(Location(location, gisdbase)) - # rm temporary gis_rc - Path(self.gisrc_dst).unlink() - self.gisrc_dst = None - sht.rmtree(os.path.join(self.move, "PERMANENT")) - sht.rmtree(os.path.join(self.move, self.mset.name)) - - -def patch(self): - """Patch the final results.""" - bboxes = split_region_tiles(width=self.width, height=self.height) - noutputs = 0 - for otmap in self.module.outputs: - otm = self.module.outputs[otmap] - if otm.typedesc == "raster" and otm.value: - if self.patch_backend == "RasterRow": - rpatch_map( - raster=otm.value, - mapset=self.mset.name, - mset_str=self.msetstr, - bbox_list=bboxes, - overwrite=self.module.flags.overwrite, - start_row=self.start_row, - start_col=self.start_col, - prefix=self.out_prefix, + ctx = mltp.get_context("spawn") + ctx.set_executable(sys.executable) + pool = ctx.Pool(processes=self.processes) + result = pool.map_async(cmd_exe, self.get_works()) + result.wait() + pool.close() + pool.join() + if not result.successful(): + raise RuntimeError(_("Execution of subprocesses was not successful")) + + if patch: + if self.move: + os.environ["GISRC"] = self.gisrc_dst + self.n_mset.current() + self.patch() + os.environ["GISRC"] = self.gisrc_src + self.mset.current() + # copy the outputs from dst => src + routputs = [ + self.out_prefix + o for o in select(self.module.outputs, "raster") + ] + copy_rasters( + routputs, self.gisrc_dst, self.gisrc_src, processes=self.processes ) else: - rpatch_map_r_patch_backend( - raster=otm.value, - mset_str=self.msetstr, - bbox_list=bboxes, - overwrite=self.module.flags.overwrite, - start_row=self.start_row, - start_col=self.start_col, - prefix=self.out_prefix, - processes=self.processes, - ) - noutputs += 1 - if noutputs < 1: - msg = "No raster output option defined for <{}>".format(self.module.name) - if self.module.name == "r.mapcalc": - msg += ". Use <{}.simple> instead".format(self.module.name) - raise RuntimeError(msg) - - -def rm_tiles(self): - """Remove all the tiles.""" - # if split, remove tiles - if self.inlist: - grm = Module("g.remove") - for key in self.inlist: - grm(flags="f", type="raster", name=self.inlist[key]) + self.patch() + + if self.log: + # record in the temp directory + from grass.lib.gis import G_tempfile + + tmp, dummy = os.path.split(G_tempfile()) + tmpdir = os.path.join(tmp, self.module.name) + for k in self.module.outputs: + par = self.module.outputs[k] + if par.typedesc == "raster" and par.value: + dirpath = os.path.join(tmpdir, par.name) + Path(dirpath).mkdir(parents=True, exist_ok=True) + fil = open(os.path.join(dirpath, self.out_prefix + par.value), "w+") + fil.close() + + def _clean(self): + """Cleanup temporary data""" + self.clean_location() + self.rm_tiles() + if self.n_mset: + gisdbase, location = os.path.split(self.move) + self.clean_location(Location(location, gisdbase)) + # rm temporary gis_rc + Path(self.gisrc_dst).unlink() + self.gisrc_dst = None + sht.rmtree(os.path.join(self.move, "PERMANENT")) + sht.rmtree(os.path.join(self.move, self.mset.name)) + + def patch(self): + """Patch the final results.""" + bboxes = split_region_tiles(width=self.width, height=self.height) + noutputs = 0 + for otmap in self.module.outputs: + otm = self.module.outputs[otmap] + if otm.typedesc == "raster" and otm.value: + if self.patch_backend == "RasterRow": + rpatch_map( + raster=otm.value, + mapset=self.mset.name, + mset_str=self.msetstr, + bbox_list=bboxes, + overwrite=self.module.flags.overwrite, + start_row=self.start_row, + start_col=self.start_col, + prefix=self.out_prefix, + ) + else: + rpatch_map_r_patch_backend( + raster=otm.value, + mset_str=self.msetstr, + bbox_list=bboxes, + overwrite=self.module.flags.overwrite, + start_row=self.start_row, + start_col=self.start_col, + prefix=self.out_prefix, + processes=self.processes, + ) + noutputs += 1 + if noutputs < 1: + msg = "No raster output option defined for <{}>".format(self.module.name) + if self.module.name == "r.mapcalc": + msg += ". Use <{}.simple> instead".format(self.module.name) + raise RuntimeError(msg) + + def rm_tiles(self): + """Remove all the tiles.""" + # if split, remove tiles + if self.inlist: + grm = Module("g.remove") + for key in self.inlist: + grm(flags="f", type="raster", name=self.inlist[key]) From 834728bc706e249076e01ca70a0050ce8de1127b Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Sun, 12 Apr 2026 18:56:58 +0530 Subject: [PATCH 33/39] fix: remove forced spawn context, keep pickling fixes for Python 3.14 --- python/grass/pygrass/modules/grid/grid.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/python/grass/pygrass/modules/grid/grid.py b/python/grass/pygrass/modules/grid/grid.py index 99879aeb1c9..e4729b78eda 100644 --- a/python/grass/pygrass/modules/grid/grid.py +++ b/python/grass/pygrass/modules/grid/grid.py @@ -683,9 +683,7 @@ def _actual_run(self, patch): for wrk in self.get_works(): cmd_exe(wrk) else: - ctx = mltp.get_context("spawn") - ctx.set_executable(sys.executable) - pool = ctx.Pool(processes=self.processes) + pool = mltp.Pool(processes=self.processes) result = pool.map_async(cmd_exe, self.get_works()) result.wait() pool.close() From 66dcb3e1226f970e523aae9963e658fcf40ad10b Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Sun, 12 Apr 2026 21:52:34 +0530 Subject: [PATCH 34/39] debug: print worker exitcode in test --- python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index 7c305500ed7..a2a1d44f33a 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -45,6 +45,7 @@ def run_in_subprocess(function, check=True): process = ctx.Process(target=function) process.start() process.join() + print(f"Worker exitcode: {process.exitcode}", flush=True) if check and process.exitcode != 0: msg = f"Subprocess failed with exit code {process.exitcode}" raise RuntimeError(msg) From 05cf371ac7c0a44a478c0f9826b09fe4d94a1e50 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Mon, 13 Apr 2026 12:54:38 +0530 Subject: [PATCH 35/39] debug: print full traceback in subprocess --- .../grass/pygrass/modules/tests/grass_pygrass_grid_test.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index a2a1d44f33a..fefb4977a10 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -24,7 +24,10 @@ def _run_grid_module(module_name, project, run_kwargs=None, **kwargs): grid = GridModule(module_name, **kwargs) grid.run(**run_kwargs) except Exception as e: - print(f"GridModule failed: {e}", file=sys.stderr) + import traceback + + traceback.print_exc() + print("GridModule failed: " + str(e), file=sys.stderr) sys.exit(1) @@ -45,7 +48,6 @@ def run_in_subprocess(function, check=True): process = ctx.Process(target=function) process.start() process.join() - print(f"Worker exitcode: {process.exitcode}", flush=True) if check and process.exitcode != 0: msg = f"Subprocess failed with exit code {process.exitcode}" raise RuntimeError(msg) From 7c4d5c7a81dba89daa25eed61b0cc1c4e2b1804c Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Wed, 15 Apr 2026 03:04:05 +0530 Subject: [PATCH 36/39] style: apply ruff and format fixes --- python/grass/pygrass/modules/grid/grid.py | 10 ++++++++-- python/grass/pygrass/modules/interface/module.py | 10 ++++++++++ .../modules/tests/grass_pygrass_grid_test.py | 16 +++++++++++++++- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/python/grass/pygrass/modules/grid/grid.py b/python/grass/pygrass/modules/grid/grid.py index e4729b78eda..26e05addf25 100644 --- a/python/grass/pygrass/modules/grid/grid.py +++ b/python/grass/pygrass/modules/grid/grid.py @@ -683,13 +683,19 @@ def _actual_run(self, patch): for wrk in self.get_works(): cmd_exe(wrk) else: - pool = mltp.Pool(processes=self.processes) + ctx = mltp.get_context("spawn") + ctx.set_executable(sys.executable) + pool = ctx.Pool(processes=self.processes) result = pool.map_async(cmd_exe, self.get_works()) result.wait() pool.close() pool.join() if not result.successful(): - raise RuntimeError(_("Execution of subprocesses was not successful")) + try: + result.get() + except Exception as e: + msg = f"Worker failed with: {e}" + raise RuntimeError(msg) from e if patch: if self.move: diff --git a/python/grass/pygrass/modules/interface/module.py b/python/grass/pygrass/modules/interface/module.py index d2f06d097dc..4fc97f20137 100644 --- a/python/grass/pygrass/modules/interface/module.py +++ b/python/grass/pygrass/modules/interface/module.py @@ -745,6 +745,16 @@ def __str__(self): def __repr__(self): return "Module(%r)" % self.name + def __getstate__(self): + """Make Module picklable by excluding unpicklable popen object.""" + state = self.__dict__.copy() + state["_popen"] = None + return state + + def __setstate__(self, state): + """Restore Module from pickled state.""" + self.__dict__.update(state) + @docstring_property(__doc__) def __doc__(self): """{cmd_name}({cmd_params})""" diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index fefb4977a10..e67857014cc 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -13,9 +13,23 @@ def _run_grid_module(module_name, project, run_kwargs=None, **kwargs): run_kwargs = {} import sys + import os + + gisbase = os.environ.get("GISBASE", "") + if gisbase: + # Add GRASS's Python lib paths so grass.lib (C extensions) can be found + grass_python_paths = [ + p for p in sys.path if p and p.startswith(os.path.join(gisbase, "Python")) + ] + for p in grass_python_paths: + if p not in sys.path: + sys.path.insert(0, p) + os.environ["PYTHONPATH"] = os.pathsep.join( + grass_python_paths + [p for p in sys.path if p] + ) + import grass.script as gs - # Initialize GRASS in subprocess gs.setup.init(project) from grass.pygrass.modules.grid.grid import GridModule From 1127d69df1e4f59cde1a92ada780d52ac134e8fe Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Wed, 15 Apr 2026 23:51:03 +0530 Subject: [PATCH 37/39] ci: temporarily add Python 3.14 to pytest workflow to verify fix --- .github/workflows/pytest.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index d1370aa457e..17a00d3726f 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -25,6 +25,7 @@ jobs: python-version: - "3.10" - "3.13" + - "3.14" fail-fast: true runs-on: ${{ matrix.os }} From 995cba033e69e1dc364b0d572c61c0f9759d0e64 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Sun, 19 Apr 2026 10:59:42 +0530 Subject: [PATCH 38/39] fix: address ruff linting issues in GridModule and tests --- python/grass/pygrass/modules/grid/grid.py | 11 +++- .../modules/tests/grass_pygrass_grid_test.py | 59 ++++++++++++++++++- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/python/grass/pygrass/modules/grid/grid.py b/python/grass/pygrass/modules/grid/grid.py index 26e05addf25..6aeec060ada 100644 --- a/python/grass/pygrass/modules/grid/grid.py +++ b/python/grass/pygrass/modules/grid/grid.py @@ -352,6 +352,15 @@ def get_cmd(cmdd): ] +def _grass_worker_init(): + """Initialize GRASS environment in spawned worker processes (Windows/spawn).""" + gisbase = os.environ.get("GISBASE", "") + if gisbase and gisbase not in sys.path: + etc_python = os.path.join(gisbase, "etc", "python") + if etc_python not in sys.path: + sys.path.insert(0, etc_python) + + def cmd_exe(args): """Create a mapset, and execute a cmd inside. @@ -685,7 +694,7 @@ def _actual_run(self, patch): else: ctx = mltp.get_context("spawn") ctx.set_executable(sys.executable) - pool = ctx.Pool(processes=self.processes) + pool = ctx.Pool(processes=self.processes, initializer=_grass_worker_init) result = pool.map_async(cmd_exe, self.get_works()) result.wait() pool.close() diff --git a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py index e67857014cc..4be0d7fc6d2 100644 --- a/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py +++ b/python/grass/pygrass/modules/tests/grass_pygrass_grid_test.py @@ -55,11 +55,63 @@ def max_processes(): # to separate individual GridModule calls. +def _run_with_env(env, sys_path, function): + """Set environment and sys.path then run function (for spawn subprocess)""" + import os + from pathlib import Path + import sys + + os.environ.update(env) + gisbase = env.get("GISBASE", "") + # Put GRASS installed paths FIRST so grass.lib (C extensions) are found + # before the development grass package which has no grass.lib + if gisbase: + # Ensure grass.lib (C extensions) are findable by putting etc/python first + etc_python = os.path.join(gisbase, "etc", "python") + # etc_python MUST be at index 0 so installed grass.lib (C extension) + # wins over source checkout grass package which has no grass.lib + grass_paths = [ + p for p in sys_path if p and p.startswith(gisbase) and p != etc_python + ] + # Exclude ANY path that has a "grass" subdirectory and is not under + # GISBASE -- such paths pollute the grass namespace package and hide + # grass.lib (C extensions) which only exist in the installed GRASS. + other_paths = [ + p + for p in sys_path + if p and not p.startswith(gisbase) and not Path(p, "grass").is_dir() + ] + sys.path[:] = [etc_python] + grass_paths + other_paths + # CRITICAL: grass was already imported during subprocess unpickling + # using the parent's sys.path (spawn sends it automatically). + # grass.__path__ therefore points to the source checkout which has + # no grass.lib. Clear all grass.* from sys.modules so they get + # re-imported cleanly using the corrected sys.path above. + for key in list(sys.modules.keys()): + if key == "grass" or key.startswith("grass."): + del sys.modules[key] + if hasattr(os, "add_dll_directory"): + for dll_dir in [ + os.path.join(gisbase, "bin"), + os.path.join(gisbase, "lib"), + os.path.join(gisbase, "extrabin"), + ]: + if Path(dll_dir).is_dir(): + os.add_dll_directory(dll_dir) + else: + sys.path[:] = sys_path + function() + + def run_in_subprocess(function, check=True): """Run function in a separate process""" ctx = multiprocessing.get_context("spawn") ctx.set_executable(sys.executable) # force user's Python, not GRASS's - process = ctx.Process(target=function) + import os + + process = ctx.Process( + target=_run_with_env, args=(dict(os.environ), list(sys.path), function) + ) process.start() process.join() if check and process.exitcode != 0: @@ -189,8 +241,9 @@ def test_cleans(tmp_path, clean, surface): aspect="aspect", mapset_prefix=mapset_prefix, ), - check=multiprocessing.get_start_method() == "spawn" - or surface != "non_exist_surface", + # Don't check exit code when surface is intentionally missing; + # the subprocess is expected to fail in that case. + check=surface != "non_exist_surface", ) prefixed = 0 From 5f641c39870286a630552d3e683e657080310ea9 Mon Sep 17 00:00:00 2001 From: SyedAhad01 Date: Sun, 19 Apr 2026 11:05:42 +0530 Subject: [PATCH 39/39] ci: add Python 3.14 to pytest workflow --- .github/workflows/pytest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 17a00d3726f..bfcbe391cb0 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -26,7 +26,7 @@ jobs: - "3.10" - "3.13" - "3.14" - fail-fast: true + fail-fast: false runs-on: ${{ matrix.os }} env: